|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | +import { createProtection } from '../../src/protect/runtime.js'; |
| 3 | + |
| 4 | +// File-upload content inspection: the engine exposes an upload's DATA |
| 5 | +// (files.<name>.content / .type / .filename); WHAT is malicious is expressed entirely in rules. |
| 6 | +// These tests show the rule-composed patterns (content signature + declared-type-vs-content |
| 7 | +// mismatch) and that bare files.<name> still returns the filename for existing filename rules. |
| 8 | + |
| 9 | +const B = '----PSXBOUNDARY'; |
| 10 | +function upload(field: string, filename: string, type: string, content: string) { |
| 11 | + const body = |
| 12 | + `--${B}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\n` + |
| 13 | + `Content-Type: ${type}\r\n\r\n${content}\r\n--${B}--\r\n`; |
| 14 | + return new Request('https://app.com/upload', { |
| 15 | + method: 'POST', |
| 16 | + headers: { 'content-type': `multipart/form-data; boundary=${B}` }, |
| 17 | + body, |
| 18 | + }); |
| 19 | +} |
| 20 | +const mk = (rules: any[]) => createProtection({ mode: 'block', rules: { firewall: rules, whitelists: [], whitelist_keys: {} } as any }); |
| 21 | +const blocks = async (p: any, req: Request) => (await p.fetch(() => new Response('ok'))(req)).status === 403; |
| 22 | + |
| 23 | +describe('files.<name>.content — signature inspection (pure rule)', () => { |
| 24 | + it('matches a webshell / ImageMagick-MSL signature in the file bytes', async () => { |
| 25 | + const p = await mk([ |
| 26 | + { id: 's', rule_v2: [{ parameter: 'files.f.content', match: { type: 'regex', value: '/<\\?php|<\\?=|<(?:read|write|msl)[\\s>]/i' } }] }, |
| 27 | + ]); |
| 28 | + expect(await blocks(p, upload('f', 'cat.png', 'image/png', '\x89PNG\r\n<?php system($_GET[0]); ?>'))).toBe(true); |
| 29 | + expect(await blocks(p, upload('f', 'x.jpg', 'image/jpeg', '<?xml version="1.0"?><image><read filename="/etc/passwd"/></image>'))).toBe(true); |
| 30 | + expect(await blocks(p, upload('f', 'cat.png', 'image/png', '\x89PNG a normal image'))).toBe(false); |
| 31 | + }); |
| 32 | +}); |
| 33 | + |
| 34 | +describe('type-vs-content mismatch — composed in a rule, not the engine', () => { |
| 35 | + // "declared image AND content head is markup" — two inclusive (AND) conditions on the exposed data. |
| 36 | + const mismatchRule = { |
| 37 | + id: 'm', |
| 38 | + rule_v2: [ |
| 39 | + { parameter: 'rules', rules: [ |
| 40 | + { parameter: 'files.f.type', mutations: [], match: { type: 'regex', value: '/^image\\//i' }, inclusive: true }, |
| 41 | + { parameter: 'files.f.content', match: { type: 'regex', value: '/^\\s*<[a-z!?]/i' }, inclusive: true }, |
| 42 | + ] }, |
| 43 | + ], |
| 44 | + }; |
| 45 | + |
| 46 | + it('flags a raster image that is really text/markup (svg-as-png, php-as-png)', async () => { |
| 47 | + const p = await mk([mismatchRule]); |
| 48 | + expect(await blocks(p, upload('f', 'cat.png', 'image/png', '<?php echo 1; ?>'))).toBe(true); |
| 49 | + expect(await blocks(p, upload('f', 'x.png', 'image/png', '<svg><script>alert(1)</script></svg>'))).toBe(true); |
| 50 | + }); |
| 51 | + |
| 52 | + it('does NOT flag a real image (binary head, markup only in metadata) — no false positive', async () => { |
| 53 | + const p = await mk([mismatchRule]); |
| 54 | + // Genuine binary image head; <script> only later in an EXIF/XMP-like text field. |
| 55 | + 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); |
| 56 | + expect(await blocks(p, upload('f', 'ok.png', 'image/png', '\x89PNG normal image bytes'))).toBe(false); |
| 57 | + }); |
| 58 | +}); |
| 59 | + |
| 60 | +describe('backward compatibility', () => { |
| 61 | + it('bare files.<name> still matches the filename', async () => { |
| 62 | + const p = await mk([{ id: 'ext', rule_v2: [{ parameter: 'files.avatar', match: { type: 'contains', value: '.php' } }] }]); |
| 63 | + expect(await blocks(p, upload('avatar', 'shell.php', 'application/octet-stream', 'x'))).toBe(true); |
| 64 | + expect(await blocks(p, upload('avatar', 'ok.png', 'image/png', 'x'))).toBe(false); |
| 65 | + }); |
| 66 | + it('exposes .type and .filename as sources', async () => { |
| 67 | + const p = await mk([{ id: 't', rule_v2: [{ parameter: 'files.doc.type', match: { type: 'contains', value: 'application/x-httpd-php' } }] }]); |
| 68 | + expect(await blocks(p, upload('doc', 'a.txt', 'application/x-httpd-php', 'x'))).toBe(true); |
| 69 | + }); |
| 70 | +}); |
0 commit comments