src/lib/satteri-guard.mjs (6217B)
1 /**
2 * satteri-guard — the markup a post may carry, enforced when it renders.
3 *
4 * Sätteri emits raw HTML verbatim and sanitises nothing, with rawHtml on or off, and
5 * `headingAttributes` copies every key=value in `## Title {…}` onto the heading. Without
6 * this a post could ship a <script>, an onmouseover on a heading or a javascript: link on
7 * a green build. This is a security blog: quoting a payload is ordinary writing here, and
8 * one missed pair of backticks turns a quotation into markup.
9 *
10 * Every rule throws. The glob loader logs the message against the file, and the page's
11 * render guard (src/lib/rendered.ts) then fails the build, so nothing ships.
12 *
13 * raw HTML only the inline elements Markdown has no syntax for and the article
14 * stylesheet styles — kbd, mark, abbr with a title, sub, sup, ins — plus br
15 * for a line break inside a table cell. No other attribute, no comment.
16 * headings id and class, which is what `{#id .class}` is for.
17 * links http, https, mailto, or a path on this site.
18 * images this site only. A remote image hands every reader's address to its host,
19 * and the Content-Security-Policy would block it anyway.
20 *
21 * It must run first among the hast plugins: satteriDiagram inlines its SVG as a raw node,
22 * and that one is ours.
23 */
24 const SITE = new URL('https://sqzer.com/');
25 const LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
26
27 /* The allowed tags, removed in one pass. Whatever is left must not open markup the way the
28 HTML tokenizer defines it: `<` followed by a letter, `/`, `!` or `?`. A `<` before a
29 space or a digit is text to a browser, so "a < b" and "<3" stay legal.
30 Whitespace is the tokenizer's set, not JavaScript's \s, which would also take a
31 no-break space the browser reads as part of a name. A quoted title may hold `>`, which
32 is text inside quotes to both CommonMark and the browser, but not `<`: that keeps a match
33 from reaching into another tag, so every `<` is an allowed tag or is left to be caught. */
34 const WS = '[\\t\\n\\f\\r ]';
35 const ALLOWED_TAG = new RegExp(
36 `<\\/?(?:kbd|mark|sub|sup|ins)${WS}*>|<\\/abbr${WS}*>` +
37 `|<abbr${WS}+title${WS}*=${WS}*(?:"[^"<]*"|'[^'<]*')${WS}*>|<br${WS}*\\/?>`,
38 'gi',
39 );
40 const OPENS_MARKUP = /<[A-Za-z!?/]/;
41
42 /* Sätteri percent-encodes [ and ] anywhere in a link destination, so http://[::1]:8080/
43 arrives as http://%5B::1%5D:8080/, which neither the URL parser nor a browser accepts.
44 The brackets are put back in the authority of an http(s) or protocol-relative link only,
45 and that repaired address is what ships. */
46 const IPV6_HOST = /^((?:https?:)?\/\/(?:[^/?#@[\]]*@)?)%5B([0-9A-Fa-f:.]+)%5D(?=[:/?#]|$)/i;
47
48 const clip = (s) => (s.length > 80 ? `${s.slice(0, 77)}...` : s);
49 const resolve = (value) => {
50 try { return new URL(String(value), SITE); } catch { return null; }
51 };
52
53 const commentError = (text) =>
54 new Error(
55 `satteri-guard: HTML comment ${JSON.stringify(clip(text))} would be published in the ` +
56 'page source, and the repository is public as well. Delete it. If it was separating ' +
57 'two lists, start the second with a different marker instead ("-" then "*", or "1." ' +
58 'then "1)"): deleting the comment alone merges them into one list.',
59 );
60
61 export default function satteriGuard() {
62 return {
63 name: 'guard',
64 raw(node) {
65 const html = node.value.trim();
66 // With rawHtml off a comment arrives as raw text like any other markup.
67 if (html.startsWith('<!--')) throw commentError(html);
68 if (OPENS_MARKUP.test(html.replace(ALLOWED_TAG, '')))
69 throw new Error(
70 `satteri-guard: raw HTML ${JSON.stringify(clip(html))} is not allowed. ` +
71 'A literal < in prose takes a backslash (\\<Inception>, List\\<String>). Code, ' +
72 'commands, placeholders and quoted markup go in backticks or a code fence, and a ' +
73 'key goes in <kbd>. Only <kbd>, <mark>, <abbr title>, <sub>, <sup>, <ins> and ' +
74 '<br> may be written as HTML.',
75 );
76 },
77 comment(node) {
78 throw commentError(`<!--${node.value}-->`);
79 },
80 element: [
81 {
82 filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
83 visit(node, ctx) {
84 const extra = Object.keys(node.properties ?? {}).filter((k) => k !== 'id' && k !== 'className');
85 if (extra.length)
86 throw new Error(
87 `satteri-guard: heading ${JSON.stringify(clip(ctx.textContent(node)))} carries ` +
88 `${extra.join(', ')}. A {…} at the end of a heading is read as attributes, and ` +
89 'may only set #id and .class. If the braces are text, escape the closing one ' +
90 '({a,b,c\\}), put them in backticks, or end the line with " ##". Escaping ' +
91 'only the opening one (\\{) does not work.',
92 );
93 },
94 },
95 {
96 filter: ['a'],
97 visit(node, ctx) {
98 if (node.properties?.href == null) return;
99 const href = String(node.properties.href);
100 const repaired = href.replace(IPV6_HOST, '$1[$2]');
101 const url = resolve(repaired);
102 if (!url)
103 throw new Error(
104 `satteri-guard: link to ${JSON.stringify(clip(href))} could not be parsed as a URL. ` +
105 'Check the host and the port.',
106 );
107 if (!LINK_PROTOCOLS.has(url.protocol))
108 throw new Error(
109 `satteri-guard: link to ${JSON.stringify(clip(href))} is not allowed. ` +
110 'Links may be http, https, mailto or a path on this site.',
111 );
112 if (repaired !== href) ctx.setProperty(node, 'href', repaired);
113 },
114 },
115 {
116 filter: ['img'],
117 visit(node) {
118 const src = node.properties?.src;
119 const url = src == null ? null : resolve(src);
120 if (!url || url.origin !== SITE.origin)
121 throw new Error(
122 `satteri-guard: image ${JSON.stringify(clip(String(src)))} is not on this site. ` +
123 'Put it in public/uploads/ and link it as /uploads/….',
124 );
125 },
126 },
127 ],
128 };
129 }