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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-menu-layer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
13
server/api/__site__/cache/purge.post.ts
vendored
13
server/api/__site__/cache/purge.post.ts
vendored
@@ -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' })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user