blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 0442f99e782c241881da99fa5235f307379d33d7
parent cb20946600eb02e86b7607c64bb8aae1326b0888
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 03:54:28 +0900

feat: refuse unsafe markup at build time and ship a Content-Security-Policy

Satteri emits raw HTML verbatim with rawHtml on or off, headingAttributes
copies any key=value onto a heading, and link destinations are not
filtered, so a post could ship a <script>, an onmouseover or a
javascript: link on a green build. On a blog that quotes payloads, one
missed pair of backticks is enough.

satteriGuard runs first among the hast plugins and throws on:
- raw HTML other than kbd, mark, abbr with a title, sub, sup, ins and br,
including HTML comments
- heading attributes other than id and class
- links that are not http, https, mailto or a path on this site
- images not served from this site
The loader logs the message against the file and the render guard fails
the build. Tested with 25 hostile posts: 22 fail the build by name, and
the other 3 are not markup to the parser and render as inert text.

Astro's security.csp now emits a CSP <meta> on every page, since Pages
cannot send headers: default-src 'none', same-origin images and fonts,
script hashes Astro computes itself, the baked diagrams' <style> hashes
computed in the config, and 'unsafe-inline' only for style attributes,
which Shiki needs. In Chrome, all 36 pages report no violation and the
copy buttons, contents rail and diagram colours behave as before; on a
page with injected markup, an onerror handler, an inline script, a
javascript: link and remote images are all blocked.

The rawHtml comment no longer claims the flag governs sanitisation.

Diffstat:
Mastro.config.mjs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/lib/satteri-guard.mjs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 154 insertion(+), 4 deletion(-)

diff --git a/astro.config.mjs b/astro.config.mjs
@@ -1,12 +1,30 @@
 // @ts-check
+import { createHash } from 'node:crypto';
+import { readdirSync, readFileSync } from 'node:fs';
 import { defineConfig } from 'astro/config';
 import sitemap from '@astrojs/sitemap';
 import { satteri } from '@astrojs/markdown-satteri';
 import satteriFigure from './src/lib/satteri-figure.mjs';
+import satteriGuard from './src/lib/satteri-guard.mjs';
 import satteriProse from './src/lib/satteri-prose.mjs';
 import satteriDiagram from './src/lib/satteri-diagram.mjs';
 import satteriKorean from './src/lib/satteri-korean.mjs';
 
