nuxt-site-config copies every NUXT_SITE_* variable into the public site config, so NUXT_SITE_CACHE_PURGE_TOKEN reached every page's payload as cachePurgeToken (and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied). - The purge settings move to runtimeConfig cachePurge.token (NUXT_CACHE_PURGE_TOKEN) and cachePurge.allowProxied (NUXT_CACHE_PURGE_ALLOW_PROXIED), outside that prefix. - The old siteCache.* names keep working for one release: a non-empty new token wins, either allowProxied flag turns it on, and one deprecation warning per process names the variables (never a value). - The shared Nitro plugin server/plugins/site-cache-purge.ts strips cachePurgeToken and cacheAllowProxied from nuxt-site-config's stack on site-config:init, so a site on the old names leaks nothing either; it does nothing without nuxt-site-config. - The shared lib, route and plugin and tests/sitePurge.test.ts are byte-identical in vue-journal-layer, vue-menu-layer and vue-faq-layer, with three sha256 pins. - README: new names, the deprecation, the stripping and the advice to rotate a token ever set under the old name.
195 lines
7.4 KiB
JavaScript
195 lines
7.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Purge token check: proves on a built Nuxt server that the site cache purge
|
|
// token never reaches a page, whichever environment variable carries it, and
|
|
// that the shared purge route reads the new and the deprecated names.
|
|
//
|
|
// node purge-token-check.mjs --output <.output dir> --fixtures <dir> --path <page path>
|
|
//
|
|
// nuxt-site-config (through @nuxtjs/seo) copies every NUXT_SITE_* variable
|
|
// into the public site config, which Nuxt serializes into each page's
|
|
// payload. Three scenarios, each on a fresh server with fresh random tokens:
|
|
//
|
|
// A NUXT_CACHE_PURGE_TOKEN only
|
|
// B the deprecated NUXT_SITE_CACHE_PURGE_TOKEN and
|
|
// NUXT_SITE_CACHE_ALLOW_PROXIED=true only
|
|
// C both token names, with different values (the new one wins)
|
|
//
|
|
// In each, `/`, the page and its _payload.json (when the build serves one)
|
|
// must contain no token value and neither cachePurgeToken nor
|
|
// cacheAllowProxied, and neither must the server's log. The purge route must
|
|
// answer 200 to the expected bearer, 401 to the other or a wrong one, and a
|
|
// proxied request with the bearer 200 in B (allowProxied) and 404 in A. B
|
|
// logs the deprecation warning exactly once, A and C never.
|
|
//
|
|
// Failure messages name a key or say "token value"; they never print a
|
|
// value. The last line is "purge-token-check: ok (<n> checks)" on success;
|
|
// every failure prints "purge-token-check: FAIL ..." and exits 1.
|
|
|
|
import { randomBytes } from 'node:crypto'
|
|
import { resolve } from 'node:path'
|
|
import { startFixtureApi } from './fixture-api.mjs'
|
|
import { startNuxtServer } from './nuxt-server.mjs'
|
|
|
|
const PURGE_PATH = '/api/__site__/cache/purge'
|
|
const PURGE_KEYS = ['cachePurgeToken', 'cacheAllowProxied']
|
|
const DEPRECATION_LINE = /^\[site-cache\].*NUXT_CACHE_PURGE_TOKEN/gm
|
|
|
|
/** @param {string[]} argv */
|
|
function parseArgs(argv) {
|
|
/** @type {Record<string, string>} */
|
|
const out = {}
|
|
const names = new Set(['--output', '--fixtures', '--path'])
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i]
|
|
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
|
|
const v = argv[++i]
|
|
if (v === undefined) throw new Error(`${arg} needs a value`)
|
|
out[arg.slice(2)] = v
|
|
}
|
|
for (const name of names) {
|
|
if (!out[name.slice(2)]) throw new Error('--output, --fixtures and --path are required')
|
|
}
|
|
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
|
|
return /** @type {{ output: string, fixtures: string, path: string }} */ (out)
|
|
}
|
|
|
|
const token = () => randomBytes(32).toString('hex')
|
|
|
|
/** @param {string} base @param {string} path */
|
|
async function get(base, path) {
|
|
const res = await fetch(base + path, { redirect: 'manual', signal: AbortSignal.timeout(20000) })
|
|
return { status: res.status, body: await res.text() }
|
|
}
|
|
|
|
/** @param {string} base @param {Record<string, string>} headers */
|
|
async function purge(base, headers) {
|
|
const res = await fetch(base + PURGE_PATH, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', ...headers },
|
|
body: JSON.stringify({ kind: 'post', slug: 'purge-token-check' }),
|
|
signal: AbortSignal.timeout(20000),
|
|
})
|
|
await res.text()
|
|
return res.status
|
|
}
|
|
|
|
/** @param {string} value */
|
|
const bearer = (value) => ({ authorization: `Bearer ${value}` })
|
|
|
|
/** The payload path Nuxt serves for a page path. @param {string} path */
|
|
function payloadPath(path) {
|
|
return `${path.replace(/\/+$/, '')}/_payload.json`
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
|
|
/** @type {string[]} */
|
|
const failures = []
|
|
let checks = 0
|
|
/** @param {boolean} ok @param {string} what */
|
|
const check = (ok, what) => {
|
|
checks++
|
|
if (!ok) failures.push(what)
|
|
return ok
|
|
}
|
|
|
|
const a = token()
|
|
const b = token()
|
|
const cNew = token()
|
|
const cOld = token()
|
|
const scenarios = [
|
|
{
|
|
name: 'A (NUXT_CACHE_PURGE_TOKEN)',
|
|
env: { NUXT_CACHE_PURGE_TOKEN: a },
|
|
tokens: [a],
|
|
accepted: a,
|
|
refused: [token()],
|
|
proxied: 404,
|
|
warnings: 0,
|
|
},
|
|
{
|
|
name: 'B (deprecated NUXT_SITE_CACHE_PURGE_TOKEN, NUXT_SITE_CACHE_ALLOW_PROXIED=true)',
|
|
env: { NUXT_SITE_CACHE_PURGE_TOKEN: b, NUXT_SITE_CACHE_ALLOW_PROXIED: 'true' },
|
|
tokens: [b],
|
|
accepted: b,
|
|
refused: [token()],
|
|
proxied: 200,
|
|
warnings: 1,
|
|
},
|
|
{
|
|
name: 'C (both names, different values)',
|
|
env: { NUXT_CACHE_PURGE_TOKEN: cNew, NUXT_SITE_CACHE_PURGE_TOKEN: cOld },
|
|
tokens: [cNew, cOld],
|
|
accepted: cNew,
|
|
refused: [cOld, token()],
|
|
proxied: 404,
|
|
warnings: 0,
|
|
},
|
|
]
|
|
|
|
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
|
|
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
|
|
let nuxt = null
|
|
try {
|
|
for (const s of scenarios) {
|
|
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: s.env })
|
|
const base = nuxt.url
|
|
|
|
// 1. No token value and no purge key in any page or payload.
|
|
const paths = ['/', args.path]
|
|
const page = await get(base, args.path)
|
|
check(page.status === 200, `${s.name}: GET ${args.path}: status ${page.status}, want 200`)
|
|
const payload = payloadPath(args.path)
|
|
if ((await get(base, payload)).status === 200) paths.push(payload)
|
|
for (const path of paths) {
|
|
const { body } = await get(base, path)
|
|
for (const [i, value] of s.tokens.entries()) {
|
|
check(!body.includes(value), `${s.name}: GET ${path} contains token value #${i + 1}`)
|
|
}
|
|
for (const key of PURGE_KEYS) {
|
|
check(!body.includes(key), `${s.name}: GET ${path} contains the key ${key}`)
|
|
}
|
|
}
|
|
|
|
// 2. The purge route reads the expected token.
|
|
const ok = await purge(base, bearer(s.accepted))
|
|
check(ok === 200, `${s.name}: purge with the expected bearer: status ${ok}, want 200`)
|
|
for (const [i, value] of s.refused.entries()) {
|
|
const status = await purge(base, bearer(value))
|
|
check(status === 401, `${s.name}: purge with refused bearer #${i + 1}: status ${status}, want 401`)
|
|
}
|
|
const none = await purge(base, {})
|
|
check(none === 401, `${s.name}: purge without a bearer: status ${none}, want 401`)
|
|
const proxied = await purge(base, { ...bearer(s.accepted), 'x-forwarded-for': '203.0.113.7' })
|
|
check(proxied === s.proxied, `${s.name}: proxied purge with the expected bearer: status ${proxied}, want ${s.proxied}`)
|
|
|
|
// 3. The log: the deprecation warning count, no token value, no key.
|
|
await new Promise((r) => setTimeout(r, 200))
|
|
const log = nuxt.output()
|
|
const warnings = (log.match(DEPRECATION_LINE) ?? []).length
|
|
check(warnings === s.warnings, `${s.name}: ${warnings} deprecation warnings in the server log, want ${s.warnings}`)
|
|
for (const [i, value] of s.tokens.entries()) {
|
|
check(!log.includes(value), `${s.name}: the server log contains token value #${i + 1}`)
|
|
}
|
|
|
|
await nuxt.close()
|
|
nuxt = null
|
|
}
|
|
} finally {
|
|
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
|
|
await api.close()
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
for (const f of failures) console.error(`purge-token-check: FAIL ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`purge-token-check: ok (${checks} checks)`)
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`purge-token-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
|
process.exit(1)
|
|
})
|