From 0b94b01f7008c501b00bbfb9cd33fd5e7cb3bc9b Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 12 Aug 2026 13:46:23 +0200 Subject: [PATCH] feat(protect): response header-mutation actions (set / remove / harden-cookie) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three response-hardening actions so a matched rule mutates the outgoing response's headers instead of blocking the whole response — the right mitigation for CORS misconfig, security-header insertion, and cookie hardening: - `set-header` + `set_headers: {name: value}` — set/overwrite, or `ensure: true` to add only when absent (don't clobber an existing CSP/X-Frame-Options). - `remove-header` + `remove_headers: [names]` — strip a header (e.g. Access-Control-Allow-Credentials on a CORS misconfig, so the response is still served but not cross-origin-readable). - `harden-cookie` — add missing HttpOnly/Secure/SameSite to Set-Cookie (no duplication; `cookie_flags` overridable). Plumbing: header mutations fold into the existing redact-verdict path; a `null` header value now signals removal in both rebuildResponse (fetch) and the node path; rebuildResponse guards null-body statuses (204/205/304/101) so hardening a redirect/no-body response doesn't throw. Block-mode-gated (dry-run observes only), like redact/block. Not a default — authored + route-scoped. +6 tests; 640 pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/protect/runtime.js | 58 ++++++++++++- .../protect/response-header-mutation.test.ts | 85 +++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/protect/response-header-mutation.test.ts diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 6b819fc..939dccc 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -181,6 +181,7 @@ export async function createProtection(options = {}) { const screenText = (text, meta, reqCtx) => { let blockRule = null; const redactions = []; + const headerMutations = []; let lowerText = null; // lazily lowercased body, only if a rule uses a prefilter for (const { rule, engine: re, redactors, prefilter } of responseRuleSet) { // Cheap pre-filter: if none of the rule's literal anchors is in the body, its regex can't @@ -204,9 +205,10 @@ export async function createProtection(options = {}) { onDetect({ phase: 'response', mode, category: rule.category, rule, message: result.message }); if (mode !== 'block') continue; // dry-run: observe only if (redactors && redactors.length) redactions.push({ rule, redactors }); + else if (isHeaderMutation(rule.action)) headerMutations.push(rule); else if (!blockRule) blockRule = rule; } - if (mode !== 'block' || (!blockRule && !redactions.length)) return { verdict: 'pass' }; + if (mode !== 'block' || (!blockRule && !redactions.length && !headerMutations.length)) return { verdict: 'pass' }; if (blockRule) return { verdict: 'block' }; let body = text; // Redact the offending spans in the body AND in every (string) header value — so a secret @@ -235,6 +237,7 @@ export async function createProtection(options = {}) { } } } + for (const rule of headerMutations) applyHeaderMutation(headers, rule); return { verdict: 'redact', body, headers }; }; @@ -328,7 +331,9 @@ export async function createProtection(options = {}) { // content-length was just removed (the redacted body has a new length); never re-set a // stale one here or the response truncates/hangs. if (name.toLowerCase() === 'content-length') continue; - if (Array.isArray(value)) { + if (value === null || value === undefined) { + try { res.removeHeader && res.removeHeader(name); } catch { /* ignore */ } // header-mutation removal + } else if (Array.isArray(value)) { try { res.setHeader(name, value); } catch { /* ignore invalid header */ } // Set-Cookie array } else if (typeof value === 'string' && current[name] !== value) { try { res.setHeader(name, value); } catch { /* ignore invalid header */ } @@ -839,12 +844,55 @@ function restoreBigInts(text) { return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, 'g'), '$1'); } +// Response-hardening actions. Mutate the (lowercase-keyed) headers object in place; a `null` value +// signals removal to rebuildResponse / the node path. `set-header` sets/overwrites (or `ensure`s only +// when absent); `remove-header` strips; `harden-cookie` adds missing HttpOnly/Secure/SameSite flags. +function isHeaderMutation(action) { + return action === 'set-header' || action === 'remove-header' || action === 'harden-cookie'; +} + +function applyHeaderMutation(headers, rule) { + if (rule.action === 'remove-header') { + for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null; + return; + } + if (rule.action === 'set-header') { + const ensure = rule.ensure === true; // set only when the header is absent (don't clobber) + for (const [name, value] of Object.entries(rule.set_headers ?? {})) { + const key = String(name).toLowerCase(); + const present = headers[key] != null && headers[key] !== ''; + if (ensure && present) continue; + headers[key] = String(value); + } + return; + } + if (rule.action === 'harden-cookie') { + const cookie = headers['set-cookie']; + const flags = rule.cookie_flags ?? {}; + if (Array.isArray(cookie)) { + headers['set-cookie'] = cookie.map((c) => (typeof c === 'string' ? hardenCookie(c, flags) : c)); + } else if (typeof cookie === 'string') { + headers['set-cookie'] = hardenCookie(cookie, flags); + } + } +} + +function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = 'Lax' } = {}) { + let out = String(cookie); + if (httpOnly && !/;\s*httponly/i.test(out)) out += '; HttpOnly'; + if (secure && !/;\s*secure/i.test(out)) out += '; Secure'; + if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`; + return out; +} + function rebuildResponse(response, body, redactedHeaders) { const headers = new Headers(response.headers); headers.delete('content-length'); // body length changed after redaction if (redactedHeaders) { for (const [name, value] of Object.entries(redactedHeaders)) { - if (typeof value === 'string') { + if (value === null || value === undefined) { + try { headers.delete(name); } catch { /* skip */ } // header-mutation removal + } else if (typeof value === 'string') { if (headers.get(name) !== value) { try { headers.set(name, value); } catch { /* invalid header name — skip */ } } @@ -857,7 +905,9 @@ function rebuildResponse(response, body, redactedHeaders) { } } } - return new Response(body, { status: response.status, statusText: response.statusText, headers }); + // Null-body statuses (204/205/304/101) must not carry a body, or the Response constructor throws. + const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304; + return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers }); } function leakResponse() { diff --git a/tests/protect/response-header-mutation.test.ts b/tests/protect/response-header-mutation.test.ts new file mode 100644 index 0000000..14af0fd --- /dev/null +++ b/tests/protect/response-header-mutation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// Response-hardening actions: set-header (with `ensure`), remove-header, harden-cookie. When a rule +// matches, it mutates the outgoing response's headers instead of blocking the whole response — the +// right mitigation for CORS misconfig, security-header insertion, and cookie hardening. + +const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} }; +const alwaysCond = [{ parameter: 'response.status', match: { type: 'isset' } }]; // matches every response +const json = (headers: Record) => + new Response('{}', { status: 200, headers: { 'content-type': 'application/json', ...headers } }); +const withRule = (rule: any, mode = 'block') => + createProtection({ rules: emptyBundle, responseRules: [rule], mode }); + +describe('response header mutation', () => { + it('remove-header strips the offending headers (e.g. a CORS misconfig) while serving the response', async () => { + const rule = { + phase: 'response', + category: 'cors', + action: 'remove-header', + remove_headers: ['access-control-allow-origin', 'access-control-allow-credentials'], + rule_v2: [{ parameter: 'response.header.access-control-allow-credentials', match: { type: 'equals', value: 'true' } }], + }; + const p: any = await withRule(rule); + const out = await p.screenResponse(json({ 'access-control-allow-origin': 'https://evil.com', 'access-control-allow-credentials': 'true' })); + expect(out.status).toBe(200); // NOT blocked — served, but hardened + expect(out.headers.get('access-control-allow-credentials')).toBeNull(); + expect(out.headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('set-header with ensure adds security headers when absent, and never clobbers an existing one', async () => { + const rule = { + phase: 'response', + action: 'set-header', + ensure: true, + set_headers: { 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY' }, + rule_v2: alwaysCond, + }; + const p: any = await withRule(rule); + const added = await p.screenResponse(json({})); + expect(added.headers.get('x-content-type-options')).toBe('nosniff'); + expect(added.headers.get('x-frame-options')).toBe('DENY'); + + const existing = await p.screenResponse(json({ 'x-frame-options': 'SAMEORIGIN' })); + expect(existing.headers.get('x-frame-options')).toBe('SAMEORIGIN'); // ensure → preserved + }); + + it('set-header without ensure overwrites', async () => { + const rule = { phase: 'response', action: 'set-header', set_headers: { 'x-frame-options': 'DENY' }, rule_v2: alwaysCond }; + const p: any = await withRule(rule); + const out = await p.screenResponse(json({ 'x-frame-options': 'SAMEORIGIN' })); + expect(out.headers.get('x-frame-options')).toBe('DENY'); + }); + + it('harden-cookie adds missing HttpOnly/Secure/SameSite without duplicating existing flags', async () => { + const rule = { phase: 'response', action: 'harden-cookie', rule_v2: alwaysCond }; + const p: any = await withRule(rule); + + const out = await p.screenResponse(json({ 'set-cookie': 'session=abc' })); + const cookie = out.headers.getSetCookie()[0]; + expect(cookie).toMatch(/HttpOnly/i); + expect(cookie).toMatch(/Secure/i); + expect(cookie).toMatch(/SameSite=Lax/i); + + const already = await p.screenResponse(json({ 'set-cookie': 'session=abc; HttpOnly' })); + const cookie2 = already.headers.getSetCookie()[0]; + expect((cookie2.match(/HttpOnly/gi) || []).length).toBe(1); // not duplicated + }); + + it('mutates headers on a bodyless redirect (302) and preserves status + Location', async () => { + const rule = { phase: 'response', action: 'set-header', set_headers: { 'x-frame-options': 'DENY' }, rule_v2: alwaysCond }; + const p: any = await withRule(rule); + const out = await p.screenResponse(new Response(null, { status: 302, headers: { location: '/dashboard' } })); + expect(out.status).toBe(302); + expect(out.headers.get('location')).toBe('/dashboard'); + expect(out.headers.get('x-frame-options')).toBe('DENY'); + }); + + it('does not mutate in dry-run (observe only)', async () => { + const rule = { phase: 'response', action: 'remove-header', remove_headers: ['x-secret'], rule_v2: alwaysCond }; + const p: any = await withRule(rule, 'dry-run'); + const out = await p.screenResponse(json({ 'x-secret': 'value' })); + expect(out.headers.get('x-secret')).toBe('value'); // unchanged + }); +});