pediatric-ai-scribe-v3/test/attachment-magic-bytes.test.js
Daniel bd8e413bc7
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: an assistant attachment must be the image type it claims to be
The MIME type was taken on trust here. Anything at all could be posted as
image/png: it passed the size and base64 checks, was stored in the saved chat,
and was handed to a provider as a data URI. Documents and S3 uploads have always
been sniffed by fileType.js; this was the one upload path that was not.

Now sniffed with the same helper, so there is one idea of what a PNG looks like.
A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF
labelled image/png are all refused with a message that says what is wrong.

What this does not claim: bytes hidden after a valid PNG header still make a
valid PNG, and no sniffer can promise otherwise. The protection is that the file
is never executed and never served as anything but an image.

Existing fixtures used buffers of 0x07 as stand-in images, which are correctly
refused now. They carry real file headers instead — a fixture should be the
thing it claims to be, exactly like a real upload.

Also adds the deck theme system: five palettes in assets/deck-themes.json,
render_pptx.py rebinding its palette from the theme rather than hardcoding it,
the theme carried on the deck and validated against the same catalogue the
renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme
to re-skin a stored deck with no model call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:00:10 +02:00

60 lines
3.1 KiB
JavaScript

// An attachment has to be the thing it says it is. The MIME type used to be
// taken on trust here, so anything could be posted as image/png: stored in the
// saved chat, and handed to a provider as a data URI. Documents and S3 uploads
// were always sniffed; this was the path that was not.
const test = require('node:test');
const assert = require('node:assert/strict');
const { validateAttachments } = require('../src/utils/clinicalConversation');
const b64 = buf => Buffer.from(buf).toString('base64');
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
const JPEG = Buffer.from('ffd8ffe000104a464946', 'hex');
const WEBP = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.alloc(4)]);
const attach = (mimeType, buf) => validateAttachments([{ mimeType, dataBase64: b64(buf) }]);
const refuses = (mimeType, buf, why) => assert.throws(
() => attach(mimeType, buf),
err => { assert.match(err.message, /not the image type it claims to be/); return true; }, why);
test('a genuine image of each accepted type passes', () => {
assert.equal(attach('image/png', PNG).length, 1);
assert.equal(attach('image/jpeg', JPEG).length, 1);
assert.equal(attach('image/webp', WEBP).length, 1);
});
test('executable and script payloads labelled as images are refused', () => {
refuses('image/png', '<?php system($_GET[0]); ?>', 'php');
refuses('image/png', '#!/bin/sh\nrm -rf /', 'shell script');
refuses('image/jpeg', '<script>fetch("//evil")</script>', 'html/js');
refuses('image/png', Buffer.from('4d5a90000300', 'hex'), 'a Windows executable');
refuses('image/png', Buffer.from('7f454c46', 'hex'), 'an ELF binary');
});
test('a real file of the wrong type is refused, not just junk', () => {
refuses('image/png', '%PDF-1.4 trailing', 'a PDF called a PNG');
refuses('image/png', Buffer.from('504b0304', 'hex'), 'a zip/docx called a PNG');
refuses('image/webp', PNG, 'a PNG called a WebP');
refuses('image/jpeg', PNG, 'a PNG called a JPEG');
});
test('a polyglot that merely starts with image bytes is still only that image', () => {
// A PNG header followed by script text sniffs as PNG and is accepted — which
// is correct: it IS a PNG. The protection is that it is never executed and
// never served as anything but an image, not that payloads cannot be hidden
// inside valid image bytes, which no sniffer can promise.
assert.equal(attach('image/png', Buffer.concat([PNG, Buffer.from('<?php ?>')])).length, 1);
});
test('the size and count limits still hold, and are checked before the bytes are read', () => {
assert.throws(() => validateAttachments(new Array(5).fill({ mimeType: 'image/png', dataBase64: b64(PNG) })),
/maximum of 4 images/);
const big = Buffer.concat([PNG, Buffer.alloc(5 * 1024 * 1024)]);
assert.throws(() => attach('image/png', big), /limited to 5 MiB/);
});
test('a type outside the allowlist never reaches the sniffer', () => {
assert.throws(() => attach('application/pdf', Buffer.from('%PDF-1.4')),
/Only PNG, JPEG and WebP/);
assert.throws(() => attach('image/svg+xml', Buffer.from('<svg onload="alert(1)"/>')),
/Only PNG, JPEG and WebP/);
});