From c4639c2020a1d3a88bec3077c551ac659d35f342 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 22:42:09 +0200 Subject: [PATCH] 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 --- README.md | 1 + nuxt.config.ts | 6 ++++ package.json | 2 +- server/api/__site__/cache/purge.post.ts | 13 ++++++--- server/lib/siteCachePurge.ts | 17 +++++++---- tests/sitePurge.test.ts | 38 ++++++++++++++++++++++--- 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ce4ecb2..6d8cad5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Authorization: Bearer - 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:/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. diff --git a/nuxt.config.ts b/nuxt.config.ts index 04a765d..b2923d7 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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: { diff --git a/package.json b/package.json index 0c3a2bf..bb8aac4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vue-menu-layer", - "version": "0.2.0", + "version": "0.3.0", "private": true, "type": "module", "main": "./nuxt.config.ts", diff --git a/server/api/__site__/cache/purge.post.ts b/server/api/__site__/cache/purge.post.ts index 2409806..78a56ae 100644 --- a/server/api/__site__/cache/purge.post.ts +++ b/server/api/__site__/cache/purge.post.ts @@ -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' }) diff --git a/server/lib/siteCachePurge.ts b/server/lib/siteCachePurge.ts index 73ae62b..f3b5b74 100644 --- a/server/lib/siteCachePurge.ts +++ b/server/lib/siteCachePurge.ts @@ -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' diff --git a/tests/sitePurge.test.ts b/tests/sitePurge.test.ts index 733f057..644128d 100644 --- a/tests/sitePurge.test.ts +++ b/tests/sitePurge.test.ts @@ -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; body?: unknown; bodyError?: boolean; keys?: string[] }) { +async function callRoute(opts: { token?: unknown; allowProxied?: unknown; headers?: Record; body?: unknown; bodyError?: boolean; keys?: string[] }) { const responseHeaders: Record = {} const logs: string[] = [] const live = new Set(opts.keys ?? []) @@ -148,7 +148,7 @@ async function callRoute(opts: { token?: unknown; headers?: Record { 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) + } +})