blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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 }