Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -235,6 +237,7 @@ export async function createProtection(options = {}) {
}
}
}
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
return { verdict: 'redact', body, headers };
};

Expand Down Expand Up @@ -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 */ }
Expand Down Expand Up @@ -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 */ }
}
Expand All @@ -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() {
Expand Down
85 changes: 85 additions & 0 deletions tests/protect/response-header-mutation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
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
});
});
Loading