Pages whose security policy forbids blob: scripts can still print generated sheets, verified natively

The served generator was run by importing its VERIFIED text from a blob: URL, which fails closed on a
partner page whose Content-Security-Policy forbids blob: scripts: safe, but no generated sheet there
(dx-shepherd's FIX-POSTER-RENDER-LOAD-WITHOUT-BLOB-FOR-STRICT-CSP).

The tree now also serves a CLASSIC-SCRIPT copy, lib/poster-render.global.js: the same file under a
mechanical transform (strict mode kept; the one export becomes window.DoxaPosterRender), written by
generate-tree with the function the pin is computed from (globalVariant, pin-poster-render.mjs) and on
its served allowlist — produced by regenerating the tree, not copied in by hand. posterSheets keeps the
proven path first; only when the blob import itself is refused does it load the classic copy with
<script integrity=sha384-…>, so the browser verifies it natively. An integrity FAILURE never falls back:
it throws before any import is tried.

check:poster-render-pinned adds: the classic copy matches its pin and the served copy, and — run as a
real classic script — draws the module's sheet byte for byte. The <script> path under a strict policy
needs a browser (rega). country-posters 1,473,964 bytes (ceiling 1,500,000); verify-bundles-shipped
PASS; gates:pre 74/74.
This commit is contained in:
Doxa Maps 2026-09-29 07:16:50 +00:00
parent 5203bf06c4
commit f18980f641
6 changed files with 2762 additions and 3 deletions

View file

@ -25,7 +25,8 @@ const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} ${m}`); if (!c) fai
const source = readFileSync(join(REPO, 'library/packet/poster-render.js'), 'utf8')
const served = readFileSync(join(REPO, 'public/dx-maps/assets/lib/poster-render.js'), 'utf8')
const { POSTER_RENDER_SHA384, NE_PLACES_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
const { POSTER_RENDER_SHA384, NE_PLACES_SHA384, POSTER_RENDER_GLOBAL_SHA384 } = await import(join(REPO, 'library/packet/poster-render.integrity.js'))
const { globalVariant } = await import(join(REPO, 'library/scripts/posters/pin-poster-render.mjs'))
ok(POSTER_RENDER_SHA384 === pinOf(source),
`the pin matches library/packet/poster-render.js${POSTER_RENDER_SHA384 === pinOf(source) ? '' : ' — run: node library/scripts/posters/pin-poster-render.mjs'}`)
ok(pinOf(served) === pinOf(source), 'the served copy (assets/lib/poster-render.js) is the pinned file')
@ -76,5 +77,26 @@ ok(typeof html === 'string' && html.length > 10000 && /<svg\b/.test(html), `the
ok(html !== none, 'the genuine table IS used (its sheet differs from one with no places)')
}
// THE CLASSIC-SCRIPT COPY (strict-CSP pages load it with <script integrity>): pinned, served as the tree
// writes it, and — run as a real classic script — the SAME generator: its sheet is byte-identical to the
// module's for the same inputs. (The <script> path itself needs a browser; that half is rega's.)
{
const classic = globalVariant(source)
const servedClassic = readFileSync(join(REPO, 'public/dx-maps/assets/lib/poster-render.global.js'), 'utf8')
ok(POSTER_RENDER_GLOBAL_SHA384 === pinOf(classic) && pinOf(servedClassic) === pinOf(classic),
'the classic-script copy matches its pin and the served copy (lib/poster-render.global.js)')
const sandbox = {}
new Function('globalThis', classic)(sandbox)
const mod = await import(join(REPO, 'library/packet/poster-render.js'))
const series = { totalGroups: 1, totalLists: 1, index: 1 }
const SERIES_DEF = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/series.json'), 'utf8')).country
const { ISO3_NAME } = await import(join(REPO, 'library/data/iso3-name.js'))
const make = (f) => f({ env: { FORMAT: '3x3', POSTER_DATE: '2026-09-29' }, SERIES_DEF, ISO3_NAME, adm0, series: { ...series } })
const fc = { type: 'FeatureCollection', features: [] }
const a = (await make(sandbox.DoxaPosterRender.createPosterGenerator).renderPoster(zone, pins, fc)).html
const b = (await make(mod.createPosterGenerator).renderPoster(zone, pins, fc)).html
ok(a === b && a.length > 10000, `run as a classic script it draws the module's sheet byte for byte (${a.length} bytes)`)
}
if (fails) { console.log(`POSTER-RENDER-PINNED: FAIL (${fails})`); process.exit(1) }
console.log('POSTER-RENDER-PINNED: PASS — the served generator and places table are the pinned ones, a tampered generator is refused unrun, a tampered table is not used, and the genuine copies draw')

