fix: keep the site cache purge token out of the page payload (NUXT_CACHE_PURGE_TOKEN)

nuxt-site-config copies every NUXT_SITE_* variable into the public site
config, so NUXT_SITE_CACHE_PURGE_TOKEN reached every page's payload as
cachePurgeToken (and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied).

- The purge settings move to runtimeConfig cachePurge.token
  (NUXT_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
  (NUXT_CACHE_PURGE_ALLOW_PROXIED), outside that prefix.
- The old siteCache.* names keep working for one release: a non-empty
  new token wins, either allowProxied flag turns it on, and one
  deprecation warning per process names the variables (never a value).
- The shared Nitro plugin server/plugins/site-cache-purge.ts strips
  cachePurgeToken and cacheAllowProxied from nuxt-site-config's stack on
  site-config:init, so a site on the old names leaks nothing either; it
  does nothing without nuxt-site-config.
- The shared lib, route and plugin and tests/sitePurge.test.ts are
  byte-identical in vue-journal-layer, vue-menu-layer and vue-faq-layer,
  with three sha256 pins.
- README: new names, the deprecation, the stripping and the advice to
  rotate a token ever set under the old name.
This commit is contained in:
Jakub Zych
2026-10-20 20:49:34 +02:00
parent c4639c2020
commit ef3a090647
7 changed files with 416 additions and 41 deletions

View File

@@ -1,34 +1,38 @@
// POST /api/__site__/cache/purge: removes every Nitro route-cache entry
// (pages, _payload.json and feeds), so an admin save shows on the next page
// view. The backend calls it on loopback after the save commits, with
// `Authorization: Bearer <runtimeConfig.siteCache.purgeToken>` and the body
// {"kind":"<kind>","slug":"<slug>"}; the answer is {"purged":<n>}. The kind
// and slug only reach the log line.
// `Authorization: Bearer <runtimeConfig.cachePurge.token>`
// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"<kind>","slug":"<slug>"};
// the answer is {"purged":<n>}. The kind 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)
// 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.
// unless runtimeConfig.cachePurge.allowProxied
// (NUXT_CACHE_PURGE_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.
//
// Deprecated for one release: siteCache.purgeToken
// (NUXT_SITE_CACHE_PURGE_TOKEN) and siteCache.allowProxied
// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new names are unset (see
// resolveSitePurgeConfig).
//
// Shared: several layers ship this file byte for byte, and Nitro keeps one
// handler per route and method.
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache } from '../../../lib/siteCachePurge.ts'
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache, resolveSitePurgeConfig } from '../../../lib/siteCachePurge.ts'
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 { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event))
const verdict = authorizeSitePurge({
token: siteCache?.purgeToken,
token,
authorization: getRequestHeader(event, 'authorization'),
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
allowProxied: siteCache?.allowProxied === true || siteCache?.allowProxied === 'true',
allowProxied,
})
if (verdict === 'disabled' || verdict === 'proxied') {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })

View File

