blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit bc2768e6bd9dcb505c109caa95e40115f0a80861
parent d8520215044afa06771ee574411264247170a757
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 05:01:58 +0900

fix: keep a repeated diagram's colours under the CSP, and gate the policy

A diagram drawn twice on one page is renamed for its second copy, and
that rename rewrites the copy's <style>. Only the committed file's
<style> was hashed, so the browser dropped the copy's stylesheet and the
diagram lost its palette on a green build. The rename now lives in
diagramCopy() in diagrams.mjs, used by satteriDiagram and by the config,
which hashes each copy up to the most any one document has.

check-dist now also holds every page to its policy: exactly one CSP
<meta>, nothing it governs ahead of it, and every inline <style> and
executable inline <script> listed by hash. A dropped style or script
becomes a failed build instead of a page that quietly looks wrong.
Tested by injecting a style, a script, a second policy and a script
before the policy, and by removing a diagram's hash: each fails.

The config comment now notes the hashes that are Astro's, not the
site's: five client-directive runtimes and the empty stylesheet.

Diffstat:
Mastro.config.mjs | 52+++++++++++++++++++++++++++++++++++++++++-----------
Mscripts/check-dist.mjs | 46+++++++++++++++++++++++++++++++++++++++++++---
Msrc/lib/diagrams.mjs | 9+++++++++
Msrc/lib/satteri-diagram.mjs | 6++----
4 files changed, 95 insertion(+), 18 deletion(-)

diff --git a/astro.config.mjs b/astro.config.mjs
@@ -9,21 +9,44 @@ 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';
+import { diagramCopy, diagramKey, findMermaidFences, readDiagram } from './src/lib/diagrams.mjs';
 
 /**
- * sha256 of the <style> inside each baked diagram, for the Content-Security-Policy below.
+ * sha256 of the <style> inside every diagram copy a page will carry, 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.
+ * Astro hashes the scripts and styles it emits itself, and once style-src 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 it would be blocked and
+ * the diagram would lose its palette. A diagram drawn twice on one page is renamed for the
+ * second copy, <style> included, so each copy is hashed as satteriDiagram will emit it:
+ * through diagramCopy(), up to the most copies of that diagram any one document has. The
+ * text is inlined verbatim with no entities, so it is hashed as it stands. prebuild bakes
+ * any new diagram before this file is loaded, and check-dist fails the build if a page
+ * still carries a <style> or <script> its policy does not list.
  */
