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
2 changes: 1 addition & 1 deletion examples/protect/demo-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
"category": "pii-exposure",
"action": "redact",
"rule_v2": [
{ "parameter": "response.body", "match": { "type": "regex", "value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/" } }
{ "parameter": "response.body", "match": { "type": "regex", "value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+){0,8}\\.[A-Za-z]{2,}/" } }
],
"_demo": {
"desc": "An endpoint returns a user's email; the address is masked",
Expand Down
4 changes: 3 additions & 1 deletion rule-contract.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "Generated from src/protect/rules/contract.js by scripts/emit-rule-contract.mjs. Do not edit.",
"version": "2.9",
"version": "2.10",
"sources": {
"raw": {
"keyed": false
Expand Down Expand Up @@ -433,6 +433,8 @@
"maxRules": 5000,
"maxWhitelists": 2000,
"maxConditionsPerRule": 250,
"maxConditionNodesPerRule": 1000,
"maxConditionNodesPerBundle": 25000,
"maxNestingDepth": 12,
"maxRegexLength": 1000,
"maxValueLength": 8192
Expand Down
2 changes: 1 addition & 1 deletion src/protect/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const DEFAULT_RESPONSE_RULES = [
//
// Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
// body, where the newline is the two characters `\` and `n`.
rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/' } }]
rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(?:\\n|\\\\n)\\s*at\\s+\\S[^\\r\\n(]*\\((?:node:|[A-Za-z]:[\\\\/])?[^:\\r\\n]+:\\d+:\\d+\\)/' } }]
},
{
id: 'resp-sql-error',
Expand Down
3 changes: 2 additions & 1 deletion src/protect/engine/client.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { safeBaseUrl } from '../safe-origin.js';
import { readBoundedJson } from './response-json.js';

const DEFAULT_BASE_URL = 'https://api.patchstack.com';
const DEFAULT_CACHE_TTL = 300_000;
Expand Down Expand Up @@ -71,7 +72,7 @@ export class PatchstackRuleClient {
};
}

const data = await response.json();
const data = await readBoundedJson(response);

// A 200 that isn't a genuine rule envelope must not be treated as "no rules". Report failure
// so the caller falls back to the cache / bundled rules rather than caching an empty bundle.
Expand Down
97 changes: 91 additions & 6 deletions src/protect/engine/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,92 @@ const REDOS_PATTERNS = [
new RegExp('\\(' + GRP + '\\|' + GRP + '\\)\\s*[+*]') // nested alternation under a quantifier
];

// Adjacent unbounded atoms can still make rejection polynomial even without a nested group (`a+b+c`).
// A literal delimiter resets the run only when the preceding atom cannot consume that delimiter. This
// keeps structured patterns expressible without letting a harmless prefix conceal a risky suffix.
function hasAdjacentUnbounded(body, flags) {
let previousUnboundedAtom = null;
for (let i = 0; i < body.length;) {
const start = i;
const ch = body[i];
let assertion = false;
let literal = null;
let atom = ch;

if (ch === '\\') {
const escaped = body[i + 1] ?? '';
assertion = /[bBAZz]/.test(escaped);
if (!/[bBAZzdswDSWnrtvf0-9pPkKxuc]/.test(escaped)) literal = escaped;
i += Math.min(2, body.length - i);
atom = body.slice(start, i);
} else if (ch === '[') {
i++;
while (i < body.length) {
if (body[i] === '\\') i += 2;
else if (body[i++] === ']') break;
}
atom = body.slice(start, i);
} else if (ch === ')') {
i++;
atom = '.'; // Conservatively treat a quantified group as capable of consuming a delimiter.
} else if ('(|'.includes(ch)) {
previousUnboundedAtom = null;
i++;
continue;
} else if ('^$'.includes(ch)) {
i++;
continue;
} else if ('?*+{}'.includes(ch)) {
previousUnboundedAtom = null;
i++;
continue;
} else {
if (ch !== '.') literal = ch;
i++;
}

if (assertion) continue;

let unbounded = false;
if (body[i] === '*' || body[i] === '+') {
unbounded = true;
i++;
} else if (body[i] === '{') {
const quantifier = /^\{\d*,\}/.exec(body.slice(i));
if (quantifier) {
unbounded = true;
i += quantifier[0].length;
}
}
if (unbounded && (body[i] === '?' || body[i] === '+')) i++;

if (unbounded && previousUnboundedAtom !== null) return true;
if (unbounded) {
previousUnboundedAtom = atom;
} else if (previousUnboundedAtom !== null) {
try {
const atomFlags = flags.replace(/[^iu]/g, '');
const complementaryClass =
(previousUnboundedAtom === '\\s' && atom === '\\S') ||
(previousUnboundedAtom === '\\S' && atom === '\\s') ||
(previousUnboundedAtom === '\\d' && atom === '\\D') ||
(previousUnboundedAtom === '\\D' && atom === '\\d') ||
(previousUnboundedAtom === '\\w' && atom === '\\W') ||
(previousUnboundedAtom === '\\W' && atom === '\\w');
const excludedLiteral =
literal !== null && !new RegExp(`^(?:${previousUnboundedAtom})$`, atomFlags).test(literal);
if (complementaryClass || excludedLiteral) {
previousUnboundedAtom = null;
}
} catch {
// If exclusion cannot be proved, retain the preceding atom as a conservative backstop.
}
}
if (i === start) i++;
}
return false;
}

// Report once when a rule's regex is rejected (ReDoS-shaped or unparseable). Unlike an unknown match
// type, a rejected regex used to fail silently — so a delivered rule protected nothing and nobody knew.
const warnedRejectedPatterns = new Set();
Expand All @@ -44,17 +130,16 @@ export function safeRegExp(pattern) {
return null;
}

for (const dangerous of REDOS_PATTERNS) {
if (dangerous.test(pattern)) {
return null;
}
}

const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s);
if (!match) {
return null;
}

for (const dangerous of REDOS_PATTERNS) {
if (dangerous.test(match[1])) return null;
}
if (hasAdjacentUnbounded(match[1], match[2])) return null;

try {
return new RegExp(match[1], match[2]);
} catch {
Expand Down
3 changes: 2 additions & 1 deletion src/protect/engine/pulse-client.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { safeBaseUrl } from '../safe-origin.js';
import { pulseAuthHeader } from '../../pulse-token.js';
import { canonicalBuildId } from '../../build-id.js';
import { readBoundedJson } from './response-json.js';

const DEFAULT_BASE_URL = 'https://api.patchstack.com/monitor/pulse';
const DEFAULT_CACHE_TTL = 300_000;
Expand Down Expand Up @@ -123,7 +124,7 @@ export class PulseRuleClient {
if (!response.ok) {
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
}
const data = await response.json();
const data = await readBoundedJson(response);
// A 200 that isn't a genuine rule envelope (schema drift, a proxy/interstitial page, an
// {error} body) must not be treated as "no rules". Report failure so the caller falls back to
// the cache / bundled rules rather than caching an empty bundle.
Expand Down
47 changes: 47 additions & 0 deletions src/protect/engine/response-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export const MAX_RULE_RESPONSE_BYTES = 5 * 1024 * 1024;

/** Parse a JSON response without allowing an endpoint to make the runtime buffer an unbounded body. */
export async function readBoundedJson(response, maxBytes = MAX_RULE_RESPONSE_BYTES) {
const stated = Number(response.headers?.get?.('content-length'));
if (Number.isFinite(stated) && stated > maxBytes) {
throw new Error(`rule response exceeds ${maxBytes} bytes`);
}

const stream = response.body;
if (!stream || typeof stream.getReader !== 'function') {
if (typeof response.text === 'function') {
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > maxBytes) {
throw new Error(`rule response exceeds ${maxBytes} bytes`);
}
return JSON.parse(text);
}
throw new Error('rule response body cannot be read with a size bound');
}

const reader = stream.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
void reader.cancel().catch(() => {});
throw new Error(`rule response exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}

const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(bytes));
}
4 changes: 3 additions & 1 deletion src/protect/rules/contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// `rule-contract.json` is the published form. `tests/protect/rule-contract.test.ts` reads the engine's own
// source and asserts these descriptions match what it implements.

export const CONTRACT_VERSION = '2.9';
export const CONTRACT_VERSION = '2.10';

/**
* Every parameter source, and what it accepts after the dot.
Expand Down Expand Up @@ -482,6 +482,8 @@ export const LIMITS = Object.freeze({
maxRules: 5000,
maxWhitelists: 2000,
maxConditionsPerRule: 250,
maxConditionNodesPerRule: 1000,
maxConditionNodesPerBundle: 25_000,
maxNestingDepth: 12,
maxRegexLength: 1000,
maxValueLength: 8192,
Expand Down
32 changes: 25 additions & 7 deletions src/protect/rules/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
nullPropertyProblem,
rulePropertyProblem,
} from './contract.js';
import { safeRegExp } from '../engine/engine.js';

export const LIMITS = CONTRACT_LIMITS;

Expand All @@ -45,14 +46,15 @@ export function validateBundle(bundle, opts = {}) {
const rejected = [];
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
const bundleBudget = { count: 0 };

const firewall = [];
for (const rule of inFirewall) {
if (firewall.length >= LIMITS.maxRules) {
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
continue;
}
const reason = enforceableRuleProblem(rule);
const reason = enforceableRuleProblem(rule, bundleBudget);
if (reason) rejected.push({ id: idOf(rule), reason });
else firewall.push(rule);
}
Expand All @@ -77,7 +79,7 @@ export function validateBundle(bundle, opts = {}) {
rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
continue;
}
const reason = conditionsProblem(wl?.rule_v2);
const reason = conditionsProblem(wl?.rule_v2, 0, { count: 0 }, bundleBudget);
if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
else whitelists.push(wl);
}
Expand All @@ -102,7 +104,7 @@ function idOf(rule) {
*
* @returns {string|null} a reason the rule must be dropped, or null when it's acceptable.
*/
export function enforceableRuleProblem(rule) {
export function enforceableRuleProblem(rule, bundleBudget = null) {
if (!rule || typeof rule !== 'object') return 'not an object';

// Before anything reads a property: a property that is PRESENT and null is not an omission. Every layer
Expand All @@ -129,22 +131,32 @@ export function enforceableRuleProblem(rule) {
const propertyReason = rulePropertyProblem(rule);
if (propertyReason) return propertyReason;

return conditionsProblem(rule.rule_v2);
return conditionsProblem(rule.rule_v2, 0, { count: 0 }, bundleBudget);
}

function conditionsProblem(conditions, depth = 0) {
function conditionsProblem(conditions, depth = 0, ruleBudget = { count: 0 }, bundleBudget = null) {
if (!Array.isArray(conditions)) return 'rule_v2 must be an array of conditions';
if (conditions.length === 0) return 'rule_v2 is empty (would never match)';
if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
if (conditions.length > LIMITS.maxConditionsPerRule) {
return `more than ${LIMITS.maxConditionsPerRule} conditions`;
}
for (const c of conditions) {
ruleBudget.count++;
if (ruleBudget.count > LIMITS.maxConditionNodesPerRule) {
return `more than ${LIMITS.maxConditionNodesPerRule} total condition nodes`;
}
if (bundleBudget) {
bundleBudget.count++;
if (bundleBudget.count > LIMITS.maxConditionNodesPerBundle) {
return `bundle exceeds ${LIMITS.maxConditionNodesPerBundle} total condition nodes`;
}
}
const shapeReason = conditionShapeProblem(c);
if (shapeReason) return shapeReason;

if (isGroup(c)) {
const nested = conditionsProblem(c.rules, depth + 1);
const nested = conditionsProblem(c.rules, depth + 1, ruleBudget, bundleBudget);
if (nested) return nested;
continue; // a group carries no match of its own
}
Expand All @@ -164,6 +176,7 @@ function conditionsProblem(conditions, depth = 0) {
if (m.type === 'regex') {
if (typeof m.value !== 'string') return 'regex match.value must be a string';
if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
if (safeRegExp(m.value) === null) return 'regex is invalid or has an unsafe repetition shape';
} else if (typeof m.value === 'string' && m.value.length > LIMITS.maxValueLength) {
return `match.value longer than ${LIMITS.maxValueLength} chars`;
}
Expand All @@ -173,7 +186,12 @@ function conditionsProblem(conditions, depth = 0) {
// The PARENT's parameter is carried down. The sub-match has none of its own — it applies to whatever
// the key path navigated to — so validating it in isolation reported every one of them as a match
// type missing a parameter, which would have rejected a shipped capability as malformed.
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
const nested = conditionsProblem(
[{ parameter: c.parameter, match: m.match }],
depth + 1,
ruleBudget,
bundleBudget,
);
if (nested) return nested;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/protect/templates/demo-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@
"parameter": "response.body",
"match": {
"type": "regex",
"value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/"
"value": "/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+){0,8}\\.[A-Za-z]{2,}/"
}
}
]
Expand Down
Loading