@@ -1,8 +1,11 @@
// Shared site cache purge: the authorization rules of the purge route
// POST /api/__site__/cache/purge, the purge of Nitro's route cache and the
// parsing of the purge request body. Pure TypeScript outside Nitro's and
// Nuxt's auto-import directories, so several layers can ship this file
// byte for byte, and `node --test` and the route import it directly.
// POST /api/__site__/cache/purge, the purge of Nitro's route cache, the
// parsing of the purge request body, the purge settings read from runtime
// config (new and deprecated names) and the removal of the purge keys from
// nuxt-site-config's site config. Pure TypeScript outside Nitro's and Nuxt's
// auto-import directories: vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship this file byte for byte, and `node --test`, the route
// and the Nitro plugin import it directly.
import { createHash, timingSafeEqual } from 'node:crypto'
@@ -21,16 +24,17 @@ const KIND_PATTERN = /^[a-z_]{1,40}$/
export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized'
export interface SitePurgeAuthInput {
/** The configured token (runtimeConfig.siteCache.purgeToken). */
/** The configured token (see resolveSitePurgeConfig). */
token: unknown
/** The request's Authorization header, if any. */
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.
* The allowProxied setting (see resolveSitePurgeConfig): 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
}
@@ -96,3 +100,109 @@ export function parseSitePurgeEvent(body: unknown): SitePurgeEvent {
const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : ''
return { kind, slug }
}
/**
* The site config keys nuxt-site-config derives from the deprecated
* variables NUXT_SITE_CACHE_PURGE_TOKEN and NUXT_SITE_CACHE_ALLOW_PROXIED: it
* copies every NUXT_SITE_* variable into the site config, camel-cased, and
* Nuxt serializes the site config into every page's payload.
*/
export const SITE_CONFIG_PURGE_KEYS: readonly string[] = Object.freeze(['cachePurgeToken', 'cacheAllowProxied'])
/**
* The globalThis flag that keeps the deprecation warning to one per process,
* however many layers ship the Nitro plugin.
*/
export const SITE_PURGE_WARNED = Symbol.for('summercms.siteCachePurge.deprecationWarned')
export type SitePurgeDeprecated = 'token' | 'allowProxied'
export interface SitePurgeConfig {
/** The token the route checks; authorizeSitePurge decides whether it is usable. */
token: unknown
/** True when a proxied request may reach the token check. */
allowProxied: boolean
/** The settings that came from a deprecated name. */
deprecated: SitePurgeDeprecated[]
}
function section(config: unknown, name: string): Record<string, unknown> {
if (config === null || typeof config !== 'object') return {}
const value = (config as Record<string, unknown>)[name]
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
const isTrue = (value: unknown) => value === true || value === 'true'
/**
* Reads the purge settings from runtime config. The current names are
* `cachePurge.token` (NUXT_CACHE_PURGE_TOKEN) and `cachePurge.allowProxied`
* (NUXT_CACHE_PURGE_ALLOW_PROXIED). Deprecated for one release:
* `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN) and
* `siteCache.allowProxied` (NUXT_SITE_CACHE_ALLOW_PROXIED). A non-empty new
* token wins over the old one; allowProxied is on when either flag is true or
* "true". `deprecated` lists the settings taken from an old name.
*/
export function resolveSitePurgeConfig(config: unknown): SitePurgeConfig {
const current = section(config, 'cachePurge')
const old = section(config, 'siteCache')
const deprecated: SitePurgeDeprecated[] = []
let token: unknown = current.token
if (typeof token !== 'string' || token === '') {
if (typeof old.purgeToken === 'string' && old.purgeToken !== '') {
token = old.purgeToken
deprecated.push('token')
} else if (token === undefined) {
token = old.purgeToken
}
}
let allowProxied = isTrue(current.allowProxied)
if (!allowProxied && isTrue(old.allowProxied)) {
allowProxied = true
deprecated.push('allowProxied')
}
return { token, allowProxied, deprecated }
}
/**
* Removes SITE_CONFIG_PURGE_KEYS from every entry of a nuxt-site-config stack
* (the array of partial site configs it merges per request) and returns how
* many keys it removed. Anything that is not an array of objects is left
* alone and counts 0.
*/
export function redactSiteConfigStack(stack: unknown): number {
if (!Array.isArray(stack)) return 0
let removed = 0
for (const entry of stack) {
if (entry === null || typeof entry !== 'object') continue
for (const key of SITE_CONFIG_PURGE_KEYS) {
if (Object.prototype.hasOwnProperty.call(entry, key)) {
delete (entry as Record<string, unknown>)[key]
removed++
}
}
}
return removed
}
const DEPRECATED_NAMES: Record<SitePurgeDeprecated, [string, string]> = {
token: ['NUXT_SITE_CACHE_PURGE_TOKEN', 'NUXT_CACHE_PURGE_TOKEN'],
allowProxied: ['NUXT_SITE_CACHE_ALLOW_PROXIED', 'NUXT_CACHE_PURGE_ALLOW_PROXIED'],
}
/**
* The one-line deprecation warning for the settings resolveSitePurgeConfig
* took from an old name, or '' when there are none. It names variables
* only, never a value.
*/
export function sitePurgeDeprecationMessage(deprecated: readonly SitePurgeDeprecated[]): string {
const names = [...new Set(deprecated)].filter((d) => d in DEPRECATED_NAMES).map((d) => `${DEPRECATED_NAMES[d][0]} -> ${DEPRECATED_NAMES[d][1]}`)
if (names.length === 0) return ''
return (
`[site-cache] deprecated purge settings in use, rename them: ${names.join(', ')}. ` +
'The old names work until the next minor release. Earlier layer versions published every NUXT_SITE_* value ' +
'in the page payload through nuxt-site-config, so rotate a token that was ever set under NUXT_SITE_CACHE_PURGE_TOKEN.'
)
}

View File

@@ -0,0 +1,48 @@
// Keeps the site cache purge settings out of the public site config.
//
// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and
// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased,
// and Nuxt serializes the site config into every page's payload (and into
// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's
// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as
// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied.
// The settings now live under NUXT_CACHE_PURGE_TOKEN and
// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep
// working for one release, so this plugin also removes the two keys from
// every entry of the site config stack on nuxt-site-config's
// `site-config:init` hook, before anything reads it. The stripping stays
// with the new names too: a site that still sets an old variable, or sets
// both, must not publish it.
//
// When a setting comes from an old name, the plugin logs one deprecation
// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending
// several layers that ship this file warns once). The warning names
// variables, never a value.
//
// Without nuxt-site-config the hook is never called and the plugin does
// nothing else. Shared: several layers ship this file byte for byte;
// running it more than once is harmless.
import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts'
interface SiteConfigInitContext {
siteConfig?: { stack?: unknown }
}
type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown }
export default defineNitroPlugin((nitroApp) => {
const g = globalThis as Record<symbol, unknown>
if (!g[SITE_PURGE_WARNED]) {
const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated)
if (message) {
g[SITE_PURGE_WARNED] = true
console.warn(message)
}
}
// The hook name is untyped in an app without nuxt-site-config.
;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => {
redactSiteConfigStack(ctx?.siteConfig?.stack)
})
})