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.5.0
This commit is contained in:
@@ -109,6 +109,7 @@ Runtime config, overridable by environment variables at start:
|
||||
| `runtimeConfig.journal.apiBase` | `NUXT_JOURNAL_API_BASE` | `http://127.0.0.1:8096/_journal/api/v1` | Server-side API base for SSR, the sitemap and RSS. Use the internal address of the SummerCMS application, not its public URL. |
|
||||
| `runtimeConfig.public.journal.apiBase` | `NUXT_PUBLIC_JOURNAL_API_BASE` | `/_journal/api/v1` | Browser API base for client-side navigation; the reverse proxy routes it to the application. |
|
||||
| `runtimeConfig.siteCache.purgeToken` | `NUXT_SITE_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the shared [site cache purge](#site-cache-purge) route `/api/__site__/cache/purge`. With fewer than 32 characters the route answers 404. |
|
||||
| `runtimeConfig.siteCache.allowProxied` | `NUXT_SITE_CACHE_ALLOW_PROXIED` | `false` | `true` lets the shared purge route accept requests that came through a reverse proxy, for a backend that reaches the site at an https URL on another host; the bearer token is then the only gate. |
|
||||
| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): token of the old route `/api/__journal__/cache/purge`. With fewer than 32 characters that route answers 404. |
|
||||
|
||||
The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`.
|
||||
@@ -127,7 +128,7 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth
|
||||
|
||||
- POST only.
|
||||
- `404` when the route's token holds fewer than 32 characters, so an app without a token has no purge route.
|
||||
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy.
|
||||
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy. The shared route makes one exception, below.
|
||||
- `401` for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.
|
||||
- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }` and `'/api/__site__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache.
|
||||
|
||||
@@ -139,6 +140,7 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth
|
||||
|
||||
- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_SITE_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`. Keep it out of committed files.
|
||||
- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__site__/cache/purge`), not through the public site.
|
||||
- A backend on another host calls an https `site_cache.purge_url` through the site's TLS-terminating proxy, which adds forwarding headers. Set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` (`runtimeConfig.siteCache.allowProxied`) and the shared route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret, and let the proxy forward POST to this one path only. Unset, proxied requests stay refused. The old `/api/__journal__/cache/purge` route never accepts them.
|
||||
- The reverse proxy must not forward POST to Nuxt (for example nginx `limit_except GET HEAD { deny all; }`), so the route stays off the public path even before its own checks.
|
||||
- The purge clears the cache storage of the Nuxt process that answers. A server with several Nuxt processes needs a shared storage driver for the `cache` mount, or every process keeps its own entries.
|
||||
|
||||
|
||||
@@ -41,6 +41,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: {
|
||||
journal: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.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