diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts index 8ccb0c8ce..671d20431 100644 --- a/blog/manifest.gen.ts +++ b/blog/manifest.gen.ts @@ -38,7 +38,8 @@ import * as $$$$$$14 from "./sections/blocks/Quote.tsx"; import * as $$$$$$15 from "./sections/blocks/Stat.tsx"; import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx"; import * as $$$$$$17 from "./sections/blocks/Steps.tsx"; -import * as $$$$$$18 from "./sections/blocks/Video.tsx"; +import * as $$$$$$18 from "./sections/blocks/Table.tsx"; +import * as $$$$$$19 from "./sections/blocks/Video.tsx"; import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx"; import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx"; import * as $$$$$$2 from "./sections/Template.tsx"; @@ -80,7 +81,8 @@ const manifest = { "blog/sections/blocks/Stat.tsx": $$$$$$15, "blog/sections/blocks/StatGroup.tsx": $$$$$$16, "blog/sections/blocks/Steps.tsx": $$$$$$17, - "blog/sections/blocks/Video.tsx": $$$$$$18, + "blog/sections/blocks/Table.tsx": $$$$$$18, + "blog/sections/blocks/Video.tsx": $$$$$$19, "blog/sections/Seo/SeoBlogPost.tsx": $$$$$$0, "blog/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1, "blog/sections/Template.tsx": $$$$$$2, diff --git a/blog/sections/blocks/Table.tsx b/blog/sections/blocks/Table.tsx new file mode 100644 index 000000000..b8632855c --- /dev/null +++ b/blog/sections/blocks/Table.tsx @@ -0,0 +1,76 @@ +import { hardSanitize } from "../../utils/hardSanitize.ts"; + +export interface Props { + /** JSON-encoded string[] — optional header row */ + headers?: string[] | string; + /** JSON-encoded string[][] — body rows × cells */ + rows?: string[][] | string; +} + +function parseHeaders(value: string[] | string | undefined): string[] { + if (Array.isArray(value)) return value.map((c) => String(c ?? "")); + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map((c) => String(c ?? "")); + } catch { /* ignore */ } + } + return []; +} + +function parseRows(value: string[][] | string | undefined): string[][] { + const toRow = (row: unknown): string[] => + Array.isArray(row) ? row.map((c) => String(c ?? "")) : []; + + if (Array.isArray(value)) return value.map(toRow); + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map(toRow); + } catch { /* ignore */ } + } + return []; +} + +export default function Table({ headers, rows }: Props) { + const head = parseHeaders(headers); + const body = parseRows(rows); + + if (head.length === 0 && body.length === 0) return null; + + const cellClass = + "px-4 py-3 text-sm leading-normal align-top [&_a]:text-accent [&_a]:underline [&_strong]:font-semibold [&_strong]:text-base"; + + return ( +
+ + {head.length > 0 && ( + + + {head.map((cell, i) => ( + + + )} + + {body.map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/blog/utils/blocksToSections.ts b/blog/utils/blocksToSections.ts index 8aef79ac5..54f1c78c0 100644 --- a/blog/utils/blocksToSections.ts +++ b/blog/utils/blocksToSections.ts @@ -120,6 +120,12 @@ function blockToSection( right: content.right, }); + case "table": + return toSection(`${BASE}/Table.tsx`, { + headers: content.headers, + rows: content.rows, + }); + case "image": return toSection(`${BASE}/BlockImage.tsx`, { url: content.url, diff --git a/blog/utils/hardSanitize.ts b/blog/utils/hardSanitize.ts new file mode 100644 index 000000000..62e75c3f4 --- /dev/null +++ b/blog/utils/hardSanitize.ts @@ -0,0 +1,109 @@ +/** + * Stricter, dependency-free HTML sanitizer for blocks that render CMS-provided + * markup in a table-like context (see Table.tsx). It is intentionally separate + * from the shared `sanitizeHtml` so hardening this path cannot regress the many + * other blocks that rely on the lighter sanitizer. + * + * On top of the shared sanitizer's guarantees it also: + * - Removes dangerous elements together with their content (script, style, + * iframe, object, embed, applet, form, svg, math, template, noscript, base, + * link, meta, frame, frameset), plus any leftover open/close/self-closing tags + * - Strips inline event-handler attributes (on*), srcdoc and inline style + * - Neutralizes javascript:, data: and vbscript: protocols in url-bearing + * attributes (href, src, action, formaction, xlink:href), decoding entities + * and stripping whitespace/control chars first so obfuscated schemes are + * caught, while harmless values like "data-*" or "javascriptX" are preserved + * + * Note: this is a pragmatic denylist — not a full HTML parser. Keep the content + * model simple (text + basic inline/formatting markup). + */ +const DANGEROUS_ELEMENTS = [ + "script", + "style", + "iframe", + "object", + "embed", + "applet", + "form", + "svg", + "math", + "template", + "noscript", + "base", + "link", + "meta", + "frame", + "frameset", +]; + +const URL_ATTRS = "href|src|action|formaction|xlink:href"; +const DANGEROUS_PROTOCOLS = "javascript|data|vbscript"; + +// Captures a url-bearing attribute and its value (double/single-quoted or bare). +const URL_ATTR_RE = new RegExp( + `\\b(${URL_ATTRS})\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, + "gi", +); +// A dangerous scheme must be a real scheme: name immediately followed by ":". +const DANGEROUS_SCHEME_RE = new RegExp(`^(?:${DANGEROUS_PROTOCOLS}):`, "i"); + +function toCodePoint(n: number): string { + return Number.isFinite(n) && n >= 0 && n <= 0x10ffff + ? String.fromCodePoint(n) + : ""; +} + +/** Decode the HTML entities most commonly used to smuggle a scheme past a filter. */ +function decodeEntities(value: string): string { + return value + .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => toCodePoint(parseInt(hex, 16))) + .replace(/&#(\d+);?/g, (_, dec) => toCodePoint(parseInt(dec, 10))) + .replace(/:/gi, ":") + .replace(/&tab;/gi, "\t") + .replace(/&newline;/gi, "\n"); +} + +/** + * True when an attribute value resolves to a dangerous URL scheme. The value is + * decoded and stripped of whitespace/control chars first, since browsers ignore + * those within a scheme (e.g. `java\tscript:` and `javascript:`). + */ +function hasDangerousScheme(value: string): boolean { + const normalized = decodeEntities(value) + // Control chars are intentional: browsers strip C0 controls/whitespace from + // a URL scheme, so an attacker can hide one inside `javascript:`. + // deno-lint-ignore no-control-regex + .replace(/[\s\u0000-\u001f]+/g, "") + .toLowerCase(); + return DANGEROUS_SCHEME_RE.test(normalized); +} + +export function hardSanitize(raw: string | null | undefined): string { + if (!raw) return ""; + + let html = raw; + + for (const tag of DANGEROUS_ELEMENTS) { + // Remove the element with its content, then any stray open/close/self-closing tag. + html = html + .replace(new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "") + .replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi"), ""); + } + + return html + // Inline event handlers (onclick, onerror, …) + .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") + // srcdoc (smuggles an inline document into iframes) and inline styles + .replace(/\s+(srcdoc|style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") + // Neutralize dangerous protocols in url-bearing attributes. One pass handles + // quoted and unquoted values identically: decode/normalize, then require a + // real dangerous scheme followed by ":" (so harmless "data-*"/"javascriptX" + // values are left intact). + .replace(URL_ATTR_RE, (match, attr, value) => { + const quote = value[0] === '"' || value[0] === "'" ? value[0] : ""; + const inner = quote ? value.slice(1, -1) : value; + if (!hasDangerousScheme(inner)) return match; + const q = quote || '"'; + return `${attr}=${q}#${q}`; + }); +} diff --git a/deno.json b/deno.json index 42cd13bc0..302330e24 100644 --- a/deno.json +++ b/deno.json @@ -56,7 +56,8 @@ "exclude": [ "static", "README.md", - "**/README.md" + "**/README.md", + ".context" ], "compilerOptions": { "jsx": "react-jsx",