Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions blog/manifest.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
76 changes: 76 additions & 0 deletions blog/sections/blocks/Table.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class="my-8 border border-line rounded-brand overflow-x-auto">
<table class="w-full border-collapse text-left">
{head.length > 0 && (
<thead>
<tr class="bg-alt">
{head.map((cell, i) => (
<th
key={i}
class="px-4 py-3 text-xs font-semibold tracking-caps uppercase border-b-2 border-line"
dangerouslySetInnerHTML={{ __html: hardSanitize(cell) }}
/>
))}
</tr>
</thead>
)}
<tbody>
{body.map((row, r) => (
<tr key={r} class="border-b border-line-subtle last:border-b-0">
{row.map((cell, c) => (
<td
key={c}
class={cellClass}
dangerouslySetInnerHTML={{ __html: hardSanitize(cell) }}
/>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
6 changes: 6 additions & 0 deletions blog/utils/blocksToSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions blog/utils/hardSanitize.ts
Original file line number Diff line number Diff line change
@@ -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(/&colon;/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 `&#106;avascript:`).
*/
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}`;
});
}
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
"exclude": [
"static",
"README.md",
"**/README.md"
"**/README.md",
".context"
],
"compilerOptions": {
"jsx": "react-jsx",
Expand Down
Loading