From a30febcddf09a0547c1fe846b953f5f00526f36c Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 1 Oct 2026 20:07:28 +0200 Subject: [PATCH] fix(protect): screen parsed bodies and responses across generated guards --- src/protect/install/adapters/next.ts | 7 ++- .../install/adapters/tanstack-supabase.ts | 6 +- src/protect/install/generic.ts | 6 +- src/protect/install/seam.ts | 28 +++++++-- src/protect/install/template-upgrade.ts | 46 ++++++++++++++ src/protect/runtime.js | 4 +- src/protect/templates/astro-middleware.ts | 9 +-- src/protect/templates/express-guard.cjs | 13 ++-- src/protect/templates/express-guard.js | 13 ++-- src/protect/templates/express-guard.ts | 13 ++-- src/protect/templates/fastify-plugin.cjs | 58 ++++++++++++++++-- src/protect/templates/fastify-plugin.js | 58 ++++++++++++++++-- src/protect/templates/fastify-plugin.ts | 60 ++++++++++++++++--- src/protect/templates/generic-guard.cjs | 11 ++-- src/protect/templates/generic-guard.js | 11 ++-- src/protect/templates/generic-guard.ts | 13 ++-- src/protect/templates/guard.ts | 11 ++-- src/protect/templates/next-guard.ts | 9 +-- src/protect/templates/next-middleware.ts | 9 +-- src/protect/templates/nuxt-middleware.ts | 9 +-- src/protect/templates/sveltekit-hooks.ts | 9 +-- tests/protect/adapters.test.ts | 4 +- tests/protect/fastify-scope.test.ts | 39 +++++++++++- tests/protect/injection-safety.test.ts | 17 ++++++ tests/protect/seam-contracts.test.ts | 38 +++++++++++- 25 files changed, 392 insertions(+), 109 deletions(-) create mode 100644 src/protect/install/template-upgrade.ts diff --git a/src/protect/install/adapters/next.ts b/src/protect/install/adapters/next.ts index 9740ded8..9dcc7001 100644 --- a/src/protect/install/adapters/next.ts +++ b/src/protect/install/adapters/next.ts @@ -4,6 +4,7 @@ import { bakeSiteUuid, hasDependency, read, log, templatesDir } from '../util.js import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js'; import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js'; import { composeNextMiddleware, composeNextRoute, nextCompiler, nextSourceWired, standardNextRouting, NEXT_MARKER, ROUTE_MARKER } from './next-source.js'; +import { installTemplate } from '../template-upgrade.js'; function middlewareInfo(cwd: string) { const candidates = ['middleware.ts', 'middleware.js', 'src/middleware.ts', 'src/middleware.js']; @@ -85,7 +86,10 @@ function wire(cwd: string, opts: WireOptions): WireResult { const guardPath = join(cwd, mw.guard); const guardConflict = existsSync(guardPath) && !sharedGuardPresent(guardPath); const ensureGuard = () => { - if (existsSync(guardPath)) return; + if (existsSync(guardPath)) { + if (mw.guard.endsWith('.ts') && installTemplate(cwd, mw.guard, 'next-guard.ts')) changed.push(mw.guard); + return; + } const source = read(join(templates, 'next-guard.ts')); writeProjectFileSync(cwd, guardPath, mw.guard.endsWith('.js') ? ts!.transpileModule(source, { compilerOptions: { target: ts!.ScriptTarget.ES2022, module: ts!.ModuleKind.ESNext } }).outputText @@ -101,6 +105,7 @@ function wire(cwd: string, opts: WireOptions): WireResult { changed.push(mw.relFile); log(`scaffolded ${mw.relFile} (request-phase guard)`); } else if (existing.includes(NEXT_MARKER) || existing.includes('#region patchstack-next (')) { + if (existing.includes('#region patchstack-next (') && installTemplate(cwd, mw.relFile, 'next-middleware.ts')) changed.push(mw.relFile); log(`${mw.relFile} already has a Patchstack guard — left as-is`); if (ts && existing.includes(NEXT_MARKER)) ensureGuard(); } else { diff --git a/src/protect/install/adapters/tanstack-supabase.ts b/src/protect/install/adapters/tanstack-supabase.ts index 1b878fa0..ffb2dd3e 100644 --- a/src/protect/install/adapters/tanstack-supabase.ts +++ b/src/protect/install/adapters/tanstack-supabase.ts @@ -12,6 +12,7 @@ import type { Adapter, WireOptions, WireResult, VerifyResult } from '../types.js import { copyProjectFileSync, ensureProjectDirectorySync, writeProjectFileSync } from '../../../safe-file.js'; import { parsedSource, sourceCompiler, type Compiler } from '../syntax.js'; import { matchesGuardTemplate } from '../template-match.js'; +import { installTemplate } from '../template-upgrade.js'; const CLIENT_TUNNEL = [ '', @@ -120,10 +121,7 @@ function scaffold(cwd: string, opts: WireOptions): string[] { const dst = join(cwd, 'src/integrations/patchstack'); ensureProjectDirectorySync(cwd, dst); const changed: string[] = []; - if (!existsSync(join(dst, 'guard.ts'))) { - copyProjectFileSync(cwd, join(templates, 'guard.ts'), join(dst, 'guard.ts')); - changed.push(GUARD_FILE); - } + if (installTemplate(cwd, GUARD_FILE, 'guard.ts')) changed.push(GUARD_FILE); const rulesDst = join(dst, 'rules.json'); // Default: the high-precision starter, written only if absent (don't clobber the user's rules on // re-run). --demo: (re)seed the broad multi-class sample bundle for a self-contained demonstration. diff --git a/src/protect/install/generic.ts b/src/protect/install/generic.ts index bbcad72b..28e86cbd 100644 --- a/src/protect/install/generic.ts +++ b/src/protect/install/generic.ts @@ -10,6 +10,7 @@ import type { WireOptions, VerifyResult } from './types.js'; import type { GuardModuleQuery } from './source-scope.js'; import { copyProjectFileSync, ensureProjectDirectorySync } from '../../safe-file.js'; import { matchesGuardTemplate } from './template-match.js'; +import { installTemplate } from './template-upgrade.js'; import { stripComments, maskStringContents, @@ -107,10 +108,7 @@ export function scaffoldGeneric( ensureProjectDirectorySync(cwd, dst); const guardRel = `${dir}/${guardFile}`; const changed: string[] = []; - if (!existsSync(join(dst, guardFile))) { - copyProjectFileSync(cwd, join(templates, guardTemplate), join(dst, guardFile)); - changed.push(guardRel); - } + if (installTemplate(cwd, guardRel, guardTemplate)) changed.push(guardRel); if (!opts.demo && matchesGuardTemplate(cwd, guardRel, guardTemplate)) bakeSiteUuid(cwd, guardRel); const rulesDst = join(dst, 'rules.json'); if (opts.demo || !existsSync(rulesDst)) { diff --git a/src/protect/install/seam.ts b/src/protect/install/seam.ts index a2532cc1..9a69bfc3 100644 --- a/src/protect/install/seam.ts +++ b/src/protect/install/seam.ts @@ -8,6 +8,8 @@ import { join, dirname } from 'node:path'; import { bakeSiteUuid, read, log, templatesDir } from './util.js'; import type { WireOptions, WireResult, VerifyResult } from './types.js'; import { copyProjectFileSync, ensureProjectDirectorySync } from '../../safe-file.js'; +import { parsedSource, sourceCompiler } from './syntax.js'; +import { installTemplate } from './template-upgrade.js'; export interface SeamSpec { templateName: string; // template copied to the seam target when none exists @@ -39,9 +41,8 @@ export function wireSeam(cwd: string, opts: WireOptions, spec: SeamSpec): WireRe const current = existing ? read(join(cwd, existing)) : ''; if (existing && current.includes(spec.marker)) { - // Already ours — do NOT re-copy the template over it: the whole seam file is user-editable - // (unlike the tanstack region-marked blocks), so overwriting would discard any edits. - log(`${existing} already has the Patchstack guard — left as-is`); + // Only an unchanged generated file can be upgraded; customized hooks remain user-owned. + if (installTemplate(cwd, seamRel, spec.templateName)) changed.push(seamRel); return { ok: true, changed }; } if (existing) { @@ -59,13 +60,30 @@ export function wireSeam(cwd: string, opts: WireOptions, spec: SeamSpec): WireRe export function verifySeam(cwd: string, spec: SeamSpec): VerifyResult { const existing = spec.candidates.find((c) => existsSync(join(cwd, c))); const seamRel = existing ?? spec.target; - const present = existing ? read(join(cwd, existing)).includes(spec.marker) : false; + const present = existing ? templateWiringPresent(cwd, existing, spec.templateName) : false; const rulesPresent = existsSync(join(cwd, rulesRel(seamRel))); return { wired: present && rulesPresent, checks: [ - { label: `${spec.seamLabel} present`, ok: present, hint: `run \`patchstack-connect protect\` (writes ${spec.target})` }, + { label: `${spec.seamLabel} wiring verified`, ok: present, hint: `run \`patchstack-connect protect\`; customized hooks need manual verification (${spec.target})` }, { label: 'rules co-located with the guard', ok: rulesPresent, hint: 'run `patchstack-connect protect`' }, ], }; } + +/** Compare executable hook statements, not comments or strings that merely name a guard. */ +export function templateWiringPresent(cwd: string, file: string, template: string): boolean { + const ts = sourceCompiler(cwd); + if (!ts) return false; + const actual = parsedSource(ts, file, read(join(cwd, file))); + const expected = parsedSource(ts, template, read(join(templatesDir(), template))); + if (!actual || !expected) return false; + const printer = ts.createPrinter({ removeComments: true }); + const emit = (node: import('typescript').Node, tree: import('typescript').SourceFile) => printer.printNode(ts.EmitHint.Unspecified, node, tree); + const required = expected.statements.filter(node => + ts.isExportAssignment(node) || (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) + || (ts.isFunctionDeclaration(node) && node.name?.text === 'getProtection') + || (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@patchstack/connect/protect')); + const code = actual.statements.map(node => emit(node, actual)); + return required.length >= 3 && required.every(node => code.filter(text => text === emit(node, expected)).length === 1); +} diff --git a/src/protect/install/template-upgrade.ts b/src/protect/install/template-upgrade.ts new file mode 100644 index 00000000..c05afad5 --- /dev/null +++ b/src/protect/install/template-upgrade.ts @@ -0,0 +1,46 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { read, log, templatesDir } from './util.js'; +import { writeProjectFileSync } from '../../safe-file.js'; + +// Fingerprints of public generated helpers. Only the baked site identity is excluded. +const PREVIOUS: Record = { + 'generic-guard.ts': '2c6bb8324b909e646049c36725704b474a8bfb1b6849c392eb3462eed83727e9', + 'generic-guard.js': '42357bcf0cb0541e584821d03e06ced28f98d4ef91a76a50e39008ef18c63709', + 'generic-guard.cjs': '8de21893474a3139eff9e8fc48a0c13e976e64421281db9e8e93b0c0549361b8', + 'express-guard.ts': '6c30cc82b7cbd3fee62d423fcb20b27eba1d6cc1ed054efcf9d64eb360bc7b17', + 'express-guard.js': 'bce7c76a061297621791d4ebf838449e3880d05f333588fed7bf88431d1e712e', + 'express-guard.cjs': '3a6430f00fc159c28a897e700cc492c306f39b556f0f4cb4b1e718993b6c40b4', + 'fastify-plugin.ts': '5d606cb8f8f2e6fe35b9d377c5568c5a2090a81af9f0acb219ebc15a713792e9', + 'fastify-plugin.js': '03327d437513571d53554ca34c84ba170ebeebb5e89c2602ae340fb82178ab48', + 'fastify-plugin.cjs': 'b3754bf023ebb5f2a22e0a95cfe74bb783034ec3db9ce61a3e7202237e09e1dd', + 'guard.ts': '270e29769f5a7b3ab70ee39970be5a4e150e1b3949877687dca1ed83596afae7', + 'sveltekit-hooks.ts': '0a68389ac9018dc0ab5805356ad78301f039cb4c701bf642e7f986cc959da990', + 'astro-middleware.ts': 'f217e302fb7019de451c7538e9266a5ce38aa6a6651072b6291d4593b31f60ce', + 'nuxt-middleware.ts': 'e377c48cc8c08d20c61c6119bb5f4c8991f0eed3ebd31310b8a0fc6eb5505e4e', + 'next-middleware.ts': '65c163cdb2ddda4fe53c1a3cd20aa6ab63ad7595a66985d9141d595374ecfab3', + 'next-guard.ts': 'e37c4d67619df9d850a7d94b567bd76841b6ac8e774a5b442689482f5374c451', +}; +const identity = /const PS_SITE_UUID = "([^"]*)";/; +const normalized = (source: string) => source.replace(identity, 'const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";'); + +/** Install a missing helper or upgrade a known unmodified one, retaining its baked site UUID. */ +export function installTemplate(cwd: string, file: string, template: string): boolean { + const next = read(join(templatesDir(), template)); + const target = join(cwd, file); + if (!existsSync(target)) { + writeProjectFileSync(cwd, target, next); + return true; + } + const previous = read(target); + if (normalized(previous) === normalized(next)) return false; + if (createHash('sha256').update(normalized(previous)).digest('hex') !== PREVIOUS[template]) { + log(`${file} is customized or unrecognized — preserved; review its request/response wiring and rule-refresh settings manually.`); + return false; + } + const site = identity.exec(previous)?.[1]; + writeProjectFileSync(cwd, target, site ? next.replace(identity, () => `const PS_SITE_UUID = ${JSON.stringify(site)};`) : next); + log(`updated unchanged generated helper ${file}`); + return true; +} diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 5d4c3c66..63f1d9cc 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -1681,14 +1681,14 @@ export async function createProtection(options = {}) { // Wrap a fetch handler: screens the request, then the response (redact/block). fetch(handler) { - return async (request, ...rest) => { + return async function (request, ...rest) { // The request phase's own resolution is carried into the response phase rather than the response // screening making a second one. Two resolutions for one request can disagree, and a response // detection naming a different address than the request detection describes two clients that do // not exist. const { blocked, client } = await screenFetchRequest(request, rest); if (blocked) return blocked; - const response = await handler(request, ...rest); + const response = await handler.call(this, request, ...rest); return screenResp(response, reqContextFromFetch(request, client)); }; diff --git a/src/protect/templates/astro-middleware.ts b/src/protect/templates/astro-middleware.ts index be968399..5ccb7f65 100644 --- a/src/protect/templates/astro-middleware.ts +++ b/src/protect/templates/astro-middleware.ts @@ -29,10 +29,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true } as const; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } diff --git a/src/protect/templates/express-guard.cjs b/src/protect/templates/express-guard.cjs index d5e772ba..857eb90b 100644 --- a/src/protect/templates/express-guard.cjs +++ b/src/protect/templates/express-guard.cjs @@ -30,13 +30,10 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live - // rules periodically — a dependency flagged after boot is then enforced without a restart. - // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there. - const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; const common = { mode, egress: true, refreshMs }; return createProtection( siteUuid @@ -85,7 +82,7 @@ function patchstackMiddleware(req, res, next) { // there is nothing to answer and the request is screened as normal. const screen = () => { getProtection().then( - (active) => active.express()(req, res, carryOn), + (active) => active.express({ screenResponses: true })(req, res, carryOn), (err) => { psStepAside(err); carryOn(); diff --git a/src/protect/templates/express-guard.js b/src/protect/templates/express-guard.js index 454cd645..680e6358 100644 --- a/src/protect/templates/express-guard.js +++ b/src/protect/templates/express-guard.js @@ -31,13 +31,10 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live - // rules periodically — a dependency flagged after boot is then enforced without a restart. - // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there. - const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; const common = { mode, egress: true, refreshMs }; return createProtection( siteUuid @@ -86,7 +83,7 @@ export function patchstackMiddleware(req, res, next) { // there is nothing to answer and the request is screened as normal. const screen = () => { getProtection().then( - (active) => active.express()(req, res, carryOn), + (active) => active.express({ screenResponses: true })(req, res, carryOn), (err) => { psStepAside(err); carryOn(); diff --git a/src/protect/templates/express-guard.ts b/src/protect/templates/express-guard.ts index 9e7ab843..d17c681d 100644 --- a/src/protect/templates/express-guard.ts +++ b/src/protect/templates/express-guard.ts @@ -27,13 +27,10 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live - // rules periodically — a dependency flagged after boot is then enforced without a restart. - // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there. - const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid @@ -82,7 +79,7 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un // there is nothing to answer and the request is screened as normal. const screen = () => { getProtection().then( - (protection) => (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn), + (protection) => (protection.express({ screenResponses: true }) as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn), (err) => { psStepAside(err); carryOn(); diff --git a/src/protect/templates/fastify-plugin.cjs b/src/protect/templates/fastify-plugin.cjs index 6ce12701..2b665816 100644 --- a/src/protect/templates/fastify-plugin.cjs +++ b/src/protect/templates/fastify-plugin.cjs @@ -31,10 +31,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true }; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs }; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules, cacheDir: ".patchstack" } @@ -93,8 +94,23 @@ async function patchstackFastify(fastify) { const host = request.headers?.host ?? "localhost"; const url = `http://${host}${request.url ?? "/"}`; const hasBody = method !== "GET" && method !== "HEAD" && request.body != null; - const body = hasBody ? (typeof request.body === "string" ? request.body : JSON.stringify(request.body)) : undefined; - const blocked = await guard(new Request(url, { method, headers: request.headers, body })); + let blocked; + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : String(value)); + } + const parsed = hasBody && typeof request.body === "object" && !Buffer.isBuffer(request.body); + const body = hasBody ? (parsed ? JSON.stringify(request.body) : request.body) : undefined; + // Fastify already parsed form fields. Screen that object as JSON, not JSON mislabeled as a form. + // Only the screening copy changes; the route keeps its original body, headers and raw stream. + if (parsed) headers.set("content-type", "application/json"); + headers.delete("content-length"); + blocked = await guard(new Request(url, { method, headers, body })); + } catch (err) { + psStepAside(err); + return; + } if (blocked) { const contentType = blocked.headers.get("content-type"); reply.code(blocked.status); @@ -103,6 +119,36 @@ async function patchstackFastify(fastify) { return reply; } }); + + fastify.addHook("onSend", async (request, reply, payload) => { + // Do not consume streams, hijacked responses, or bodyless status codes. + if ((typeof payload !== "string" && !Buffer.isBuffer(payload)) + || request.method === "HEAD" || reply.statusCode < 200 || [204, 205, 304].includes(reply.statusCode)) return payload; + const protection = await getProtection().catch(psStepAside); + if (!protection) return payload; + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(reply.getHeaders())) { + for (const item of Array.isArray(value) ? value : [value]) { + if (item !== undefined) headers.append(name, String(item)); + } + } + const original = new Response(typeof payload === "string" ? payload : new Uint8Array(payload).buffer, { status: reply.statusCode, headers }); + const context = new Request(`http://${request.headers?.host ?? "localhost"}${request.url ?? "/"}`, { method: request.method, headers: request.headers }); + const screened = await protection.screenResponse(original, context); + if (screened === original) return payload; + const body = Buffer.from(await screened.arrayBuffer()); + reply.code(screened.status); + for (const name of Object.keys(reply.getHeaders())) reply.removeHeader(name); + screened.headers.forEach((value, name) => { if (name !== "set-cookie") reply.header(name, value); }); + const cookies = screened.headers.getSetCookie(); + if (cookies.length) reply.header("set-cookie", cookies); + return body; + } catch (err) { + psStepAside(err); + return payload; + } + }); } // Fastify ENCAPSULATES a registered plugin: hooks added inside one apply to that plugin's context and diff --git a/src/protect/templates/fastify-plugin.js b/src/protect/templates/fastify-plugin.js index 91f584ab..e6c5a9b6 100644 --- a/src/protect/templates/fastify-plugin.js +++ b/src/protect/templates/fastify-plugin.js @@ -32,10 +32,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true }; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs }; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules, cacheDir: ".patchstack" } @@ -94,8 +95,23 @@ export async function patchstackFastify(fastify) { const host = request.headers?.host ?? "localhost"; const url = `http://${host}${request.url ?? "/"}`; const hasBody = method !== "GET" && method !== "HEAD" && request.body != null; - const body = hasBody ? (typeof request.body === "string" ? request.body : JSON.stringify(request.body)) : undefined; - const blocked = await guard(new Request(url, { method, headers: request.headers, body })); + let blocked; + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : String(value)); + } + const parsed = hasBody && typeof request.body === "object" && !Buffer.isBuffer(request.body); + const body = hasBody ? (parsed ? JSON.stringify(request.body) : request.body) : undefined; + // Fastify already parsed form fields. Screen that object as JSON, not JSON mislabeled as a form. + // Only the screening copy changes; the route keeps its original body, headers and raw stream. + if (parsed) headers.set("content-type", "application/json"); + headers.delete("content-length"); + blocked = await guard(new Request(url, { method, headers, body })); + } catch (err) { + psStepAside(err); + return; + } if (blocked) { const contentType = blocked.headers.get("content-type"); reply.code(blocked.status); @@ -104,6 +120,36 @@ export async function patchstackFastify(fastify) { return reply; } }); + + fastify.addHook("onSend", async (request, reply, payload) => { + // Do not consume streams, hijacked responses, or bodyless status codes. + if ((typeof payload !== "string" && !Buffer.isBuffer(payload)) + || request.method === "HEAD" || reply.statusCode < 200 || [204, 205, 304].includes(reply.statusCode)) return payload; + const protection = await getProtection().catch(psStepAside); + if (!protection) return payload; + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(reply.getHeaders())) { + for (const item of Array.isArray(value) ? value : [value]) { + if (item !== undefined) headers.append(name, String(item)); + } + } + const original = new Response(typeof payload === "string" ? payload : new Uint8Array(payload).buffer, { status: reply.statusCode, headers }); + const context = new Request(`http://${request.headers?.host ?? "localhost"}${request.url ?? "/"}`, { method: request.method, headers: request.headers }); + const screened = await protection.screenResponse(original, context); + if (screened === original) return payload; + const body = Buffer.from(await screened.arrayBuffer()); + reply.code(screened.status); + for (const name of Object.keys(reply.getHeaders())) reply.removeHeader(name); + screened.headers.forEach((value, name) => { if (name !== "set-cookie") reply.header(name, value); }); + const cookies = screened.headers.getSetCookie(); + if (cookies.length) reply.header("set-cookie", cookies); + return body; + } catch (err) { + psStepAside(err); + return payload; + } + }); } // Fastify ENCAPSULATES a registered plugin: hooks added inside one apply to that plugin's context and diff --git a/src/protect/templates/fastify-plugin.ts b/src/protect/templates/fastify-plugin.ts index efc2a468..ee813d77 100644 --- a/src/protect/templates/fastify-plugin.ts +++ b/src/protect/templates/fastify-plugin.ts @@ -1,6 +1,6 @@ // Patchstack runtime guard for Fastify — plugin. Managed by `patchstack-connect protect`. // Register it once (`app.register(patchstackFastify)`); it adds a preHandler hook that runs the -// request-phase WAF (+ egress SSRF) on every request. Fastify's request/reply aren't Web-Fetch +// request-phase WAF (+ egress SSRF) and filters buffered onSend output. Fastify's request/reply aren't Web-Fetch // shaped, so we reconstruct a Request from the parsed fastify request and run the fetch guard. import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect"; import fallbackRules from "./rules.json"; @@ -29,10 +29,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true } as const; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } @@ -92,8 +93,23 @@ export async function patchstackFastify(fastify: any) { const host = request.headers?.host ?? "localhost"; const url = `http://${host}${request.url ?? "/"}`; const hasBody = method !== "GET" && method !== "HEAD" && request.body != null; - const body = hasBody ? (typeof request.body === "string" ? request.body : JSON.stringify(request.body)) : undefined; - const blocked = await guard(new Request(url, { method, headers: request.headers as HeadersInit, body })); + let blocked; + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : String(value)); + } + const parsed = hasBody && typeof request.body === "object" && !Buffer.isBuffer(request.body); + const body = hasBody ? (parsed ? JSON.stringify(request.body) : request.body) : undefined; + // Fastify already parsed form fields. Screen that object as JSON, not JSON mislabeled as a form. + // Only the screening copy changes; the route keeps its original body, headers and raw stream. + if (parsed) headers.set("content-type", "application/json"); + headers.delete("content-length"); + blocked = await guard(new Request(url, { method, headers, body })); + } catch (err) { + psStepAside(err); + return; + } if (blocked) { const contentType = blocked.headers.get("content-type"); reply.code(blocked.status); @@ -102,6 +118,36 @@ export async function patchstackFastify(fastify: any) { return reply; // stop the request here } }); + + fastify.addHook("onSend", async (request: any, reply: any, payload: any) => { + // Do not consume streams, hijacked responses, or bodyless status codes. + if ((typeof payload !== "string" && !Buffer.isBuffer(payload)) + || request.method === "HEAD" || reply.statusCode < 200 || [204, 205, 304].includes(reply.statusCode)) return payload; + const protection = await getProtection().catch(psStepAside); + if (!protection) return payload; + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(reply.getHeaders())) { + for (const item of Array.isArray(value) ? value : [value]) { + if (item !== undefined) headers.append(name, String(item)); + } + } + const original = new Response(typeof payload === "string" ? payload : new Uint8Array(payload).buffer, { status: reply.statusCode, headers }); + const context = new Request(`http://${request.headers?.host ?? "localhost"}${request.url ?? "/"}`, { method: request.method, headers: request.headers }); + const screened = await protection.screenResponse(original, context); + if (screened === original) return payload; + const body = Buffer.from(await screened.arrayBuffer()); + reply.code(screened.status); + for (const name of Object.keys(reply.getHeaders())) reply.removeHeader(name); + screened.headers.forEach((value, name) => { if (name !== "set-cookie") reply.header(name, value); }); + const cookies = screened.headers.getSetCookie(); + if (cookies.length) reply.header("set-cookie", cookies); + return body; + } catch (err) { + psStepAside(err); + return payload; + } + }); } // #endregion patchstack-fastify diff --git a/src/protect/templates/generic-guard.cjs b/src/protect/templates/generic-guard.cjs index 2a0d5509..b2840501 100644 --- a/src/protect/templates/generic-guard.cjs +++ b/src/protect/templates/generic-guard.cjs @@ -31,10 +31,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true }; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs }; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules, cacheDir: ".patchstack" } @@ -105,7 +106,7 @@ function patchstackMiddleware(req, res, next) { // there is nothing to answer and the request is screened as normal. const screen = () => { getProtection().then( - (active) => active.node()(req, res, carryOn), + (active) => active.node({ screenResponses: true })(req, res, carryOn), (err) => { psStepAside(err); carryOn(); diff --git a/src/protect/templates/generic-guard.js b/src/protect/templates/generic-guard.js index 1b66850a..04ff7497 100644 --- a/src/protect/templates/generic-guard.js +++ b/src/protect/templates/generic-guard.js @@ -32,10 +32,11 @@ export async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true }; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs }; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules, cacheDir: ".patchstack" } @@ -107,7 +108,7 @@ export function patchstackMiddleware(req, res, next) { // there is nothing to answer and the request is screened as normal. const screen = () => { getProtection().then( - (active) => active.node()(req, res, carryOn), + (active) => active.node({ screenResponses: true })(req, res, carryOn), (err) => { psStepAside(err); carryOn(); diff --git a/src/protect/templates/generic-guard.ts b/src/protect/templates/generic-guard.ts index 34c7189d..9569dd06 100644 --- a/src/protect/templates/generic-guard.ts +++ b/src/protect/templates/generic-guard.ts @@ -35,13 +35,10 @@ export async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live - // rules periodically — a dependency flagged after boot is then enforced without a restart. - // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there. - const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid @@ -116,7 +113,7 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un // there is nothing to answer and the request is screened as normal. const screen = () => { getProtection().then( - (protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn), + (protection) => (protection.node({ screenResponses: true }) as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn), (err) => { psStepAside(err); carryOn(); diff --git a/src/protect/templates/guard.ts b/src/protect/templates/guard.ts index ac10fab4..61e8997f 100644 --- a/src/protect/templates/guard.ts +++ b/src/protect/templates/guard.ts @@ -49,9 +49,9 @@ async function getProtection() { async function buildProtection() { // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only. - const mode: "block" | "dry-run" = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; + const mode: "block" | "dry-run" = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; // Egress SSRF screening: block the app's outbound calls to internal / metadata addresses, // but never its own Supabase project. let allowHosts: string[] = []; @@ -60,10 +60,7 @@ async function buildProtection() { } catch { /* ignore a malformed SUPABASE_URL — just don't add an allow entry */ } - // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live - // rules periodically — a dependency flagged after boot is then enforced without a restart. - // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there. - const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; const common = { mode, egress: true, allowHosts, refreshMs }; return createProtection( siteUuid diff --git a/src/protect/templates/next-guard.ts b/src/protect/templates/next-guard.ts index 2a6c8350..e64ba931 100644 --- a/src/protect/templates/next-guard.ts +++ b/src/protect/templates/next-guard.ts @@ -18,10 +18,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const common = { mode, egress: true } as const; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } diff --git a/src/protect/templates/next-middleware.ts b/src/protect/templates/next-middleware.ts index 1749c5ad..a5f51b01 100644 --- a/src/protect/templates/next-middleware.ts +++ b/src/protect/templates/next-middleware.ts @@ -29,10 +29,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true } as const; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } diff --git a/src/protect/templates/nuxt-middleware.ts b/src/protect/templates/nuxt-middleware.ts index 7e80e7ec..e1392659 100644 --- a/src/protect/templates/nuxt-middleware.ts +++ b/src/protect/templates/nuxt-middleware.ts @@ -29,10 +29,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true } as const; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } diff --git a/src/protect/templates/sveltekit-hooks.ts b/src/protect/templates/sveltekit-hooks.ts index cf915164..9f52913a 100644 --- a/src/protect/templates/sveltekit-hooks.ts +++ b/src/protect/templates/sveltekit-hooks.ts @@ -29,10 +29,11 @@ async function getProtection() { } async function buildProtection() { - const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; - const token = process.env.PATCHSTACK_WAF_TOKEN; - const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID; - const common = { mode, egress: true } as const; + const mode = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_MODE) === "dry-run" ? "dry-run" : "block"; + const token = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_WAF_TOKEN); + const siteUuid = PS_SITE_UUID.startsWith("__") ? (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_SITE_UUID) : PS_SITE_UUID; + const refreshMs = (typeof process === "undefined" ? undefined : process.env.PATCHSTACK_ENVIRONMENT) === "sandbox" ? 15000 : 300000; + const common = { mode, egress: true, refreshMs } as const; return createProtection( siteUuid ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } diff --git a/tests/protect/adapters.test.ts b/tests/protect/adapters.test.ts index 240bb66c..44951080 100644 --- a/tests/protect/adapters.test.ts +++ b/tests/protect/adapters.test.ts @@ -74,7 +74,7 @@ describe('Express adapter', () => { expect(server.indexOf('app.use(patchstackMiddleware)')).toBeLessThan(server.indexOf("app.post('/api/tasks'")); expect(existsSync(path.join(dir, guard))).toBe(true); expect(existsSync(path.join(dir, guard.replace(/\.js$/, '.ts')))).toBe(false); - expect(read(dir, guard)).toContain('active.express()'); + expect(read(dir, guard)).toContain('active.express({ screenResponses: true })'); expect(read(dir, guard)).toContain(uuid); expect(runVerify(dir).wired).toBe(true); execFileSync(process.execPath, ['--check', path.join(dir, 'server.js')]); @@ -99,7 +99,7 @@ describe('Express adapter', () => { const server = read(dir, 'server.js'); expect(server).toContain('const { patchstackMiddleware } = require("./patchstack/guard.cjs");'); expect(existsSync(path.join(dir, 'patchstack/guard.cjs'))).toBe(true); - expect(read(dir, 'patchstack/guard.cjs')).toContain('active.express()'); + expect(read(dir, 'patchstack/guard.cjs')).toContain('active.express({ screenResponses: true })'); expect(runVerify(dir).wired).toBe(true); execFileSync(process.execPath, ['--check', path.join(dir, 'server.js')]); execFileSync(process.execPath, ['--check', path.join(dir, 'patchstack/guard.cjs')]); diff --git a/tests/protect/fastify-scope.test.ts b/tests/protect/fastify-scope.test.ts index 578d63d0..e3a5b4e5 100644 --- a/tests/protect/fastify-scope.test.ts +++ b/tests/protect/fastify-scope.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import Fastify from 'fastify'; +import { Readable } from 'node:stream'; import { createProtection } from '../../src/protect/runtime.js'; /** @@ -50,7 +51,7 @@ afterEach(() => { * substitution is one function body, and the export, the hook registration and the encapsulation marker * are the template's own. */ -async function loadPlugin(): Promise<(fastify: unknown) => Promise> { +async function loadPlugin(rules = RULES): Promise<(fastify: unknown) => Promise> { // The protection is passed in through a global rather than imported by the generated module: the // template's own import of the published package cannot resolve from a temp directory, and rewriting it // to an absolute path is the one substitution that would let a broken relative import pass unnoticed. @@ -69,7 +70,7 @@ async function loadPlugin(): Promise<(fastify: unknown) => Promise> { // requests is a verification, and the seam has to behave as it does for ordinary traffic. const preamble = 'const sentinelAnswer = async () => null;\nconst VERIFY_HEADER = "x-patchstack-verify";\n'; - const protection = await createProtection({ mode: 'block', rules: RULES as never }); + const protection = await createProtection({ mode: 'block', rules: rules as never, reportDetections: false, reportFirewallLog: false }); protections.push(protection); (globalThis as Record).__psTestProtection = protection; @@ -84,6 +85,40 @@ async function loadPlugin(): Promise<(fastify: unknown) => Promise> { } describe('the scaffolded Fastify plugin', () => { + it('screens parsed form and JSON fields equally without mutating the route body', async () => { + const plugin = await loadPlugin({firewall:[{id:'synthetic-form',title:'form',rule_v2:[{parameter:'post.message',match:{type:'contains',value:'synthetic-block'}}]}],whitelists:[]}); + const app = Fastify(); + app.addContentTypeParser('application/x-www-form-urlencoded', {parseAs:'string'}, (_req, body, done) => done(null, Object.fromEntries(new URLSearchParams(String(body))))); + await app.register(plugin); + app.post('/contact', async req => ({ body:req.body, type:req.headers['content-type'] })); + try { + for (const type of ['application/json', 'application/x-www-form-urlencoded']) { + const encode = (message: string) => type === 'application/json' ? JSON.stringify({message}) : new URLSearchParams({message}).toString(); + expect((await app.inject({method:'POST',url:'/contact',headers:{'content-type':type},payload:encode('synthetic-block')})).statusCode).toBe(403); + const allowed = await app.inject({method:'POST',url:'/contact',headers:{'content-type':type},payload:encode('hello')}); + expect(allowed.statusCode).toBe(200); + expect(allowed.json()).toEqual({body:{message:'hello'},type}); + } + } finally { await app.close(); } + }); + + it('redacts serialized output, preserves cookies and lets live streams pass through', async () => { + const plugin = await loadPlugin({firewall:[{id:'synthetic-response',title:'redact',phase:'response',action:'redact',rule_v2:[{parameter:'response.body',match:{type:'contains',value:'synthetic-secret'}}]}] as never,whitelists:[]}); + const app = Fastify(); + await app.register(plugin); + app.get('/text', async (_req, reply) => reply.header('set-cookie',['first=1; Path=/','second=2; Path=/']).type('text/plain').send('synthetic-secret')); + app.get('/stream', async (_req, reply) => reply.type('text/event-stream').send(Readable.from(['data: public\n\n']))); + app.get('/empty', async (_req, reply) => reply.code(204).send()); + try { + const redacted = await app.inject('/text'); + expect(redacted.statusCode).toBe(200); + expect(redacted.body).not.toContain('synthetic-secret'); + expect(redacted.headers['set-cookie']).toEqual(['first=1; Path=/','second=2; Path=/']); + expect(Number(redacted.headers['content-length'])).toBe(Buffer.byteLength(redacted.body)); + expect((await app.inject('/stream')).body).toBe('data: public\n\n'); + expect((await app.inject('/empty')).statusCode).toBe(204); + } finally { await app.close(); } + }); it('screens a route registered on the root instance', async () => { // The plain case, and the one encapsulation breaks: `app.get(...)` on the same instance the guard was // registered on is a SIBLING of the plugin's context, not a child of it. diff --git a/tests/protect/injection-safety.test.ts b/tests/protect/injection-safety.test.ts index b1d497b6..17b7c50d 100644 --- a/tests/protect/injection-safety.test.ts +++ b/tests/protect/injection-safety.test.ts @@ -4,6 +4,7 @@ import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import ts from 'typescript'; import { runProtect, runVerify } from '../../src/protect/install/index.js'; +import { installTemplate } from '../../src/protect/install/template-upgrade.js'; const dirs: string[] = []; afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -100,6 +101,22 @@ app.post('/submit', handler); expect(read(cwd, 'server.ts')).toBe(entry); expect(read(cwd, 'patchstack/guard.ts')).toBe(helper); }); + + it('upgrades a recognized unchanged helper and retains its baked site identity', () => { + const template = readFileSync(new URL('../../src/protect/templates/next-guard.ts', import.meta.url),'utf8'); + const previous = template + .replace(/\(typeof process === "undefined" \? undefined : process.env.(PATCHSTACK_\w+)\)/g, 'process.env.$1') + .replace(/ const refreshMs =[^\n]*\n/, '').replace('mode, egress: true, refreshMs','mode, egress: true') + .replace('__PATCHSTACK_SITE_UUID__','00000000-0000-4000-8000-000000000001'); + const cwd = project({}, {'helper.ts':previous}); + expect(installTemplate(cwd,'helper.ts','next-guard.ts')).toBe(true); + expect(read(cwd,'helper.ts')).toContain('300000'); + expect(read(cwd,'helper.ts')).toContain('00000000-0000-4000-8000-000000000001'); + expect(installTemplate(cwd,'helper.ts','next-guard.ts')).toBe(false); + writeFileSync(join(cwd,'helper.ts'),previous+'\n// Custom application policy.\n'); + expect(installTemplate(cwd,'helper.ts','next-guard.ts')).toBe(false); + expect(read(cwd,'helper.ts')).toContain('// Custom application policy.'); + }); }); const client = `export function createSupabaseFetch(supabaseKey: string): typeof fetch { diff --git a/tests/protect/seam-contracts.test.ts b/tests/protect/seam-contracts.test.ts index 61d4360e..da2a59e2 100644 --- a/tests/protect/seam-contracts.test.ts +++ b/tests/protect/seam-contracts.test.ts @@ -3,7 +3,7 @@ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'nod import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { genericVerify, scaffoldGeneric } from '../../src/protect/install/generic.js'; -import { runVerify } from '../../src/protect/install/index.js'; +import { runProtect, runVerify } from '../../src/protect/install/index.js'; import { expressAdapter } from '../../src/protect/install/adapters/express.js'; import { createProtection } from '../../src/protect/runtime.js'; @@ -36,6 +36,42 @@ function template(name: string): string { return readFileSync(new URL(`../../src/protect/templates/${name}`, import.meta.url), 'utf8'); } +describe('executable scaffold verification', () => { + it.each([ + ['@sveltejs/kit', 'src/hooks.server.ts', 'patchstack-sveltekit', 'export const handle = ({event, resolve}) => resolve(event);'], + ['astro', 'src/middleware.ts', 'patchstack-astro', 'export const onRequest = (ctx, next) => next();'], + ['nuxt', 'server/middleware/patchstack.ts', 'patchstack-nuxt', 'export default () => {};'], + ])('does not treat a marker as %s wiring', (dependency, file, marker, source) => { + const cwd = project({'package.json':JSON.stringify({dependencies:{[dependency]:'*'}})}); + runProtect(cwd); + expect(runVerify(cwd).wired).toBe(true); + writeFileSync(join(cwd, file), `// #region ${marker}\n${source}\n// #endregion ${marker}\n`); + expect(runVerify(cwd).wired).toBe(false); + const original = readFileSync(join(cwd,file),'utf8'); + expect(runProtect(cwd).status).toBe('scaffolded'); + expect(readFileSync(join(cwd,file),'utf8')).toBe(original); + }); + + it('preserves a Fetch handler receiver and host arguments, and propagates app errors once', async () => { + const protection = await createProtection({rules:{firewall:[],whitelists:[]},mode:'block',reportDetections:false}); + const context = {label:'app'}; + const options = {context:{tenant:'synthetic'}}; + let calls = 0; + const handler = protection.fetch(function(this: typeof context, request: Request, opts: typeof options) { + calls++; + expect(this).toBe(context); + expect(opts).toBe(options); + if (request.url.endsWith('/error')) throw new Error('application failure'); + return new Response('ok'); + }); + try { + expect(await (await handler.call(context,new Request('https://app.example/'),options)).text()).toBe('ok'); + await expect(handler.call(context,new Request('https://app.example/error'),options)).rejects.toThrow('application failure'); + expect(calls).toBe(2); + } finally { protection.stop(); } + }); +}); + describe('the request a response seam passes on', () => { it('is threaded through every generated seam', () => { // Asserted per template because each is scaffolded independently: the one an app receives is the one