+/**
+ * sha256 of the <style> inside each baked diagram, for the Content-Security-Policy below.
+ *
+ * Astro hashes the scripts and styles it emits itself, and once style-src-elem carries a
+ * hash, browsers ignore 'unsafe-inline' for <style> elements. The diagrams' own <style>
+ * arrives inside Markdown HTML that Astro never looks at, so without these three it would
+ * be blocked and every diagram would lose its palette. The files hold the text exactly as
+ * it is inlined (no entities, satteriDiagram inserts it verbatim), so it is hashed as read.
+ * prebuild bakes any new diagram before this file is loaded.
+ */
+const DIAGRAM_STYLE_HASHES = readdirSync('src/lib/diagrams')
+  .filter((f) => f.endsWith('.svg'))
+  .flatMap((f) => [...readFileSync(`src/lib/diagrams/${f}`, 'utf8').matchAll(/<style>([\s\S]*?)<\/style>/g)])
+  .map(([, css]) => `sha256-${createHash('sha256').update(css).digest('base64')}`);
+
 /**
  * Grammars preloaded into the Shiki highlighter: the fence languages the corpus uses.
  *
@@ -98,19 +116,49 @@ export default defineConfig({
   // Every canonical URL ends in a trailing slash; the directory format emits dist/<path>/index.html.
   trailingSlash: 'always',
   build: { format: 'directory' },
+  /*
+   * Content-Security-Policy, emitted by Astro as a <meta> in every page's <head>, since
+   * GitHub Pages cannot send response headers. Astro computes the hashes of the scripts it
+   * inlines, so editing the article's two scripts needs no change here.
+   *
+   * Nothing on this site loads from another origin, so everything is 'self' or 'none'.
+   * Shiki colours every token with a style="" attribute, so 'unsafe-inline' is granted to
+   * style-src-attr and nowhere else; a style attribute cannot run script. That grant is
+   * the answer to the "Shiki ... not compatible with Content Security Policy" warning
+   * Astro prints on every build. What a <meta> cannot carry: frame-ancestors, report-uri
+   * and sandbox are ignored there.
+   */
+  security: {
+    csp: {
+      directives: [
+        "default-src 'none'",
+        "img-src 'self'",
+        "font-src 'self'",
+        "base-uri 'none'",
+        "form-action 'none'",
+      ],
+      styleDirective: {
+        resources: ["'self'", { resource: "'unsafe-inline'", kind: 'attribute' }],
+        hashes: DIAGRAM_STYLE_HASHES,
+      },
+    },
+  },
   markdown: {
     // Sätteri is the default Markdown processor in Astro 7, and `markdown.rehypePlugins`
     // now throws unless @astrojs/markdown-remark is installed alongside it. Plugins go
     // here instead, as Sätteri visitor objects.
     //   mdast: satteriKorean  — CommonMark inline rules that misfire on Korean prose
     //   mdast: satteriFigure  — <figure>/<figcaption> out of the corpus's own convention
+    //   hast:  satteriGuard   — refuses raw HTML, heading attributes, link schemes and
+    //                           image hosts a post must not carry; the build fails
     //   hast:  satteriProse   — heading demotion, intrinsic image size, table scrollers
     //   hast:  satteriDiagram — a mermaid fence -> the SVG baked from it before the build
     // Ordering matters: hastPlugins run after highlighting and BEFORE heading-id
-    // collection, so `headings` from render() reports the demoted depths.
+    // collection, so `headings` from render() reports the demoted depths. satteriGuard
+    // goes first, before satteriDiagram adds the one raw node the site trusts.
     processor: satteri({
       mdastPlugins: [satteriKorean(), satteriFigure()],
-      hastPlugins: [satteriProse({ publicDir: 'public' }), satteriDiagram()],
+      hastPlugins: [satteriGuard(), satteriProse({ publicDir: 'public' }), satteriDiagram()],
       /*
        * Parser flags. Astro hands Sätteri only `{ gfm, smartPunctuation }`. Every other
        * entry in `Features` defaults off except `frontmatter`, which Sätteri turns on unless
@@ -158,8 +206,9 @@ export default defineConfig({
          *   directive     `:::note` blocks need styling per directive to mean anything.
          *   wikilinks     no wiki.
          *   definitionList  0 in the corpus; a table or a list carries the same content.
-         *   rawHtml       leaving HTML as an opaque `raw` node is the safer default, and
-         *                 it is the switch that also governs sanitisation.
+         *   rawHtml       off, raw HTML stays an opaque `raw` node; on, it is re-parsed into
+         *                 elements. Either way it is emitted verbatim: this flag sanitises
+         *                 nothing. What limits raw HTML is satteriGuard.
          */
       },
     }),
