A leaf page reads a Mapbox token from ?tk= and keeps it in the browser, but left it in the address. Now that the address names the open tab, people copy the address bar to share one, and the token went with it. A leaf that owns its address now removes tk with one replace, only when it is there; the other parameters keep their exact text, and the hash and history state stay. A framed page never writes. The token still works on a reload from the browser's store. check:leaf-drops-tk runs every generated leaf's own script in a sandbox.
140 lines
8.8 KiB
JavaScript
140 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-leaf-drops-tk.mjs — a leaf page that owns its address does not keep a token in it.
|
|
*
|
|
* THE DEFECT. A generated leaf page reads a Mapbox token from ?tk= and keeps it in the browser (MAPS_STAGING_TK), but it
|
|
* left it in the address. Now that the address names the open tab (?tab=, useViewAddress), people copy the address bar
|
|
* to share a tab, and the token travelled with it. internal/generate-tree.mjs's leaf script now removes tk from the
|
|
* address with a replace, on a page that owns its address (urlOwner 'app'; a framed page has none), and only when tk is
|
|
* there, keeping the other parameters' exact text, the hash and history.state.
|
|
*
|
|
* Every generated leaf page (public/<tree>/<bundle>/<profile>[/<instance>]/index.html) runs its own leaf script in a
|
|
* sandbox (location, history, localStorage and a stub DOM; fetch never answers):
|
|
* W. the page carries the template's block verbatim (a stale tree fails: regenerate it);
|
|
* A. owned, ?tk=pk.T&tab=x&lang=ar#h → ONE replace, to ?tab=x&lang=ar#h, history.state kept; the token stored; and
|
|
* where a page that needs a token mounts, the element is given that token;
|
|
* B. framed (a partner's iframe) with ?tk= → no write;
|
|
* C. owned, no tk → no write;
|
|
* D. other parameters keep their exact text (?f=a,b&tk=…&q=%C3%A9 → ?f=a,b&q=%C3%A9);
|
|
* E. an encoded key (?%74k=…) is still a tk.
|
|
* `--self-test`: no strip, a strip on a framed page, a strip that drops every parameter, one that loses the hash and a
|
|
* push instead of a replace must each FAIL. `--splice` runs on pages with the template's block put in place, as the
|
|
* generator will emit them (for a source change whose tree is not regenerated yet; never in gates).
|
|
*/
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import vm from 'node:vm'
|
|
|
|
const REPO = new URL('..', import.meta.url).pathname
|
|
const TREE = join(REPO, 'public/dx-maps')
|
|
const TEMPLATE = readFileSync(join(REPO, 'internal/generate-tree.mjs'), 'utf8')
|
|
const ANCHOR = "if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }\n"
|
|
|
|
// The block as the template writes it: from its comment to the closing brace of its `if`.
|
|
function templateBlock() {
|
|
const at = TEMPLATE.indexOf(' // ★ THE TOKEN LEAVES THE ADDRESS.')
|
|
if (at < 0) return ''
|
|
const end = TEMPLATE.indexOf('\n }\n', TEMPLATE.indexOf("if (urlTk && BASE_CONFIG.urlOwner === 'app') {", at))
|
|
return end < 0 ? '' : TEMPLATE.slice(at, end + '\n }\n'.length)
|
|
}
|
|
|
|
function leafPages() {
|
|
const out = []
|
|
const walk = (d, depth) => {
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
const p = join(d, e.name)
|
|
if (e.isDirectory() && depth < 3) walk(p, depth + 1)
|
|
else if (e.name === 'index.html' && depth >= 2) {
|
|
const html = readFileSync(p, 'utf8')
|
|
const s = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]).find((x) => x.includes('var BASE_CONFIG'))
|
|
if (s) out.push({ file: p.slice(REPO.length), script: s })
|
|
}
|
|
}
|
|
}
|
|
if (existsSync(TREE)) walk(TREE, 0)
|
|
return out
|
|
}
|
|
|
|
// Run one leaf script; returns what it did to the address, the store and the element it mounted.
|
|
function run(script, { search, hash = '', framed = false, state = { k: 1 } }) {
|
|
const writes = []
|
|
const loc = { pathname: '/dx-maps/b/p/', search, hash }
|
|
const hist = {
|
|
state,
|
|
replaceState(s, _, url) { writes.push('replace'); this.state = s; const u = new URL(url, 'https://x.test'); loc.pathname = u.pathname; loc.search = u.search; loc.hash = u.hash },
|
|
pushState() { writes.push('push') },
|
|
}
|
|
const store = new Map()
|
|
const mounted = []
|
|
const stub = () => ({ style: {}, className: '', textContent: '', innerHTML: '', parentNode: null, attrs: {}, setAttribute(k, v) { this.attrs[k] = v }, appendChild(c) { mounted.push(c) }, removeChild() {}, addEventListener() {} })
|
|
const win = {
|
|
location: loc, history: hist, URL, URLSearchParams, JSON, Object, String, console: { log() {}, warn() {}, error() {} },
|
|
localStorage: { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
|
|
document: { getElementById: () => stub(), createElement: () => stub(), documentElement: { style: {} }, body: { style: {} } },
|
|
fetch: () => new Promise(() => {}),
|
|
}
|
|
win.window = win; win.self = win; win.top = framed ? {} : win
|
|
let error = ''
|
|
try { vm.runInNewContext(script, win, { timeout: 2000 }) } catch (e) { error = String(e && e.message) }
|
|
const el = mounted.find((m) => m.attrs['profile-config'])
|
|
let tk
|
|
if (el) { try { tk = JSON.parse(el.attrs['profile-config']).tk } catch { tk = undefined } }
|
|
return { writes, search: loc.search, hash: loc.hash, state: hist.state, stored: store.get('MAPS_STAGING_TK'), mountedTk: el ? tk : null, error }
|
|
}
|
|
|
|
function audit({ splice = false, mutate = (s) => s } = {}) {
|
|
const f = []
|
|
const block = templateBlock()
|
|
if (!block) return ['W: internal/generate-tree.mjs has no token-leaves-the-address block']
|
|
const pages = leafPages()
|
|
if (pages.length < 20) f.push(`W: only ${pages.length} generated leaf pages found under public/dx-maps`)
|
|
let mountedWithTk = 0
|
|
for (const p of pages) {
|
|
let s = p.script
|
|
if (splice && !s.includes(block)) s = s.includes(ANCHOR) ? s.replace(ANCHOR, ANCHOR + block) : s
|
|
if (!s.includes(block)) { f.push(`W: ${p.file} does not carry the template's block (regenerate the tree)`); continue }
|
|
s = mutate(s)
|
|
const st = { k: 1 }
|
|
const a = run(s, { search: '?tk=pk.T&tab=x&lang=ar', hash: '#h', state: st })
|
|
if (a.error) f.push(`A: ${p.file} threw: ${a.error.slice(0, 80)}`)
|
|
if (a.writes.join() !== 'replace') f.push(`A: ${p.file} wrote [${a.writes}] (want one replace)`)
|
|
if (a.search !== '?tab=x&lang=ar' || a.hash !== '#h') f.push(`A: ${p.file} left ${a.search}${a.hash} (want ?tab=x&lang=ar#h)`)
|
|
if (a.state !== st) f.push(`A: ${p.file} did not keep history.state`)
|
|
if (a.stored !== 'pk.T') f.push(`A: ${p.file} did not keep the token (MAPS_STAGING_TK)`)
|
|
// "used" applies to a page that needs a token; a token-free one (a shell, a MapLibre map) mounts without it.
|
|
const needsToken = /var TOKEN_FREE = false;/.test(s)
|
|
if (needsToken && a.mountedTk !== null) { if (a.mountedTk !== 'pk.T') f.push(`A: ${p.file} mounted without the token`); else mountedWithTk++ }
|
|
const b = run(s, { search: '?tk=pk.T&tab=x', framed: true })
|
|
if (b.writes.length || b.search !== '?tk=pk.T&tab=x') f.push(`B: ${p.file} framed: wrote [${b.writes}], left ${b.search}`)
|
|
const c = run(s, { search: '?tab=x&lang=ar', hash: '#h' })
|
|
if (c.writes.length || c.search !== '?tab=x&lang=ar') f.push(`C: ${p.file} without tk: wrote [${c.writes}]`)
|
|
const d = run(s, { search: '?f=a,b&tk=pk.T&q=%C3%A9' })
|
|
if (d.search !== '?f=a,b&q=%C3%A9') f.push(`D: ${p.file} left ${d.search} (want ?f=a,b&q=%C3%A9)`)
|
|
const e = run(s, { search: '?%74k=pk.T&tab=x' })
|
|
if (e.search !== '?tab=x') f.push(`E: ${p.file} left ${e.search} for an encoded tk`)
|
|
}
|
|
if (pages.length && !mountedWithTk) f.push('A: no page mounted its element, so "the token is used" was never checked')
|
|
return f
|
|
}
|
|
|
|
if (process.argv.includes('--self-test')) {
|
|
const RE = "history.replaceState(history.state, '', location.pathname + (keep ? '?' + keep : '') + location.hash);"
|
|
const poisons = [
|
|
['no strip', (s) => s.replace(RE, ''), /^A: /],
|
|
['a strip on a framed page', (s) => s.replace("if (urlTk && BASE_CONFIG.urlOwner === 'app') {", 'if (urlTk) {'), /^B: /],
|
|
['a strip that drops every parameter', (s) => s.replace("return p && k !== 'tk';", 'return false;'), /^[AD]: /],
|
|
['a strip that loses the hash', (s) => s.replace(RE, RE.replace(' + location.hash', '')), /^A: /],
|
|
['a push instead of a replace', (s) => s.replace(RE, RE.replace('replaceState', 'pushState')), /^A: /],
|
|
]
|
|
let missed = 0
|
|
for (const [name, mutate, rx] of poisons) {
|
|
const f = audit({ splice: true, mutate }); const hit = f.find((x) => rx.test(x))
|
|
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
|
|
if (!hit) missed++
|
|
}
|
|
if (missed) process.exitCode = 1
|
|
} else {
|
|
const f = audit({ splice: process.argv.includes('--splice') })
|
|
if (f.length) { for (const x of f.slice(0, 30)) console.log('✗ ' + x); if (f.length > 30) console.log(`… ${f.length - 30} more`); console.log(`FAIL check:leaf-drops-tk (${f.length})`); process.exitCode = 1 }
|
|
else console.log(`PASS check:leaf-drops-tk — every generated leaf that owns its address drops ?tk= with one replace (the other parameters, the hash and history.state kept; the token kept in the browser and used); a framed page and an address without tk are never written${process.argv.includes('--splice') ? ' [--splice: pages as the generator will emit them]' : ''}`)
|
|
}
|