2 Commits
v0.2.0 ... main

Author SHA1 Message Date
Jakub Zych
c4639c2020 feat(19.3-06): purge route accepts proxied requests on siteCache.allowProxied (WR-03)
- runtimeConfig.siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED, default
  false) lets the shared purge route accept a request that came through a
  TLS-terminating proxy; the bearer token is then the only gate
- shared lib, route and spec stay byte-identical across the three layers,
  with updated sha256 pins and two new sitePurge specs

- version 0.3.0
2026-10-15 22:42:09 +02:00
Jakub Zych
dd0ee35085 test(19.3-05): site purge route handler specs and edge cases 2026-10-15 19:56:16 +02:00
6 changed files with 181 additions and 12 deletions

View File

@@ -84,6 +84,7 @@ Authorization: Bearer <NUXT_SITE_CACHE_PURGE_TOKEN>
- It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug. - It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug.
- Set `NUXT_SITE_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:<port>/api/__site__/cache/purge`. Without a token of that length the route answers 404. - Set `NUXT_SITE_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:<port>/api/__site__/cache/purge`. Without a token of that length the route answers 404.
- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time). - It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time).
- Behind a TLS-terminating proxy, for a backend that reaches the site at an https `site_cache.purge_url` on another host, the proxy adds those headers. Set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` (`runtimeConfig.siteCache.allowProxied`, default `false`) and the route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret. Unset, proxied requests stay refused.
- The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache. - The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache.
- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps. - vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.

View File

@@ -29,6 +29,12 @@ export default defineNuxtConfig({
// backend's site_cache.purge_token. Shorter than 32 characters, the // backend's site_cache.purge_token. Shorter than 32 characters, the
// route answers 404. // route answers 404.
purgeToken: '', purgeToken: '',
// NUXT_SITE_CACHE_ALLOW_PROXIED: set it to true when the backend reaches
// the site through a TLS-terminating proxy (an https site_cache.purge_url
// on another host), which adds X-Forwarded-For, X-Real-IP or Forwarded.
// The route then accepts proxied requests and the bearer token is the
// only gate; unset, it answers 404 to any proxied request.
allowProxied: false,
}, },
public: { public: {
menu: { menu: {

View File

@@ -1,6 +1,6 @@
{ {
"name": "vue-menu-layer", "name": "vue-menu-layer",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./nuxt.config.ts", "main": "./nuxt.config.ts",

View File

@@ -6,9 +6,12 @@
// and slug only reach the log line. // and slug only reach the log line.
// //
// 404 without a token of at least 32 characters, and for any request that // 404 without a token of at least 32 characters, and for any request that
// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded); // came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded)
// 401 for a missing or wrong bearer token. The '/api/__site__/**' route rule // unless runtimeConfig.siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED)
// keeps this route out of the route cache. // is true or "true": a backend that reaches the site through a TLS-terminating
// proxy opts in, and the bearer token is then the only gate. 401 for a
// missing or wrong bearer token. The '/api/__site__/**' route rule keeps this
// route out of the route cache.
// //
// Shared: several layers ship this file byte for byte, and Nitro keeps one // Shared: several layers ship this file byte for byte, and Nitro keeps one
// handler per route and method. // handler per route and method.
@@ -20,10 +23,12 @@ const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
setResponseHeader(event, 'cache-control', 'no-store') setResponseHeader(event, 'cache-control', 'no-store')
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const siteCache = config.siteCache as { purgeToken?: unknown; allowProxied?: unknown } | undefined
const verdict = authorizeSitePurge({ const verdict = authorizeSitePurge({
token: (config.siteCache as { purgeToken?: unknown } | undefined)?.purgeToken, token: siteCache?.purgeToken,
authorization: getRequestHeader(event, 'authorization'), authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined), proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
allowProxied: siteCache?.allowProxied === true || siteCache?.allowProxied === 'true',
}) })
if (verdict === 'disabled' || verdict === 'proxied') { if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' }) throw createError({ statusCode: 404, statusMessage: 'Not Found' })

View File

@@ -27,6 +27,12 @@ export interface SitePurgeAuthInput {
authorization?: string | null authorization?: string | null
/** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */ /** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */
proxied: boolean proxied: boolean
/**
* runtimeConfig.siteCache.allowProxied: when true a proxied request is not
* refused, and the bearer token is the only gate. For a backend that reaches
* the site through a TLS-terminating proxy, which adds forwarding headers.
*/
allowProxied?: boolean
} }
function digest(value: string): Buffer { function digest(value: string): Buffer {
@@ -36,13 +42,14 @@ function digest(value: string): Buffer {
/** /**
* Decides a purge request: `disabled` when no token of at least * Decides a purge request: `disabled` when no token of at least
* MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the * MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the
* request came through a reverse proxy (checked before the token), `ok` when * request came through a reverse proxy (checked before the token) unless
* the Bearer value equals the token (compared in constant time over sha256 * allowProxied is true, `ok` when the Bearer value equals the token (compared
* digests) and `unauthorized` otherwise. * in constant time over sha256 digests) and `unauthorized` otherwise. With
* allowProxied the token is the only gate, so it must stay long and secret.
*/ */
export function authorizeSitePurge({ token, authorization, proxied }: SitePurgeAuthInput): SitePurgeVerdict { export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict {
if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled' if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled'
if (proxied) return 'proxied' if (proxied && allowProxied !== true) return 'proxied'
const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '') const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '')
if (!m) return 'unauthorized' if (!m) return 'unauthorized'
return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized' return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized'

View File

@@ -15,8 +15,8 @@ import {
// The sha256 of the two shared files. vue-journal-layer, vue-menu-layer and // The sha256 of the two shared files. vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship them byte for byte; a change lands in all three with // vue-faq-layer ship them byte for byte; a change lands in all three with
// new values here. // new values here.
const SHARED_LIB_SHA256 = '7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8' const SHARED_LIB_SHA256 = '0ea9f0f936134516872a2edf9b8e09feedddfa23e9da05c57d06c772193b260e'
const SHARED_ROUTE_SHA256 = 'c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06' const SHARED_ROUTE_SHA256 = '8f86226074abf10c2ecc1d181b6957e90ed8d900589a6372214f50cdbfcd532c'
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
@@ -95,3 +95,153 @@ test('sitePurge: purge event kinds and slugs', () => {
assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body)) assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body))
} }
}) })
test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => {
// timingSafeEqual on raw strings throws on a length mismatch; over digests
// every length compares in constant time and answers unauthorized.
for (const length of [1, 31, 33, 64, 4096]) {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`)
}
const long = 'k'.repeat(200)
assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized')
})
test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => {
const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json'])
const removed: string[] = []
const storage = {
getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')),
removeItem: (key: string) => {
live.delete(key)
removed.push(key)
},
}
assert.equal(await purgeSiteRouteCache(storage), 1)
assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`])
assert.ok(live.has(SITE_ROUTE_CACHE_BASE))
assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0)
})
test('sitePurge: purge event edge cases', () => {
assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' })
assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' })
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200)
// Control characters are stripped before the cut.
const slug = '\u0001'.repeat(50) + 'z'.repeat(200)
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200))
assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' })
})
/** One call of the shared route with Nitro's auto-imports stubbed. */
async function callRoute(opts: { token?: unknown; allowProxied?: unknown; headers?: Record<string, string>; body?: unknown; bodyError?: boolean; keys?: string[] }) {
const responseHeaders: Record<string, string> = {}
const logs: string[] = []
const live = new Set(opts.keys ?? [])
const g = globalThis as Record<string, unknown>
g.defineEventHandler = (fn: unknown) => fn
g.setResponseHeader = (_e: unknown, name: string, value: string) => {
responseHeaders[name] = value
}
g.useRuntimeConfig = () => ({ siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied } })
g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name]
g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input)
g.readBody = async () => {
if (opts.bodyError) throw new Error('invalid JSON')
return opts.body
}
g.useStorage = (base: string) => {
assert.equal(base, 'cache')
return {
getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')),
removeItem: async (key: string) => {
live.delete(key)
},
}
}
const info = console.info
console.info = (line: string) => {
logs.push(line)
}
try {
const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise<unknown>
try {
return { result: await route({}), status: 200, responseHeaders, logs, live }
} catch (err) {
return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live }
}
} finally {
console.info = info
}
}
test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
for (const token of [undefined, '', 'short']) {
const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys })
assert.equal(r.status, 404, `token ${JSON.stringify(token)}`)
assert.equal(r.live.size, 1)
}
for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) {
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys })
assert.equal(r.status, 404, name)
assert.equal(r.live.size, 1)
}
for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) {
const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys })
assert.equal(r.status, 401, String(authorization))
assert.equal(r.live.size, 1)
assert.equal(r.responseHeaders['cache-control'], 'no-store')
}
})
test('sitePurge: route purges, answers the count and logs kind and slug only', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json']
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys })
assert.equal(r.status, 200)
assert.deepEqual(r.result, { purged: 2 })
assert.deepEqual([...r.live], ['nitro:functions:_:x.json'])
assert.equal(r.responseHeaders['cache-control'], 'no-store')
assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)'])
assert.ok(!r.logs.join('').includes(TOKEN))
const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] })
assert.equal(bad.status, 200)
assert.deepEqual(bad.result, { purged: 0 })
assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )'])
})
test('sitePurge: allowProxied lets a proxied request reach the token check', () => {
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized')
assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled')
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled')
// Without the flag, or with anything but true, a proxied request stays refused.
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied')
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
})
test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => {
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`]
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' }
for (const allowProxied of [true, 'true']) {
const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys })
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
assert.deepEqual(r.result, { purged: 2 })
assert.equal(r.live.size, 0)
}
const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
assert.equal(wrong.status, 401)
assert.equal(wrong.live.size, 2)
for (const allowProxied of [false, undefined, 'false', 1]) {
const r = await callRoute({ token: TOKEN, allowProxied, headers, keys })
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
assert.equal(r.live.size, 2)
}
})