From be9a12d2bb724456ff1f0fa1796e4c889d169eb9 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 18 Aug 2026 18:44:18 +0200 Subject: [PATCH] Put the authored rule shapes in CI, where they cannot rot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authored rule shapes were verified once, by hand, in a local working directory no CI runs. They are the only evidence that the shipped rules are not inert — and that failure mode is the whole reason the verification exists: a rule the engine silently rejects, or one whose match never fires, is indistinguishable from protection. It is in the bundle, it shows in the dashboard, it blocks nothing. Four of these were inert at some point for exactly that kind of reason: an inline-flag regex, a phase left at the default, a parameter source that does not exist. The five shapes now run through the real guard on every build, each payload paired with a benign request, because a rule that blocks the attack and also blocks ordinary traffic has mitigated nothing either. `rule-corpus.test.ts` already proves the engine can express one canonical rule per class; this asserts that these shapes, as authored — AND-ed gadget spellings, mutations, extra carriers — execute as written. Shapes are keyed by mechanism, and the fixture carries payload shapes only: no advisory identifiers, no affected version ranges, no mapping from a shape to what it covers. That boundary is asserted rather than left to review, because this repository is public and the coverage mapping is the product. Per-advisory duplication collapses with it: several advisories share one shape byte for byte, and what actually differs between them is the version scope. Writing this found a drift in the source corpus worth recording: one shape was missing the third condition its own prose described — the carrier that reaches a cookie-borne payload — while the rule that had been verified included it. The shape was weaker than both its documentation and the artifact the assertions covered, and regenerating from it would have quietly dropped that coverage. Both mutation directions are now checked: weakening a rule fails a blocking assertion, broadening one fails a false-positive assertion. --- tests/protect/fixtures/rule-shapes.json | 169 +++++++++++ tests/protect/rule-shapes.test.ts | 364 ++++++++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 tests/protect/fixtures/rule-shapes.json create mode 100644 tests/protect/rule-shapes.test.ts diff --git a/tests/protect/fixtures/rule-shapes.json b/tests/protect/fixtures/rule-shapes.json new file mode 100644 index 0000000..4bb853a --- /dev/null +++ b/tests/protect/fixtures/rule-shapes.json @@ -0,0 +1,169 @@ +{ + "note": "Authored vPatch rule SHAPES, as the engine must execute them. Payload shapes only: no advisory identifiers, no affected version ranges, and no mapping from a shape to the advisories it covers.", + "shapes": { + "prototype-pollution-gadget": { + "title": "Prototype pollution — the gadget must appear literally in the request", + "category": "prototype-pollution", + "phase": "request", + "rule_v2": [ + { + "parameter": "raw", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "__proto__" + } + }, + { + "parameter": "rules", + "rules": [ + { + "parameter": "raw", + "mutations": [ + "urldecode" + ], + "inclusive": true, + "match": { + "type": "contains", + "value": "constructor" + } + }, + { + "parameter": "raw", + "mutations": [ + "urldecode" + ], + "inclusive": true, + "match": { + "type": "contains", + "value": "prototype" + } + } + ] + }, + { + "parameter": "all", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "__proto__" + } + } + ], + "why": "Every one of these is a merge/assign that walks an attacker-controlled key. The gadget cannot be expressed without `__proto__`, or `constructor` together with `prototype`, so the payload shape is the anchor and no parameter name is needed. `raw` is matched because JSON.stringify drops a literal `__proto__` key; `all` catches the query-string and header carriers.", + "falsePositiveRisk": "A request legitimately carrying the word `constructor` AND `prototype` in free text (JS documentation, a code snippet) trips the second condition. Assessed only against sites on the vulnerable version." + }, + "internal-subrequest-header": { + "title": "Next.js middleware authorization bypass via an internal-only header", + "category": "authorization-bypass", + "phase": "request", + "rule_v2": [ + { + "parameter": "server.HTTP_X_MIDDLEWARE_SUBREQUEST", + "match": { + "type": "isset" + } + } + ], + "why": "Middleware is skipped when the request carries `x-middleware-subrequest`. That header is an internal marker the framework sets on its own subrequests; a client has no legitimate reason to send it, so its mere presence is the exploit. Presence, not a value pattern — any value bypasses.", + "falsePositiveRisk": "A proxy or test harness that forwards the header verbatim from an internal call would be blocked. Real, and preferable to leaving authorization bypassable." + }, + "serialized-function-marker": { + "title": "node-serialize remote code execution via the function marker", + "category": "deserialization", + "phase": "request", + "rule_v2": [ + { + "parameter": "raw", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "_$$ND_FUNC$$_" + } + }, + { + "parameter": "raw", + "mutations": [ + "base64_decode" + ], + "match": { + "type": "contains", + "value": "_$$ND_FUNC$$_" + } + }, + { + "parameter": "all", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "_$$ND_FUNC$$_" + } + } + ], + "why": "The payload must carry the `_$$ND_FUNC$$_` marker for unserialize to evaluate a function, which makes this the rare deserialization case with a fixed, high-signal anchor. The base64 mutation is there because the payload is commonly transported encoded. The `all` condition is what reaches a cookie-borne payload: `raw` is the request body only, and a deserialized session value is commonly a cookie.", + "falsePositiveRisk": "None plausible: the marker is an internal token of one library and does not occur in ordinary data." + }, + "template-option-injection": { + "title": "EJS server-side template injection via render-option keys in request data", + "category": "template-injection", + "phase": "request", + "rule_v2": [ + { + "parameter": "all", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "outputFunctionName" + } + }, + { + "parameter": "all", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "escapeFunction" + } + }, + { + "parameter": "all", + "mutations": [ + "urldecode" + ], + "match": { + "type": "contains", + "value": "localsName" + } + } + ], + "why": "The vector is request data flowing into ejs's render OPTIONS, where an option key is compiled into the generated function body. The option names are the anchor: they are ejs internals, not application field names, so their presence in request data is the attack rather than a heuristic for it.", + "falsePositiveRisk": "An app that legitimately accepts one of these names as a form field would be blocked. Implausible for the option names chosen, all of which are ejs-specific." + }, + "internal-destination-egress": { + "title": "SSRF reaching an internal address through a redirect", + "category": "ssrf", + "phase": "egress", + "rule_v2": [ + { + "parameter": "egress.host", + "match": { + "type": "internal_host" + } + } + ], + "why": "The request-side URL cannot be pinned without knowing the app's own field names, and a redirect changes the destination after any request-side check anyway. The outbound call is the only chokepoint, so the destination is what gets screened — which also covers the redirect target, since the runtime re-screens redirects.", + "falsePositiveRisk": "An app that legitimately calls an internal service is blocked. Deploy dry-run first and use allowHosts for the destinations a deployment genuinely needs." + } + } +} diff --git a/tests/protect/rule-shapes.test.ts b/tests/protect/rule-shapes.test.ts new file mode 100644 index 0000000..18a7772 --- /dev/null +++ b/tests/protect/rule-shapes.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createProtection } from '../../src/protect/runtime.js'; + +// The authored rule SHAPES, exercised through the real guard. +// +// `rule-corpus.test.ts` proves the engine can express one canonical rule per vulnerability class. This is a +// different claim about a different artifact: these are the shapes as authored for production, in full — the +// AND-ed gadget spellings, the mutations, the extra carriers — and the question here is whether the engine +// executes them as written. +// +// That question has a specific failure mode behind it. A rule the engine silently rejects, or one whose +// match never fires, is indistinguishable from protection: it is present in the bundle, it appears in the +// dashboard, and it blocks nothing. Four rules in this set were inert at some point for exactly that kind of +// reason — an inline-flag regex, a phase left at the default, a parameter source that does not exist. So +// every shape is run against a real payload, and every payload is paired with a benign request, because a +// rule that blocks the attack and also blocks ordinary traffic has mitigated nothing either. +// +// The fixture is payload shapes only. Which advisories a shape covers, the affected version ranges, and the +// authoring notes stay out of this repository: that mapping is the product, and it is maintained where the +// advisories are triaged. + +const FIXTURE = join(import.meta.dirname, 'fixtures', 'rule-shapes.json'); +const { shapes } = JSON.parse(readFileSync(FIXTURE, 'utf8')) as { + shapes: Record; +}; + +const shape = (id: string) => { + const found = shapes[id]; + expect(found, `${id} must be in the shape fixture`).toBeDefined(); + return found; +}; + +/** A guard carrying ONE shape, so nothing can pass or fail because of a sibling rule. */ +const guardFor = async (id: string, opts: Record = {}) => { + const s = shape(id); + const entry = { id: `shape:${id}`, title: s.title, category: s.category, rule_v2: s.rule_v2, ...(s.phase ? { phase: s.phase } : {}) }; + const p: any = await createProtection({ rules: { firewall: [entry] }, mode: 'block', ...opts }); + return p.fetchGuard(); +}; + +/** + * Send a body VERBATIM. Necessary for the gadget cases: `{ __proto__: {…} }` written as a JavaScript object + * literal sets the prototype instead of creating a key, so `JSON.stringify` emits `{}` — a test built that + * way sends no gadget at all while appearing to. + */ +const raw = (body: string, path = '/api/save', type = 'application/json') => + new Request(`https://app.example.com${path}`, { method: 'POST', headers: { 'content-type': type }, body }); +const post = (body: unknown, path = '/api/save') => raw(JSON.stringify(body), path); +const get = (query: string, headers: Record = {}) => + new Request(`https://app.example.com/api/items?${query}`, { method: 'GET', headers }); + +describe('the gadget must appear literally in the request', () => { + const ID = 'prototype-pollution-gadget'; + + it('blocks the literal gadget in a JSON body', async () => { + const guard = await guardFor(ID); + const body = '{"settings":{"__proto__":{"polluted":true}}}'; + expect(body, 'the gadget must be on the wire, not swallowed by an object literal').toContain('__proto__'); + + const res = await guard(raw(body)); + + expect(res, 'a body carrying the gadget must not reach the vulnerable merge').not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('blocks the gadget when the key is nested inside an array', async () => { + const guard = await guardFor(ID); + + expect(await guard(post({ patches: [{ path: 'a' }, { path: '__proto__.isAdmin' }] }))).not.toBeNull(); + }); + + it('blocks the constructor/prototype spelling, which needs no __proto__ at all', async () => { + const guard = await guardFor(ID); + + const res = await guard(post({ key: 'constructor', sub: 'prototype', value: 1 })); + + expect(res, 'the second spelling is the one a __proto__-only rule misses').not.toBeNull(); + }); + + it('blocks the gadget arriving in the query string', async () => { + // Not every consumer of a polluted merge reads a JSON body — argv and query-string parsers take the + // same gadget through a different carrier. + const guard = await guardFor(ID); + + expect(await guard(get('__proto__[isAdmin]=1'))).not.toBeNull(); + }); + + it('blocks a percent-encoded gadget', async () => { + const guard = await guardFor(ID); + + const res = await guard(get('%5f%5fproto%5f%5f[x]=1')); + + expect(res, 'the urldecode mutation must see through the encoding').not.toBeNull(); + }); + + it('passes an ordinary settings update', async () => { + const guard = await guardFor(ID); + + expect(await guard(post({ settings: { theme: 'dark', locale: 'en-GB', notify: true } }))).toBeNull(); + }); + + it('passes a body that mentions only one half of the constructor spelling', async () => { + // `constructor` alone is an ordinary English word that appears in real content. The rule ANDs the two + // halves precisely so this does not become a false positive. + const guard = await guardFor(ID); + + expect(await guard(post({ bio: 'I am the constructor of small wooden boats.' }))).toBeNull(); + }); + + it('passes a query string with ordinary bracket syntax', async () => { + const guard = await guardFor(ID); + + expect(await guard(get('filter[status]=open&sort[created]=desc'))).toBeNull(); + }); +}); + +describe('an internal-only header is the whole exploit', () => { + const ID = 'internal-subrequest-header'; + + it('blocks a request carrying the header', async () => { + const guard = await guardFor(ID); + + const res = await guard(get('page=1', { 'x-middleware-subrequest': 'middleware' })); + + expect(res, 'presence of the header IS the bypass').not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('blocks it whatever the value, including empty', async () => { + // The reason presence is matched rather than a value pattern: an empty header is as effective as a + // crafted one, and a value regex would pass it while looking specific. + for (const value of ['', 'src/middleware', 'middleware:middleware:middleware', 'x']) { + const guard = await guardFor(ID); + + const res = await guard(get('page=1', { 'x-middleware-subrequest': value })); + + expect(res, `value ${JSON.stringify(value)} must not slip through`).not.toBeNull(); + } + }); + + it('passes an identical request without the header', async () => { + const guard = await guardFor(ID); + + expect(await guard(get('page=1'))).toBeNull(); + }); + + it('passes a request whose body merely mentions the header name', async () => { + // A support form or a docs page discussing the advisory must not be blocked: the rule reads the header, + // not the text of the request. + const guard = await guardFor(ID); + + expect(await guard(post({ message: 'is x-middleware-subrequest patched in our version?' }))).toBeNull(); + }); +}); + +describe('a fixed marker is what makes a payload executable', () => { + const ID = 'serialized-function-marker'; + const PAYLOAD = '{"rce":"_$$ND_FUNC$$_function(){require(\'child_process\').exec(\'id\')}()"}'; + + it('blocks the payload in a body', async () => { + const guard = await guardFor(ID); + + const res = await guard(raw(PAYLOAD, '/api/state')); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('blocks it when transported in a cookie', async () => { + const guard = await guardFor(ID); + + const res = await guard(get('x=1', { cookie: `profile=${encodeURIComponent(PAYLOAD)}` })); + + expect(res, 'a deserialized session value commonly arrives in a cookie').not.toBeNull(); + }); + + it('blocks a base64-transported payload', async () => { + const guard = await guardFor(ID); + const encoded = Buffer.from(PAYLOAD).toString('base64'); + expect(encoded.includes('_$$ND_FUNC$$_'), 'the marker must be hidden by the encoding').toBe(false); + + const res = await guard(raw(encoded, '/api/state', 'text/plain')); + + expect(res, 'the base64_decode mutation must see the marker').not.toBeNull(); + }); + + it('passes an ordinary serialized object', async () => { + const guard = await guardFor(ID); + + expect(await guard(post({ user: { id: 7, name: 'Ada' }, ts: 1755000000 }))).toBeNull(); + }); + + it('passes a body containing plain JavaScript function source', async () => { + // A code-sharing app posting `function(){}` is not this attack; the marker is what makes it one. + const guard = await guardFor(ID); + + expect(await guard(post({ snippet: 'function(){ return 1 }' }))).toBeNull(); + }); +}); + +describe('template option names in request data are the attack, not a heuristic', () => { + const ID = 'template-option-injection'; + + it('blocks the documented option vector', async () => { + const guard = await guardFor(ID); + + const res = await guard(get('settings[view options][outputFunctionName]=x;process.mainModule.require(\'child_process\').execSync(\'id\');s')); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('blocks the sibling option keys that reach the same compile', async () => { + for (const key of ['escapeFunction', 'localsName']) { + const guard = await guardFor(ID); + + expect(await guard(post({ options: { [key]: 'payload' } })), `${key} is the same injection`).not.toBeNull(); + } + }); + + it('passes an ordinary render request', async () => { + const guard = await guardFor(ID); + + expect(await guard(get('template=invoice&locale=en¤cy=EUR'))).toBeNull(); + }); + + it('passes a body with the app’s own option-shaped fields', async () => { + const guard = await guardFor(ID); + + const res = await guard(post({ options: { format: 'pdf', pageSize: 'A4', outputName: 'invoice.pdf' } })); + + expect(res, 'outputName is not outputFunctionName — no prefix collision').toBeNull(); + }); +}); + +describe('when the destination is the only chokepoint', () => { + const ID = 'internal-destination-egress'; + + /** Install the egress guard with only this shape, exercise it, then restore fetch. */ + const withEgress = async (opts: Record, fn: (f: typeof fetch) => Promise) => { + const s = shape(ID); + const entry = { id: `shape:${ID}`, title: s.title, category: s.category, phase: s.phase, rule_v2: s.rule_v2 }; + const original = globalThis.fetch; + globalThis.fetch = (async () => new Response('stub')) as any; + const p: any = await createProtection({ rules: { firewall: [entry] }, mode: 'block', egress: true, ...opts }); + try { + await fn(globalThis.fetch); + } finally { + p.uninstallEgress?.(); + globalThis.fetch = original; + } + }; + const blocked = async (f: typeof fetch, url: string) => { + try { + await f(url); + return false; + } catch (e) { + return /Patchstack blocked/.test(String(e)); + } + }; + + it('is authored as an egress rule, not a request rule', () => { + // The redirect is the point: a request-phase check on the app's own URL field runs before the redirect + // exists, so only the outbound call can see the final destination. + expect(shape(ID).phase).toBe('egress'); + }); + + it('fires under its own id once the built-in default is suppressed', async () => { + // Worth pinning on its own. In ordinary operation the BUILT-IN internal-address egress rule matches + // first, so this shape contributes attribution rather than protection — and if it were inert, the + // blocking assertions below would be the default's work while appearing to prove this rule. + const hits: string[] = []; + + await withEgress({ allowHosts: [], egressRules: [], onDetect: (d: any) => hits.push(d.rule?.id) }, + async (f) => { expect(await blocked(f, 'http://169.254.169.254/')).toBe(true); }); + + expect(hits).toContain(`shape:${ID}`); + }); + + it('blocks an outbound call to the link-local metadata address', async () => { + await withEgress({ allowHosts: [] }, async (f) => { + expect(await blocked(f, 'http://169.254.169.254/latest/meta-data/')).toBe(true); + }); + }); + + it('blocks loopback and private-range destinations', async () => { + await withEgress({ allowHosts: [] }, async (f) => { + for (const url of ['http://127.0.0.1:6379/', 'http://10.0.0.5/admin', 'http://192.168.1.1/']) { + expect(await blocked(f, url), url).toBe(true); + } + }); + }); + + it('allows an ordinary third-party API call', async () => { + await withEgress({ allowHosts: [] }, async (f) => { + expect(await blocked(f, 'https://api.stripe.example/v1/charges')).toBe(false); + }); + }); + + it('allows an internal destination the deployment declared', async () => { + // The false-positive answer for an app that genuinely calls an internal service. + await withEgress({ allowHosts: ['internal.svc.test'] }, async (f) => { + expect(await blocked(f, 'http://internal.svc.test/health')).toBe(false); + }); + }); +}); + +describe('every shape is in a form the engine executes, and reviewable', () => { + const walk = (rules: any[], visit: (r: any) => void) => { + for (const r of rules) { + visit(r); + if (Array.isArray(r.rules)) walk(r.rules, visit); + } + }; + const allRules = () => Object.values(shapes).flatMap((s) => s.rule_v2); + + it('uses only /pattern/flags regexes — the inline-flag form is rejected and silently inert', () => { + walk(allRules(), (r) => { + if (r.match?.type !== 'regex') return; + expect(r.match.value, `regex must be delimited: ${r.match.value}`).toMatch(/^\/.*\/[a-z]*$/s); + expect(r.match.value, 'PCRE inline flags are not supported').not.toMatch(/^\/?\(\?[a-z]+\)/); + }); + }); + + it('names only parameter sources the engine understands', () => { + const known = /^(raw|all|get|post|cookie|files|server|response|egress|rules)(\.|$)/; + + walk(allRules(), (r) => { + expect(r.parameter, `unknown source: ${r.parameter}`).toMatch(known); + }); + }); + + it('declares a phase whenever it screens something other than the request', () => { + // The default is `request`. A shape that reads `egress.*` while defaulting to the request phase never + // runs — and reports as shipped protection. + for (const [id, s] of Object.entries(shapes)) { + if (JSON.stringify(s.rule_v2).includes('"egress.')) { + expect(s.phase, `${id} screens egress but does not declare the phase`).toBe('egress'); + } + } + }); + + it('carries a stated reason and a false-positive assessment', () => { + // A shape with no false-positive analysis is not reviewable: the benign cases above are only meaningful + // against a claim about what the rule is expected to let through. + for (const [id, s] of Object.entries(shapes)) { + expect(s.why.length, `${id} must say why the shape is the anchor`).toBeGreaterThan(80); + expect(s.falsePositiveRisk.length, `${id} must assess its false positives`).toBeGreaterThan(20); + } + }); + + it('carries no advisory identifiers, version ranges, or coverage mapping', () => { + // The boundary, asserted rather than trusted to review: this repository is public, and which advisories + // are covered — with which affected ranges — is not a payload shape. + const text = readFileSync(FIXTURE, 'utf8'); + + expect(text).not.toMatch(/CVE-\d{4}-\d+/); + expect(text).not.toMatch(/GHSA-[0-9a-z-]+/i); + expect(text).not.toMatch(/affected[_-]?range/i); + expect(text).not.toMatch(/"(introduced|fixed|last_affected)"/); + }); +});