diff --git a/src/protect/engine/fetch.js b/src/protect/engine/fetch.js index 33b06d4..af70470 100644 --- a/src/protect/engine/fetch.js +++ b/src/protect/engine/fetch.js @@ -165,7 +165,12 @@ export function parseMultipart(rawBody, boundary) { const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, ''); const filename = /filename="([^"]*)"/i.exec(disposition)?.[1]; if (filename !== undefined) { - files[name] = name in files ? [].concat(files[name], filename) : filename; + // Capture the part's declared content-type and CONTENT (not just the filename), so rules can + // inspect an upload's bytes (files..content) and detect a declared-vs-actual type + // mismatch (files..mismatch). The content rides inside the already-capped rawBody. + const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || ''; + const file = { filename, type: partType, content }; + files[name] = name in files ? [].concat(files[name], file) : file; } else { body[name] = name in body ? [].concat(body[name], content) : content; } diff --git a/src/protect/engine/request.js b/src/protect/engine/request.js index 5488270..fcb7e04 100644 --- a/src/protect/engine/request.js +++ b/src/protect/engine/request.js @@ -1,3 +1,12 @@ +// Resolvable DATA attributes of an uploaded file part (files..). The engine only exposes +// the raw data — WHAT counts as a malicious upload (signatures, type-vs-content mismatch) is expressed +// in rules (see the triage-vpatch-npm skill), not hardcoded here. +const FILE_ATTRS = new Set(['content', 'filename', 'type']); + +// A captured file part is { filename, type, content }; tolerate the legacy bare-filename string. +const fileFilename = (f) => (f && typeof f === 'object' ? f.filename : f); +const fileAttribute = (f, attr) => (f && typeof f === 'object' ? f[attr] : attr === 'filename' ? f : undefined); + // WinterCG-safe base64 decode: use Buffer on Node, fall back to atob/TextDecoder on // edge runtimes (Cloudflare Workers, Deno, Bun) where Buffer may be absent. Keeps the // engine hot path free of Node-only APIs (per the ADR engine-language decision). @@ -253,16 +262,38 @@ export class RequestResolver { #resolveFiles(key) { const files = this.#req.files; - if (!files) { + if (!files || typeof files !== 'object') { return []; } - if (key.endsWith('*')) { - return this.#resolveWildcard(files, key); + // files.. — content | filename | type. Fans out over multiple files uploaded under + // the same field name. + const dot = key.lastIndexOf('.'); + if (dot !== -1 && FILE_ATTRS.has(key.slice(dot + 1)) && Object.prototype.hasOwnProperty.call(files, key.slice(0, dot))) { + const attr = key.slice(dot + 1); + const entry = files[key.slice(0, dot)]; + const list = Array.isArray(entry) ? entry : [entry]; + const out = []; + for (const f of list) { + const v = fileAttribute(f, attr); + if (v !== undefined && v !== '') out.push(v); + } + return out; } - const value = files[key]; - return value !== undefined ? [value] : []; + // Bare files. (or wildcard) → the filename(s), preserving the legacy behavior that + // filename-scoped rules rely on (the parser now stores a { filename, type, content } object). + const filenamesOf = (entry) => (Array.isArray(entry) ? entry.map(fileFilename) : [fileFilename(entry)]); + if (key.endsWith('*')) { + const prefix = key.slice(0, -1); + const out = []; + for (const [k, entry] of Object.entries(files)) { + if (k.startsWith(prefix)) out.push(...filenamesOf(entry)); + } + return out.filter((v) => v !== undefined); + } + if (!Object.prototype.hasOwnProperty.call(files, key)) return []; + return filenamesOf(files[key]).filter((v) => v !== undefined); } #resolveRaw() { diff --git a/tests/protect/batch-hardening.test.ts b/tests/protect/batch-hardening.test.ts index 8ab63c7..0d8020f 100644 --- a/tests/protect/batch-hardening.test.ts +++ b/tests/protect/batch-hardening.test.ts @@ -86,7 +86,8 @@ describe('item 3 — multipart on the node adapter + comparator coercion', () => `--${b}\r\nContent-Disposition: form-data; name="avatar"; filename="x.png"\r\n\r\nBINARY\r\n--${b}--\r\n`; const shaped: any = fromNodeRequest({ method: 'POST', url: '/x', headers: { 'content-type': `multipart/form-data; boundary=${b}` } } as any, body); expect(shaped.body.comment).toBe(''); - 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;