diff --git a/src/lib/satteri-guard.mjs b/src/lib/satteri-guard.mjs
@@ -0,0 +1,101 @@
+/**
+ * satteri-guard — the markup a post may carry, enforced when it renders.
+ *
+ * Sätteri emits raw HTML verbatim and sanitises nothing, with rawHtml on or off, and
+ * `headingAttributes` copies every key=value in `## Title {…}` onto the heading. Without
+ * this a post could ship a <script>, an onmouseover on a heading or a javascript: link on
+ * a green build. This is a security blog: quoting a payload is ordinary writing here, and
+ * one missed pair of backticks turns a quotation into markup.
+ *
+ * Every rule throws. The glob loader logs the message against the file, and the page's
+ * render guard (src/lib/rendered.ts) then fails the build, so nothing ships.
+ *
+ *   raw HTML   only the inline elements Markdown has no syntax for and the article
+ *              stylesheet styles — kbd, mark, abbr with a title, sub, sup, ins — plus br
+ *              for a line break inside a table cell. No other attribute, no comment.
+ *   headings   id and class, which is what `{#id .class}` is for.
+ *   links      http, https, mailto, or a path on this site.
+ *   images     this site only. A remote image hands every reader's address to its host,
+ *              and the Content-Security-Policy would block it anyway.
+ *
+ * It must run first among the hast plugins: satteriDiagram inlines its SVG as a raw node,
+ * and that one is ours.
+ */
+const SITE = new URL('https://blog.sqzer.com/');
+const LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
+
+/* The allowed tags, removed in one pass. Whatever is left must not open markup the way the
+   HTML tokenizer defines it: `<` followed by a letter, `/`, `!` or `?`. A `<` before a
+   space or a digit is text to a browser, so "a < b" and "<3" stay legal. */
+const ALLOWED_TAG =
+  /<\/?(?:kbd|mark|sub|sup|ins)\s*>|<\/abbr\s*>|<abbr\s+title\s*=\s*(?:"[^"<>]*"|'[^'<>]*')\s*>|<br\s*\/?>/gi;
+const OPENS_MARKUP = /<[A-Za-z!?/]/;
+
+const clip = (s) => (s.length > 80 ? `${s.slice(0, 77)}...` : s);
+const resolve = (value) => {
+  try { return new URL(String(value), SITE); } catch { return null; }
+};
+
+const commentError = (text) =>
+  new Error(
+    `satteri-guard: HTML comment ${JSON.stringify(clip(text))} would be published in the ` +
+      'page source. Delete it; the repository is public as well.',
+  );
+
+export default function satteriGuard() {
+  return {
+    name: 'guard',
+    raw(node) {
+      const html = node.value.trim();
+      // With rawHtml off a comment arrives as raw text like any other markup.
+      if (html.startsWith('<!--')) throw commentError(html);
+      if (OPENS_MARKUP.test(html.replace(ALLOWED_TAG, '')))
+        throw new Error(
+          `satteri-guard: raw HTML ${JSON.stringify(clip(html))} is not allowed. ` +
+            'Put markup you are quoting in backticks or a code fence; only <kbd>, <mark>, ' +
+            '<abbr title>, <sub>, <sup>, <ins> and <br> may be written as HTML.',
+        );
+    },
+    comment(node) {
+      throw commentError(`<!--${node.value}-->`);
+    },
+    element: [
+      {
+        filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+        visit(node, ctx) {
+          const extra = Object.keys(node.properties ?? {}).filter((k) => k !== 'id' && k !== 'className');
+          if (extra.length)
+            throw new Error(
+              `satteri-guard: heading ${JSON.stringify(clip(ctx.textContent(node)))} carries ` +
+                `${extra.join(', ')}. {…} after a heading may only set #id and .class.`,
+            );
+        },
+      },
+      {
+        filter: ['a'],
+        visit(node) {
+          const href = node.properties?.href;
+          if (href == null) return;
+          const url = resolve(href);
+          if (!url || !LINK_PROTOCOLS.has(url.protocol))
+            throw new Error(
+              `satteri-guard: link to ${JSON.stringify(clip(String(href)))} is not allowed. ` +
+                'Links may be http, https, mailto or a path on this site.',
+            );
+        },
+      },
+      {
+        filter: ['img'],
+        visit(node) {
+          const src = node.properties?.src;
+          const url = src == null ? null : resolve(src);
+          if (!url || url.origin !== SITE.origin)
+            throw new Error(
+              `satteri-guard: image ${JSON.stringify(clip(String(src)))} is not on this site. ` +
+                'Put it in public/uploads/ and link it as /uploads/….',
+            );
+        },
+      },
+    ],
+  };
+}