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:
parent
4a12b009aa
commit
f14c397758
3 changed files with 91 additions and 0 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
69
internal/check-no-eval.mjs
Normal file
69
internal/check-no-eval.mjs
Normal 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.")
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue