No bundle needs CSP 'unsafe-eval': translations compile without eval, and the partner CSP is documented

vue-i18n's default message compiler builds every message with `new Function`, so 20 of 25 bundles needed
'unsafe-eval' and a partner with a strict Content-Security-Policy got no translated text (CRITIC-4).
vite.config.js defines __INTLIFY_JIT_COMPILATION__ (vue-i18n 9.14: messages compile to an AST, no eval),
and the bundler drops the eval path: rebuilt doxa-simple-map carries 0 `new Function` (1 before).
check:no-eval (gates:post): no built bundle contains new Function / eval, and vue-i18n in JIT mode renders
real en/ar catalogue messages with Function and eval blocked. documentation/how-to.md lists the CSP an
embedding page needs (style-src 'unsafe-inline' for the shadow <style>, worker-src blob:, tile hosts).
This commit is contained in:
Doxa Maps 2026-09-26 05:49:18 +00:00
parent 4a12b009aa
commit f14c397758
3 changed files with 91 additions and 0 deletions

View file

@ -321,6 +321,22 @@ as before, and an embed that passes a token keeps getting Mapbox under `auto`.
--- ---
## Embed under a strict Content-Security-Policy
No bundle needs `'unsafe-eval'` (translations compile without `eval`; `bun run check:no-eval`), and
every third-party script is pinned with an integrity hash (`library/constants/cdn.js`,
`bun run check:cdn-pins`). A page embedding the maps needs, in addition to its own sources:
| Directive | Add | Why |
|---|---|---|
| `script-src` | `https://cdn.jsdelivr.net` (and `https://api.mapbox.com` for Mapbox) | the map engine and the search geocoder |
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
---
## Set the API + Mapbox token ## Set the API + Mapbox token
Both the data backend and the Mapbox token are **runtime inputs**, never baked into a bundle. Both the data backend and the Mapbox token are **runtime inputs**, never baked into a bundle.

View file

@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* check-no-eval — no bundle needs CSP 'unsafe-eval', and translations still render without it. Headless.
*
* CRITIC-4 (2026-09-26): vue-i18n's default message compiler builds every message with `new Function`,
* so 20 of 25 bundles needed 'unsafe-eval' and a partner with a strict Content-Security-Policy got no
* translated text. vite.config.js now defines __INTLIFY_JIT_COMPILATION__ (compile to an AST, no eval).
*
* ASSERTS (1) BUILT: no bundle under public/<tree>/ contains `new Function(` or a bare `eval(`.
* (2) RUNTIME: vue-i18n in JIT mode, with the global Function constructor and eval DISABLED
* (they throw, as under a strict CSP), renders real catalogue messages — plain,
* {named} interpolation and plural — in en and ar, from library/i18n/locales.
* EXIT 0 pass · 1 fail
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
let bad = 0
// ── (1) built output ──
const TREE = join(ROOT, 'public', TREE_NAME)
let bundles = 0
for (const d of existsSync(TREE) ? readdirSync(TREE) : []) {
const f = join(TREE, d, `${d}.js`)
if (!existsSync(f)) continue
bundles++
const src = readFileSync(f, 'utf8')
const nf = (src.match(/new Function\s*\(/g) || []).length, ev = (src.match(/(^|[^\w$.])eval\s*\(/g) || []).length
if (nf || ev) { bad++; console.log(` FAIL ${d}.js: ${nf} new Function, ${ev} eval — needs CSP 'unsafe-eval'`) }
}
if (!bundles) { bad++; console.log(' FAIL no built bundles found (vacuous)') }
else console.log(` ${bad ? ' ' : 'ok '} ${bundles} built bundle(s) scanned for new Function / eval`)
// ── (2) runtime, with eval unavailable ──
globalThis.__INTLIFY_JIT_COMPILATION__ = true
globalThis.__INTLIFY_DROP_MESSAGE_COMPILER__ = false
globalThis.__VUE_I18N_FULL_INSTALL__ = true
globalThis.__VUE_I18N_LEGACY_API__ = false
globalThis.__INTLIFY_PROD_DEVTOOLS__ = false
const { createI18n } = await import('vue-i18n')
const RealFunction = globalThis.Function
globalThis.eval = () => { throw new EvalError('CSP: eval blocked') }
globalThis.Function = new Proxy(RealFunction, { construct() { throw new EvalError('CSP: new Function blocked') }, apply() { throw new EvalError('CSP: Function blocked') } })
const cat = (lc, area) => JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales', lc, `${area}.json`), 'utf8'))
const walk = (o, p = '') => Object.entries(o).flatMap(([k, v]) => (v && typeof v === 'object' ? walk(v, p + k + '.') : [[p + k, v]]))
for (const lc of ['en', 'ar']) {
const messages = { [lc]: { studio: cat(lc, 'studio'), search: cat(lc, 'search') } }
const i18n = createI18n({ legacy: false, locale: lc, messages, missingWarn: false, fallbackWarn: false })
const t = i18n.global.t
const keys = walk(messages[lc]).filter(([, v]) => typeof v === 'string')
const named = keys.find(([, v]) => /\{\w+\}/.test(v))
let n = 0
try {
for (const [k, v] of keys) {
const vars = {}; for (const m of v.matchAll(/\{(\w+)\}/g)) vars[m[1]] = 'X'
const out = t(k, vars)
if (typeof out !== 'string' || !out) throw new Error(`${k}: rendered nothing`)
if (/\{\w+\}/.test(out)) throw new Error(`${k}: placeholder left in "${out}"`)
n++
}
const nv = named ? t(named[0], Object.fromEntries([...named[1].matchAll(/\{(\w+)\}/g)].map((m) => [m[1], 'Q']))) : ''
console.log(` ok ${lc}: ${n} studio/search messages rendered with eval blocked${named ? ` (e.g. ${named[0]} → "${nv.slice(0, 50)}")` : ''}`)
} catch (e) { bad++; console.log(` FAIL ${lc}: ${e.message}`) }
}
globalThis.Function = RealFunction
if (bad) { console.log(`NO-EVAL: FAIL — ${bad} problem(s).`); process.exit(1) }
console.log("NO-EVAL: PASS — no bundle needs 'unsafe-eval', and translations render with eval blocked.")

View file

@ -99,6 +99,12 @@ export default defineConfig(({ command, mode }) => {
// port. `--host` on the command line still overrides this for a deliberate LAN session. // port. `--host` on the command line still overrides this for a deliberate LAN session.
server: { host: '127.0.0.1' }, server: { host: '127.0.0.1' },
define: { define: {
// ★ NO 'unsafe-eval' (CRITIC-4, 2026-09-26). vue-i18n's default message compiler builds each message
// with `new Function`, so a partner with a strict Content-Security-Policy saw every translated string
// fail. The JIT compiler (vue-i18n >= 9.3) compiles messages to an AST and evaluates it without eval;
// defining the flag lets the bundler drop the eval path entirely (check:no-eval asserts it).
__INTLIFY_JIT_COMPILATION__: 'true',
__INTLIFY_DROP_MESSAGE_COMPILER__: 'false',
__DOXA_QA__: JSON.stringify(process.env.DOXA_QA === '1'), __DOXA_QA__: JSON.stringify(process.env.DOXA_QA === '1'),
// The output tree's name (vite.doxa-maps-build-config.json `name`, the same value OUT_ROOT uses), // The output tree's name (vite.doxa-maps-build-config.json `name`, the same value OUT_ROOT uses),
// for the few runtime paths that must name the tree — so renaming the tree needs no source edit. // for the few runtime paths that must name the tree — so renaming the tree needs no source edit.