Skip to content

Commit fa39700

Browse files
feat(protect): response header-mutation actions (set / remove / harden-cookie) (#111)
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 <noreply@anthropic.com>
1 parent de1e96b commit fa39700

2 files changed

Lines changed: 139 additions & 4 deletions

File tree

‎src/protect/runtime.js‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export async function createProtection(options = {}) {
181181
const screenText = (text, meta, reqCtx) => {
182182
let blockRule = null;
183183
const redactions = [];
184+
const headerMutations = [];
184185
let lowerText = null; // lazily lowercased body, only if a rule uses a prefilter
185186
for (const { rule, engine: re, redactors, prefilter } of responseRuleSet) {
186187
// 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 = {}) {
204205
onDetect({ phase: 'response', mode, category: rule.category, rule, message: result.message });
205206
if (mode !== 'block') continue; // dry-run: observe only
206207
if (redactors && redactors.length) redactions.push({ rule, redactors });
208+
else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
207209
else if (!blockRule) blockRule = rule;
208210
}
209-
if (mode !== 'block' || (!blockRule && !redactions.length)) return { verdict: 'pass' };
211+
if (mode !== 'block' || (!blockRule && !redactions.length && !headerMutations.length)) return { verdict: 'pass' };
210212
if (blockRule) return { verdict: 'block' };
211213
let body = text;
212214
// 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 = {}) {
235237
}
236238
}
237239
}
240+
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
238241
return { verdict: 'redact', body, headers };
239242
};
240243

@@ -328,7 +331,9 @@ export async function createProtection(options = {}) {
328331
// content-length was just removed (the redacted body has a new length); never re-set a
329332
// stale one here or the response truncates/hangs.
330333
if (name.toLowerCase() === 'content-length') continue;
331-
if (Array.isArray(value)) {
334+
if (value === null || value === undefined) {
335+
try { res.removeHeader && res.removeHeader(name); } catch { /* ignore */ } // header-mutation removal
336+
} else if (Array.isArray(value)) {
332337
try { res.setHeader(name, value); } catch { /* ignore invalid header */ } // Set-Cookie array
333338
} else if (typeof value === 'string' && current[name] !== value) {
334339
try { res.setHeader(name, value); } catch { /* ignore invalid header */ }
@@ -839,12 +844,55 @@ function restoreBigInts(text) {
839844
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, 'g'), '$1');
840845
}
841846

847+
// Response-hardening actions. Mutate the (lowercase-keyed) headers object in place; a `null` value
848+
// signals removal to rebuildResponse / the node path. `set-header` sets/overwrites (or `ensure`s only
849+
// when absent); `remove-header` strips; `harden-cookie` adds missing HttpOnly/Secure/SameSite flags.
850+
function isHeaderMutation(action) {
851+
return action === 'set-header' || action === 'remove-header' || action === 'harden-cookie';
852+
}
853+
854+
function applyHeaderMutation(headers, rule) {
855+
if (rule.action === 'remove-header') {
856+
for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
857+
return;
858+
}
859+
if (rule.action === 'set-header') {
860+
const ensure = rule.ensure === true; // set only when the header is absent (don't clobber)
861+
for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
862+
const key = String(name).toLowerCase();
863+
const present = headers[key] != null && headers[key] !== '';
864+
if (ensure && present) continue;
865+
headers[key] = String(value);
866+
}
867+
return;
868+
}
869+
if (rule.action === 'harden-cookie') {
870+
const cookie = headers['set-cookie'];
871+
const flags = rule.cookie_flags ?? {};
872+
if (Array.isArray(cookie)) {
873+
headers['set-cookie'] = cookie.map((c) => (typeof c === 'string' ? hardenCookie(c, flags) : c));
874+
} else if (typeof cookie === 'string') {
875+
headers['set-cookie'] = hardenCookie(cookie, flags);
876+
}
877+
}
878+
}
879+
880+
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = 'Lax' } = {}) {
881+
let out = String(cookie);
882+
if (httpOnly && !/;\s*httponly/i.test(out)) out += '; HttpOnly';
883+
if (secure && !/;\s*secure/i.test(out)) out += '; Secure';
884+
if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
885+
return out;
886+
}
887+
842888
function rebuildResponse(response, body, redactedHeaders) {
843889
const headers = new Headers(response.headers);
844890
headers.delete('content-length'); // body length changed after redaction
845891
if (redactedHeaders) {
846892
for (const [name, value] of Object.entries(redactedHeaders)) {
847-
if (typeof value === 'string') {
893+
if (value === null || value === undefined) {
894+
try { headers.delete(name); } catch { /* skip */ } // header-mutation removal
895+
} else if (typeof value === 'string') {
848896
if (headers.get(name) !== value) {
849897
try { headers.set(name, value); } catch { /* invalid header name — skip */ }
850898
}
@@ -857,7 +905,9 @@ function rebuildResponse(response, body, redactedHeaders) {
857905
}
858906
}
859907
}
860-
return new Response(body, { status: response.status, statusText: response.statusText, headers });
908+
// Null-body statuses (204/205/304/101) must not carry a body, or the Response constructor throws.
909+
const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304;
910+
return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers });
861911
}
862912