-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')}`);
+function diagramCopies(dir, copies = new Map()) {
+  for (const entry of readdirSync(dir, { withFileTypes: true })) {
+    const file = `${dir}/${entry.name}`;
+    if (entry.isDirectory()) diagramCopies(file, copies);
+    else if (entry.name.endsWith('.md')) {
+      const onPage = new Map();
+      for (const { source } of findMermaidFences(readFileSync(file, 'utf8'))) {
+        const key = diagramKey(source);
+        onPage.set(key, (onPage.get(key) ?? 0) + 1);
+      }
+      for (const [key, n] of onPage) copies.set(key, Math.max(copies.get(key) ?? 0, n));
+    }
+  }
+  return copies;
+}
+const DIAGRAM_STYLE_HASHES = [...diagramCopies('content')].flatMap(([key, n]) => {
+  const svg = readDiagram(key);
+  if (!svg) return [];
+  return Array.from({ length: n }, (_, i) => diagramCopy(svg, key, i))
+    .flatMap((markup) => [...markup.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.
@@ -127,6 +150,13 @@ export default defineConfig({
    * 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.
+   *
+   * Two things in the emitted policy are Astro's and not the site's. script-src always lists
+   * five hashes for Astro's client-directive runtimes (idle, load, media, only, visible;
+   * astro/dist/runtime/client/*.prebuilt.js), added even on a site with no islands, and
+   * style-src lists sha256 of the empty string. Neither matches anything a page carries.
+   * Astro writes the <meta> where it renders the head, after the preload and icon links and
+   * before every stylesheet and script; check-dist holds it to that.
    */
   security: {
     csp: {
diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs
@@ -1,5 +1,7 @@
 /**
- * check-dist.mjs — the post-build check that every page meant to carry a body has one.
+ * check-dist.mjs — the post-build checks on what is about to be uploaded: every page meant
+ * to carry a body has one, and every page's Content-Security-Policy allows what the page
+ * actually inlines (see the section near the end).
  *
  * The pages already refuse to build an entry that came back unrendered (src/lib/rendered.ts).
  * This reads the output instead of the inputs, so it still holds for a page that renders
@@ -9,6 +11,7 @@
  * Each container must exist as well as be non-empty. A class renamed in a template would
  * otherwise turn every check here into a silent pass.
  */
+import { createHash } from 'node:crypto';
 import { readdir, readFile } from 'node:fs/promises';
 import path from 'node:path';
 
@@ -51,7 +54,44 @@ let projects = 0;
   if (empty) err('code/index.html', `${empty} project description(s) are empty`);
 }
 
-console.log(`check-dist: ${posts} post pages, about, ${projects} projects`);
+/* Content-Security-Policy, on every page. Astro writes the <meta> and hashes what it emits,
+   but a <style> or <script> it did not see (a diagram's, say) is dropped by the browser
+   without a sound: the page ships and simply looks or behaves wrong. So each page must
+   carry exactly one policy, nothing the policy governs may come before it, and every
+   inline <style> and executable inline <script> must be listed in it by hash. */
+const sha = (s) => `'sha256-${createHash('sha256').update(s).digest('base64')}'`;
+async function htmlFiles(dir) {
+  const out = [];
+  for (const e of await readdir(dir, { withFileTypes: true })) {
+    const abs = path.join(dir, e.name);
+    if (e.isDirectory()) out.push(...await htmlFiles(abs));
+    else if (e.name.endsWith('.html')) out.push(abs);
+  }
+  return out;
+}
+let policed = 0;
+for (const abs of await htmlFiles(DIST)) {
+  const rel = path.relative(DIST, abs).split(path.sep).join('/');
+  const html = await readFile(abs, 'utf8');
+  const metas = [...html.matchAll(/<meta http-equiv="content-security-policy" content="([^"]*)">/gi)];
+  if (metas.length !== 1) { err(rel, `expected one Content-Security-Policy <meta>, found ${metas.length}`); continue; }
+  policed++;
+  if (/<(?:script|style|base)\b|<link\b[^>]*\brel="stylesheet"/i.test(html.slice(0, metas[0].index)))
+    err(rel, 'a script, style or <base> comes before the Content-Security-Policy <meta>, which does not govern it');
+  const directives = new Map(
+    metas[0][1].split(';').map((d) => d.trim().split(/\s+/)).filter((t) => t[0]).map(([name, ...values]) => [name, new Set(values)]),
+  );
+  const listed = (kind, hash) =>
+    (directives.get(`${kind}-src-elem`) ?? directives.get(`${kind}-src`) ?? directives.get('default-src') ?? new Set()).has(hash);
+  for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
+    if (!listed('style', sha(css))) err(rel, `an inline <style> (${css.length} chars) is not in the policy; the browser will drop it`);
+  for (const [, attrs, js] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
+    if (/\bsrc=/i.test(attrs) || /\btype="application\/(?:ld\+)?json"/i.test(attrs)) continue;
+    if (!listed('script', sha(js))) err(rel, `an inline <script> (${js.length} chars) is not in the policy; the browser will not run it`);
+  }
+}
+
+console.log(`check-dist: ${posts} post pages, about, ${projects} projects; a complete policy on ${policed} pages`);
 for (const e of errors) console.error(`  ERROR ${e}`);
 if (errors.length) { console.error(`\n${errors.length} error(s) — dist/ is not fit to deploy.`); process.exit(1); }
-console.log('  every body is present — passed');
+console.log('  every body is present and every inline style and script is allowed — passed');
diff --git a/src/lib/diagrams.mjs b/src/lib/diagrams.mjs
@@ -227,6 +227,15 @@ export function diagramKey(source) {
 
 export const diagramFile = (key) => `${DIAGRAM_DIR}/${key}.svg`;
 
+/**
+ * The markup for the nth appearance of one diagram on a page (n from 0). Every id in a baked
+ * file, the root's and the arrowhead markers', and every selector in its <style>, starts
+ * from `mmd-<key>`, so one replacement keeps a second copy from sharing ids with the first.
+ * It rewrites the <style> too, which is why astro.config.mjs hashes each copy for the
+ * Content-Security-Policy through this same function rather than reading the file alone.
+ */
+export const diagramCopy = (svg, key, n) => (n === 0 ? svg : svg.replaceAll(`mmd-${key}`, `mmd-${key}-${n}`));
+
 /**
  * The baked SVG, or null when this fence has not been rendered yet. Synchronous by design:
  * the plugin that calls it is a cache lookup, with no browser and no lifecycle to manage.
diff --git a/src/lib/satteri-diagram.mjs b/src/lib/satteri-diagram.mjs
@@ -13,7 +13,7 @@
  * useMaxWidth note in diagrams.mjs). What the tabindex buys is a keyboard reader landing on
  * the figure - the drawing is vector, so a reader who lands on it can zoom it losslessly.
  */
-import { diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
+import { diagramCopy, diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
 
 /**
  * A build with a fence that was never rendered is a broken build, not a warning: the
@@ -89,9 +89,7 @@ export default function satteriDiagram() {
         const seen = ctx.data.diagramSeen;
         const repeat = seen.get(key) ?? 0;
         seen.set(key, repeat + 1);
-        // Every id inside the file — the root, and the arrowhead markers that reference it —
-        // starts from this one string, so one replacement keeps the SVG self-consistent.
-        const markup = repeat === 0 ? svg : svg.replaceAll(`mmd-${key}`, `mmd-${key}-${repeat}`);
+        const markup = diagramCopy(svg, key, repeat);
 
         const kind = /aria-roledescription="([^"]+)"/.exec(svg)?.[1] ?? '';
         const label = LABELS[kind] ?? 'Diagram';