View file

@ -40,6 +40,7 @@ import { tmpdir } from 'node:os'
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
import { globalVariant } from '../library/scripts/posters/pin-poster-render.mjs'
// Bake the composition tier (capability machine-key) INTO the manifest so ANY consumer —
// the built gallery AND the plugin-studio cap-5 element — can group by capability 5→1
// without a separate tierMap. Previously the manifest carried no `type`, so a manifest-fed
@ -1717,6 +1718,10 @@ function main() {
const libDir = join(pubAssets, 'lib')
mkdirSync(libDir, { recursive: true })
cpSync(resolve(__dirname, '..', 'library', 'packet', 'poster-render.js'), join(libDir, 'poster-render.js'))
// …and its CLASSIC-SCRIPT copy, for pages whose policy forbids blob: scripts (loaded with
// <script integrity>), written by the same transform the pin is computed from.
writeFileSync(join(libDir, 'poster-render.global.js'),
globalVariant(readFileSync(resolve(__dirname, '..', 'library', 'packet', 'poster-render.js'), 'utf8')))
// DATA LIVES IN data/, THE WEB-ROOT IS GENERATED (the D1 reorganization):
// source-of-truth folders that used to sit ONLY inside public/ now live in
@ -1788,6 +1793,7 @@ function main() {
// gathered into the pool above but, missing here, never reached the tree: a print would 404.
'boundaries/ne-places.json',
'lib/poster-render.js',
'lib/poster-render.global.js', // the classic-script copy, same generator (see the pool stage)
// people-groups — all-people-groups.vue (the slim JSON only; NOT the 16 MB CSV,
// which no element fetches and which dropin.exclude already withholds).
'people-groups/people-groups-slim.json',

View file

@ -6,3 +6,6 @@ export const POSTER_RENDER_SHA384 = 'sha384-8uzpgX1i2IEQNEh7WIxVxanacbBvX2r11/Ve
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8rHTX++mgSCih4LO4Rdrb2MIQy'
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-gRuzl6j8EMIN3bQXFZH3CSXKWx7w8LCf45PlTgXorFrFs/9jIKX3Ic3D/iCOpWwU'

View file

@ -15,7 +15,7 @@
* const html = await generateSheet({ zone, pins, zoneFC, format: 'packet', seriesId: 'upg100',
* ISO3_NAME, series: { totalGroups, totalLists, index } })
*/
import { POSTER_RENDER_SHA384, NE_PLACES_SHA384 } from './poster-render.integrity.js'
import { POSTER_RENDER_SHA384, NE_PLACES_SHA384, POSTER_RENDER_GLOBAL_SHA384 } from './poster-render.integrity.js'
// ★ THE GENERATOR IS SERVED, NOT BUNDLED, AND IT IS INTEGRITY-PINNED. poster-render.js is served verbatim
// at <assets>/lib/poster-render.js (generate-tree) and loaded at the FIRST print: a single-file build
@ -36,6 +36,23 @@ async function sha384(text) {
for (const b of d) bin += String.fromCharCode(b)
return `sha384-${btoa(bin)}`
}
// The classic-script path: the browser checks POSTER_RENDER_GLOBAL_SHA384 itself and refuses to run a
// file that does not match (onerror), so nothing unverified executes here either.
function loadClassic(url) {
if (globalThis.DoxaPosterRender?.createPosterGenerator) return Promise.resolve(globalThis.DoxaPosterRender.createPosterGenerator)
return new Promise((resolve, reject) => {
const el = document.createElement('script')
el.src = url
el.integrity = POSTER_RENDER_GLOBAL_SHA384
el.crossOrigin = 'anonymous'
el.async = true
el.onload = () => (globalThis.DoxaPosterRender?.createPosterGenerator
? resolve(globalThis.DoxaPosterRender.createPosterGenerator)
: reject(new Error(`the sheet generator at ${url} loaded but did not define DoxaPosterRender`)))
el.onerror = () => reject(new Error(`the sheet generator at ${url} was refused (integrity or network) — not running it`))
document.head.appendChild(el)
})
}
const generatorCache = new Map()
function loadGenerator(assetsBase) {
if (!assetsBase) return Promise.reject(new Error('no asset base to load the sheet generator from'))
@ -51,6 +68,13 @@ function loadGenerator(assetsBase) {
}
const blobUrl = URL.createObjectURL(new Blob([text], { type: 'text/javascript' }))
try { return (await import(/* @vite-ignore */ blobUrl)).createPosterGenerator }
catch (e) {
// A page whose Content-Security-Policy forbids blob: scripts refuses the import of the VERIFIED text.
// There, load the classic-script copy with <script integrity>, and the browser verifies it natively.
// (An integrity FAILURE above never reaches here: it threw before any import was tried.)
if (!globalThis.document?.createElement) throw e
return loadClassic(abs(`${assetsBase}/lib/poster-render.global.js`))
}
finally { URL.revokeObjectURL(blobUrl) }
})()
p.catch(() => generatorCache.delete(url))

View file

@ -13,10 +13,22 @@ import { join, dirname } from 'node:path'
const LIB = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'packet')
export const pinOf = (text) => `sha384-${createHash('sha384').update(text, 'utf8').digest('base64')}`
// The CLASSIC-SCRIPT copy of the generator, for pages whose Content-Security-Policy forbids blob: scripts:
// loaded with <script integrity=…> the browser verifies it natively. poster-render.js has one export and
// no imports, so the transform is mechanical — strict mode kept (modules are strict), the one export
// becomes window.DoxaPosterRender. generate-tree writes it with THIS function, so the served copy and the
// pin cannot disagree about what it is.
export function globalVariant(source) {
const body = String(source).replace(/^export function createPosterGenerator\(/m, 'function createPosterGenerator(')
if (body === String(source)) throw new Error('poster-render.js no longer exports createPosterGenerator as the transform expects')
return `(function () {\n'use strict';\n${body}\nglobalThis.DoxaPosterRender = { createPosterGenerator };\n})();\n`
}
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
if (isMain) {
const pin = pinOf(readFileSync(join(LIB, 'poster-render.js'), 'utf8'))
const places = pinOf(readFileSync(join(LIB, '..', 'scripts', 'posters', 'data', 'ne-places.json'), 'utf8'))
const classic = pinOf(globalVariant(readFileSync(join(LIB, 'poster-render.js'), 'utf8')))
writeFileSync(join(LIB, 'poster-render.integrity.js'), `// GENERATED by library/scripts/posters/pin-poster-render.mjs — do not edit by hand.
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
@ -25,6 +37,9 @@ export const POSTER_RENDER_SHA384 = '${pin}'
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
export const NE_PLACES_SHA384 = '${places}'
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
export const POSTER_RENDER_GLOBAL_SHA384 = '${classic}'
`)
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}`)
console.log(`[pin-poster-render] ${pin}\n[pin-poster-render] ne-places ${places}\n[pin-poster-render] classic ${classic}`)
}

File diff suppressed because it is too large Load diff