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
This commit is contained in:
Jakub Zych
2026-10-15 22:42:09 +02:00
parent dd0ee35085
commit 255c0fdd00
6 changed files with 63 additions and 14 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.
- 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).
- 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.
- 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
// route answers 404.
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: {
menu: {

View File

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

View File

@@ -6,9 +6,12 @@
// and slug only reach the log line.
//
// 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);
// 401 for a missing or wrong bearer token. The '/api/__site__/**' route rule
// keeps this route out of the route cache.
// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded)
// unless runtimeConfig.siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED)
// 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
// handler per route and method.
@@ -20,10 +23,12 @@ const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded']
export default defineEventHandler(async (event) => {
setResponseHeader(event, 'cache-control', 'no-store')
const config = useRuntimeConfig(event)
const siteCache = config.siteCache as { purgeToken?: unknown; allowProxied?: unknown } | undefined
const verdict = authorizeSitePurge({
token: (config.siteCache as { purgeToken?: unknown } | undefined)?.purgeToken,
token: siteCache?.purgeToken,
authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
allowProxied: siteCache?.allowProxied === true || siteCache?.allowProxied === 'true',
})
if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })

View File

@@ -27,6 +27,12 @@ export interface SitePurgeAuthInput {
authorization?: string | null
/** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */
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 {
@@ -36,13 +42,14 @@ function digest(value: string): Buffer {
/**
* Decides a purge request: `disabled` when no token of at least
* MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the
* request came through a reverse proxy (checked before the token), `ok` when
* the Bearer value equals the token (compared in constant time over sha256
* digests) and `unauthorized` otherwise.
* request came through a reverse proxy (checked before the token) unless
* allowProxied is true, `ok` when the Bearer value equals the token (compared
* 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 (proxied) return 'proxied'
if (proxied && allowProxied !== true) return 'proxied'
const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '')
if (!m) return '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
// vue-faq-layer ship them byte for byte; a change lands in all three with
// new values here.
const SHARED_LIB_SHA256 = '7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8'
const SHARED_ROUTE_SHA256 = 'c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06'
const SHARED_LIB_SHA256 = '0ea9f0f936134516872a2edf9b8e09feedddfa23e9da05c57d06c772193b260e'
const SHARED_ROUTE_SHA256 = '8f86226074abf10c2ecc1d181b6957e90ed8d900589a6372214f50cdbfcd532c'
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
@@ -139,7 +139,7 @@ test('sitePurge: purge event edge cases', () => {
})
/** One call of the shared route with Nitro's auto-imports stubbed. */
async function callRoute(opts: { token?: unknown; headers?: Record<string, string>; body?: unknown; bodyError?: boolean; keys?: string[] }) {
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 ?? [])
@@ -148,7 +148,7 @@ async function callRoute(opts: { token?: unknown; headers?: Record<string, strin
g.setResponseHeader = (_e: unknown, name: string, value: string) => {
responseHeaders[name] = value
}
g.useRuntimeConfig = () => ({ siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token } })
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 () => {
@@ -215,3 +215,33 @@ test('sitePurge: route purges, answers the count and logs kind and slug only', a
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)
}
})