From b65137c03894eab115896824667fb25808feb208 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 09:41:52 +0200 Subject: [PATCH] protect: expose uploaded-file data so rules can inspect content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine saw an upload's filename and the raw multipart blob, but not file content as a first-class thing (file_contains was a no-op), so a polyglot — a valid image with a malicious payload inside (the ImageMagick "PNG that isn't a PNG" RCE class) — passed a filename rule. Expose the DATA, keep the detection in rules. The multipart parser now captures each file part as { filename, type, content }, and the resolver exposes: - files..content the file bytes (contains/regex signature rules) - files..type the part's declared content-type - files..filename the filename (same as bare files.) Bare files. still returns the filename, so existing filename rules are unchanged. No detection heuristics live in the engine — "what's malicious" (webshell signatures, declared-type-vs-content mismatch) is composed in rules (see the triage-vpatch-npm upload template): e.g. mismatch = `files.f.type` matches ^image/ AND `files.f.content` head is markup (`^\s*<`), two inclusive conditions — which also avoids the false positive of '); - expect(shaped.files.avatar).toBe('x.png'); + // File parts are captured as { filename, type, content } for content inspection. + expect(shaped.files.avatar).toMatchObject({ filename: 'x.png', content: 'BINARY' }); }); it('in_array / array_in_array coerce numeric rule values to match string request values', () => { diff --git a/tests/protect/multipart.test.ts b/tests/protect/multipart.test.ts index ab507e3..de8dfd7 100644 --- a/tests/protect/multipart.test.ts +++ b/tests/protect/multipart.test.ts @@ -42,7 +42,8 @@ describe('multipart/form-data parsing', () => { ); expect(shaped.body.title).toBe(''); expect(shaped.body['__proto__[polluted]']).toBe('yes'); - expect(shaped.files.avatar).toBe('evil.svg'); + // File parts are now captured as { filename, type, content } (content inspection), not a bare filename. + expect(shaped.files.avatar).toMatchObject({ filename: 'evil.svg', type: 'image/svg+xml', content: '' }); expect(shaped._rawBody).toContain('__proto__'); }); diff --git a/tests/protect/upload-inspection.test.ts b/tests/protect/upload-inspection.test.ts new file mode 100644 index 0000000..85ee838 --- /dev/null +++ b/tests/protect/upload-inspection.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// File-upload content inspection: the engine exposes an upload's DATA +// (files..content / .type / .filename); WHAT is malicious is expressed entirely in rules. +// These tests show the rule-composed patterns (content signature + declared-type-vs-content +// mismatch) and that bare files. still returns the filename for existing filename rules. + +const B = '----PSXBOUNDARY'; +function upload(field: string, filename: string, type: string, content: string) { + const body = + `--${B}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\n` + + `Content-Type: ${type}\r\n\r\n${content}\r\n--${B}--\r\n`; + return new Request('https://app.com/upload', { + method: 'POST', + headers: { 'content-type': `multipart/form-data; boundary=${B}` }, + body, + }); +} +const mk = (rules: any[]) => createProtection({ mode: 'block', rules: { firewall: rules, whitelists: [], whitelist_keys: {} } as any }); +const blocks = async (p: any, req: Request) => (await p.fetch(() => new Response('ok'))(req)).status === 403; + +describe('files..content — signature inspection (pure rule)', () => { + it('matches a webshell / ImageMagick-MSL signature in the file bytes', async () => { + const p = await mk([ + { id: 's', rule_v2: [{ parameter: 'files.f.content', match: { type: 'regex', value: '/<\\?php|<\\?=|<(?:read|write|msl)[\\s>]/i' } }] }, + ]); + expect(await blocks(p, upload('f', 'cat.png', 'image/png', '\x89PNG\r\n'))).toBe(true); + expect(await blocks(p, upload('f', 'x.jpg', 'image/jpeg', ''))).toBe(true); + expect(await blocks(p, upload('f', 'cat.png', 'image/png', '\x89PNG a normal image'))).toBe(false); + }); +}); + +describe('type-vs-content mismatch — composed in a rule, not the engine', () => { + // "declared image AND content head is markup" — two inclusive (AND) conditions on the exposed data. + const mismatchRule = { + id: 'm', + rule_v2: [ + { parameter: 'rules', rules: [ + { parameter: 'files.f.type', mutations: [], match: { type: 'regex', value: '/^image\\//i' }, inclusive: true }, + { parameter: 'files.f.content', match: { type: 'regex', value: '/^\\s*<[a-z!?]/i' }, inclusive: true }, + ] }, + ], + }; + + it('flags a raster image that is really text/markup (svg-as-png, php-as-png)', async () => { + const p = await mk([mismatchRule]); + expect(await blocks(p, upload('f', 'cat.png', 'image/png', ''))).toBe(true); + expect(await blocks(p, upload('f', 'x.png', 'image/png', ''))).toBe(true); + }); + + it('does NOT flag a real image (binary head, markup only in metadata) — no false positive', async () => { + const p = await mk([mismatchRule]); + // Genuine binary image head;