- 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.5.0
248 lines
13 KiB
TypeScript
248 lines
13 KiB
TypeScript
// Shared site cache purge: route authorization, the route-cache purge, the
|
|
// purge body parsing and the byte-identity of the files every layer ships.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createHash } from 'node:crypto'
|
|
import { readFileSync } from 'node:fs'
|
|
import {
|
|
MIN_SITE_PURGE_TOKEN_LENGTH,
|
|
SITE_ROUTE_CACHE_BASE,
|
|
authorizeSitePurge,
|
|
parseSitePurgeEvent,
|
|
purgeSiteRouteCache,
|
|
} from '../server/lib/siteCachePurge.ts'
|
|
|
|
// 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
|
|
// new values here.
|
|
const SHARED_LIB_SHA256 = '0ea9f0f936134516872a2edf9b8e09feedddfa23e9da05c57d06c772193b260e'
|
|
const SHARED_ROUTE_SHA256 = '8f86226074abf10c2ecc1d181b6957e90ed8d900589a6372214f50cdbfcd532c'
|
|
|
|
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
|
|
|
function sha256(relative: string): string {
|
|
return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex')
|
|
}
|
|
|
|
test('sitePurge: the shared files match the pinned hashes', () => {
|
|
assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts')
|
|
assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts')
|
|
})
|
|
|
|
test('sitePurge: a missing or short token disables the route', () => {
|
|
for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) {
|
|
assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`)
|
|
}
|
|
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled')
|
|
assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32)
|
|
})
|
|
|
|
test('sitePurge: a proxied request is refused before the token is checked', () => {
|
|
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
|
|
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied')
|
|
})
|
|
|
|
test('sitePurge: authorize ok with the token and unauthorized otherwise', () => {
|
|
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok')
|
|
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok')
|
|
for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) {
|
|
assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`)
|
|
}
|
|
})
|
|
|
|
/** A fake unstorage prefix storage over a plain key list. */
|
|
function fakeStorage(keys: string[]) {
|
|
const live = new Set(keys)
|
|
const removed: string[] = []
|
|
return {
|
|
removed,
|
|
live,
|
|
async getKeys(base?: string) {
|
|
return [...live].filter((k) => !base || k === base || k.startsWith(base + ':'))
|
|
},
|
|
async removeItem(key: string) {
|
|
live.delete(key)
|
|
removed.push(key)
|
|
},
|
|
}
|
|
}
|
|
|
|
test('sitePurge: purge removes only nitro:routes: keys', async () => {
|
|
const routes = [
|
|
`${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`,
|
|
`${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`,
|
|
`${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`,
|
|
]
|
|
const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json']
|
|
const storage = fakeStorage([...routes, ...others])
|
|
assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes')
|
|
assert.equal(await purgeSiteRouteCache(storage), routes.length)
|
|
assert.deepEqual([...storage.removed].sort(), [...routes].sort())
|
|
assert.deepEqual([...storage.live].sort(), [...others].sort())
|
|
assert.equal(await purgeSiteRouteCache(storage), 0)
|
|
})
|
|
|
|
test('sitePurge: purge event kinds and slugs', () => {
|
|
for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) {
|
|
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' })
|
|
}
|
|
for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) {
|
|
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind))
|
|
}
|
|
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' })
|
|
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200)
|
|
for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) {
|
|
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)
|
|
}
|
|
})
|