863913
function leakResponse() {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createProtection } from '../../src/protect/runtime.js';
3+
4+
// Response-hardening actions: set-header (with `ensure`), remove-header, harden-cookie. When a rule
5+
// matches, it mutates the outgoing response's headers instead of blocking the whole response — the
6+
// right mitigation for CORS misconfig, security-header insertion, and cookie hardening.
7+
8+
const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} };
9+
const alwaysCond = [{ parameter: 'response.status', match: { type: 'isset' } }]; // matches every response
10+
const json = (headers: Record<string, string>) =>
11+
new Response('{}', { status: 200, headers: { 'content-type': 'application/json', ...headers } });
12+
const withRule = (rule: any, mode = 'block') =>
13+
createProtection({ rules: emptyBundle, responseRules: [rule], mode });
14+
15+
describe('response header mutation', () => {
16+
it('remove-header strips the offending headers (e.g. a CORS misconfig) while serving the response', async () => {
17+
const rule = {
18+
phase: 'response',
19+
category: 'cors',
20+
action: 'remove-header',
21+
remove_headers: ['access-control-allow-origin', 'access-control-allow-credentials'],
22+
rule_v2: [{ parameter: 'response.header.access-control-allow-credentials', match: { type: 'equals', value: 'true' } }],
23+
};
24+
const p: any = await withRule(rule);
25+
const out = await p.screenResponse(json({ 'access-control-allow-origin': 'https://evil.com', 'access-control-allow-credentials': 'true' }));
26+
expect(out.status).toBe(200); // NOT blocked — served, but hardened
27+
expect(out.headers.get('access-control-allow-credentials')).toBeNull();
28+
expect(out.headers.get('access-control-allow-origin')).toBeNull();
29+
});
30+
31+
it('set-header with ensure adds security headers when absent, and never clobbers an existing one', async () => {
32+
const rule = {
33+
phase: 'response',
34+
action: 'set-header',
35+
ensure: true,
36+
set_headers: { 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY' },
37+
rule_v2: alwaysCond,
38+
};
39+
const p: any = await withRule(rule);
40+
const added = await p.screenResponse(json({}));
41+
expect(added.headers.get('x-content-type-options')).toBe('nosniff');
42+
expect(added.headers.get('x-frame-options')).toBe('DENY');
43+
44+
const existing = await p.screenResponse(json({ 'x-frame-options': 'SAMEORIGIN' }));
45+
expect(existing.headers.get('x-frame-options')).toBe('SAMEORIGIN'); // ensure → preserved
46+
});
47+
48+
it('set-header without ensure overwrites', async () => {
49+
const rule = { phase: 'response', action: 'set-header', set_headers: { 'x-frame-options': 'DENY' }, rule_v2: alwaysCond };
50+
const p: any = await withRule(rule);
51+
const out = await p.screenResponse(json({ 'x-frame-options': 'SAMEORIGIN' }));
52+
expect(out.headers.get('x-frame-options')).toBe('DENY');
53+
});
54+
55+
it('harden-cookie adds missing HttpOnly/Secure/SameSite without duplicating existing flags', async () => {
56+
const rule = { phase: 'response', action: 'harden-cookie', rule_v2: alwaysCond };
57+
const p: any = await withRule(rule);
58+
59+
const out = await p.screenResponse(json({ 'set-cookie': 'session=abc' }));
60+
const cookie = out.headers.getSetCookie()[0];
61+
expect(cookie).toMatch(/HttpOnly/i);
62+
expect(cookie).toMatch(/Secure/i);
63+
expect(cookie).toMatch(/SameSite=Lax/i);
64+
65+
const already = await p.screenResponse(json({ 'set-cookie': 'session=abc; HttpOnly' }));
66+
const cookie2 = already.headers.getSetCookie()[0];
67+
expect((cookie2.match(/HttpOnly/gi) || []).length).toBe(1); // not duplicated
68+
});
69+
70+
it('mutates headers on a bodyless redirect (302) and preserves status + Location', async () => {
71+
const rule = { phase: 'response', action: 'set-header', set_headers: { 'x-frame-options': 'DENY' }, rule_v2: alwaysCond };
72+
const p: any = await withRule(rule);
73+
const out = await p.screenResponse(new Response(null, { status: 302, headers: { location: '/dashboard' } }));
74+
expect(out.status).toBe(302);
75+
expect(out.headers.get('location')).toBe('/dashboard');
76+
expect(out.headers.get('x-frame-options')).toBe('DENY');
77+
});
78+
79+
it('does not mutate in dry-run (observe only)', async () => {
80+
const rule = { phase: 'response', action: 'remove-header', remove_headers: ['x-secret'], rule_v2: alwaysCond };
81+
const p: any = await withRule(rule, 'dry-run');
82+
const out = await p.screenResponse(json({ 'x-secret': 'value' }));
83+
expect(out.headers.get('x-secret')).toBe('value'); // unchanged
84+
});
85+
});

0 commit comments

Comments
 (0)