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:
parent
5203bf06c4
commit
f18980f641
6 changed files with 2762 additions and 3 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
2689
public/dx-maps/assets/lib/poster-render.global.js
Normal file
2689
public/dx-maps/assets/lib/poster-render.global.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue