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
7 changes: 6 additions & 1 deletion src/protect/engine/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.content) and detect a declared-vs-actual type
// mismatch (files.<name>.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;
}
Expand Down
41 changes: 36 additions & 5 deletions src/protect/engine/request.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
// Resolvable DATA attributes of an uploaded file part (files.<name>.<attr>). 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).
Expand Down Expand Up @@ -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.<name>.<attr> — 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.<name> (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() {
Expand Down
3 changes: 2 additions & 1 deletion tests/protect/batch-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script>alert(1)</script>');
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', () => {
Expand Down
3 changes: 2 additions & 1 deletion tests/protect/multipart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ describe('multipart/form-data parsing', () => {
);
expect(shaped.body.title).toBe('<script>alert(1)</script>');
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: '<svg onload=alert(1)>' });
expect(shaped._rawBody).toContain('__proto__');
});

Expand Down
70 changes: 70 additions & 0 deletions tests/protect/upload-inspection.test.ts
Original file line number Diff line number Diff line change
@@ -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.<name>.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.<name> 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.<name>.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<?php system($_GET[0]); ?>'))).toBe(true);
expect(await blocks(p, upload('f', 'x.jpg', 'image/jpeg', '<?xml version="1.0"?><image><read filename="/etc/passwd"/></image>'))).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', '<?php echo 1; ?>'))).toBe(true);
expect(await blocks(p, upload('f', 'x.png', 'image/png', '<svg><script>alert(1)</script></svg>'))).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; <script> only later in an EXIF/XMP-like text field.
expect(await blocks(p, upload('f', 'p.jpg', 'image/jpeg', '\xff\xd8\xff\xe1 EXIF <x:xmpmeta><dc:description><script>x</script></dc:description>'))).toBe(false);
expect(await blocks(p, upload('f', 'ok.png', 'image/png', '\x89PNG normal image bytes'))).toBe(false);
});
});

describe('backward compatibility', () => {
it('bare files.<name> still matches the filename', async () => {
const p = await mk([{ id: 'ext', rule_v2: [{ parameter: 'files.avatar', match: { type: 'contains', value: '.php' } }] }]);
expect(await blocks(p, upload('avatar', 'shell.php', 'application/octet-stream', 'x'))).toBe(true);
expect(await blocks(p, upload('avatar', 'ok.png', 'image/png', 'x'))).toBe(false);
});
it('exposes .type and .filename as sources', async () => {
const p = await mk([{ id: 't', rule_v2: [{ parameter: 'files.doc.type', match: { type: 'contains', value: 'application/x-httpd-php' } }] }]);
expect(await blocks(p, upload('doc', 'a.txt', 'application/x-httpd-php', 'x'))).toBe(true);
});
});
Loading