Skip to content

Commit ad3c247

Browse files
feat(upload): validate image content by magic bytes (#55)
## Summary Add magic-byte validation to the upload endpoint to verify that uploaded files actually match their declared `Content-Type`. Supports PNG, JPEG, GIF, WebP, and BMP formats. ## Changes - **`src/routes.js`**: Added `IMAGE_SIGNATURES` array and `validateMagicBytes()` function. Validation runs after buffer assembly and before file write. Mismatched magic bytes return 400. WebP validation checks both RIFF header (offset 0) and WEBP signature (offset 8). - **`test/routes.test.js`**: Updated existing PNG test to use real magic bytes. Added 7 new tests covering: valid JPEG/GIF/WebP/BMP uploads, fake data rejection, content-type mismatch (JPEG as PNG), and WebP missing RIFF header. ## Acceptance Criteria - [x] Validate first bytes for PNG, JPEG, GIF, WebP, BMP - [x] Reject mismatches with 400 and clear error message - [x] Keep existing 10MB limit - [x] Tests for spoofed content-type Closes #42 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9ee8d7c commit ad3c247

2 files changed

Lines changed: 160 additions & 1 deletion

File tree

src/routes.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ const log = require('./logger');
99
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
1010
const uploadedFiles = new Map(); // id -> filepath
1111

12+
const IMAGE_SIGNATURES = [
13+
{ type: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
14+
{ type: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
15+
{ type: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
16+
{ type: 'image/webp', offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
17+
{ type: 'image/bmp', bytes: [0x42, 0x4d] },
18+
];
19+
20+
function validateMagicBytes(buffer, contentType) {
21+
const sig = IMAGE_SIGNATURES.find((s) => s.type === contentType);
22+
if (!sig) return true; // unknown type, skip validation
23+
const offset = sig.offset || 0;
24+
if (buffer.length < offset + sig.bytes.length) return false;
25+
const match = sig.bytes.every((b, i) => buffer[offset + i] === b);
26+
if (!match) return false;
27+
// WebP requires RIFF header at offset 0
28+
if (contentType === 'image/webp') {
29+
const riff = [0x52, 0x49, 0x46, 0x46];
30+
if (buffer.length < 4) return false;
31+
return riff.every((b, i) => buffer[i] === b);
32+
}
33+
return true;
34+
}
35+
1236
function setupRoutes(app, { auth, sessions, config, state }) {
1337
// Serve static files (manifest.json, sw.js, icons, etc.)
1438
app.use(express.static(PUBLIC_DIR, { index: false }));
@@ -204,6 +228,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
204228
if (!buffer.length) {
205229
return res.status(400).json({ error: 'No image data' });
206230
}
231+
if (!validateMagicBytes(buffer, contentType)) {
232+
log.warn(`Upload rejected: content-type "${contentType}" does not match file signature`);
233+
return res.status(400).json({ error: 'File content does not match declared image type' });
234+
}
207235
const ext =
208236
{
209237
'image/png': '.png',

test/routes.test.js

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('Routes', () => {
6060

6161
it('should accept valid image upload and return opaque id', async () => {
6262
inst = await startServer();
63-
const imageData = Buffer.from('fakepngdata');
63+
const imageData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]);
6464
const res = await httpRequest(
6565
{
6666
hostname: '127.0.0.1',
@@ -147,6 +147,137 @@ describe('Routes', () => {
147147
const body = JSON.parse(res.data);
148148
assert.strictEqual(body.error, 'No image data');
149149
});
150+
151+
it('should accept valid JPEG magic bytes', async () => {
152+
if (!inst) inst = await startServer();
153+
const imageData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
154+
const res = await httpRequest(
155+
{
156+
hostname: '127.0.0.1',
157+
port: inst.port,
158+
path: '/api/upload',
159+
method: 'POST',
160+
headers: { 'Content-Type': 'image/jpeg', 'Content-Length': imageData.length },
161+
},
162+
imageData,
163+
);
164+
assert.strictEqual(res.statusCode, 200);
165+
const body = JSON.parse(res.data);
166+
assert.ok(body.path, 'Response should contain a path');
167+
});
168+
169+
it('should accept valid GIF magic bytes', async () => {
170+
if (!inst) inst = await startServer();
171+
// GIF89a header
172+
const imageData = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00]);
173+
const res = await httpRequest(
174+
{
175+
hostname: '127.0.0.1',
176+
port: inst.port,
177+
path: '/api/upload',
178+
method: 'POST',
179+
headers: { 'Content-Type': 'image/gif', 'Content-Length': imageData.length },
180+
},
181+
imageData,
182+
);
183+
assert.strictEqual(res.statusCode, 200);
184+
});
185+
186+
it('should accept valid WebP magic bytes', async () => {
187+
if (!inst) inst = await startServer();
188+
// RIFF....WEBP header
189+
const imageData = Buffer.from([
190+
0x52, 0x49, 0x46, 0x46, // RIFF
191+
0x24, 0x00, 0x00, 0x00, // file size
192+
0x57, 0x45, 0x42, 0x50, // WEBP
193+
0x00, 0x00, 0x00, 0x00,
194+
]);
195+
const res = await httpRequest(
196+
{
197+
hostname: '127.0.0.1',
198+
port: inst.port,
199+
path: '/api/upload',
200+
method: 'POST',
201+
headers: { 'Content-Type': 'image/webp', 'Content-Length': imageData.length },
202+
},
203+
imageData,
204+
);
205+
assert.strictEqual(res.statusCode, 200);
206+
});
207+
208+
it('should accept valid BMP magic bytes', async () => {
209+
if (!inst) inst = await startServer();
210+
// BM header
211+
const imageData = Buffer.from([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
212+
const res = await httpRequest(
213+
{
214+
hostname: '127.0.0.1',
215+
port: inst.port,
216+
path: '/api/upload',
217+
method: 'POST',
218+
headers: { 'Content-Type': 'image/bmp', 'Content-Length': imageData.length },
219+
},
220+
imageData,
221+
);
222+
assert.strictEqual(res.statusCode, 200);
223+
});
224+
225+
it('should reject WebP data missing RIFF header', async () => {
226+
if (!inst) inst = await startServer();
227+
// Has WEBP at offset 8 but no RIFF at offset 0
228+
const imageData = Buffer.from([
229+
0x00, 0x00, 0x00, 0x00, // NOT RIFF
230+
0x24, 0x00, 0x00, 0x00,
231+
0x57, 0x45, 0x42, 0x50, // WEBP
232+
]);
233+
const res = await httpRequest(
234+
{
235+
hostname: '127.0.0.1',
236+
port: inst.port,
237+
path: '/api/upload',
238+
method: 'POST',
239+
headers: { 'Content-Type': 'image/webp', 'Content-Length': imageData.length },
240+
},
241+
imageData,
242+
);
243+
assert.strictEqual(res.statusCode, 400);
244+
});
245+
246+
it('should reject fake data with image/png content-type', async () => {
247+
if (!inst) inst = await startServer();
248+
const imageData = Buffer.from('this is not a png');
249+
const res = await httpRequest(
250+
{
251+
hostname: '127.0.0.1',
252+
port: inst.port,
253+
path: '/api/upload',
254+
method: 'POST',
255+
headers: { 'Content-Type': 'image/png', 'Content-Length': imageData.length },
256+
},
257+
imageData,
258+
);
259+
assert.strictEqual(res.statusCode, 400);
260+
const body = JSON.parse(res.data);
261+
assert.strictEqual(body.error, 'File content does not match declared image type');
262+
});
263+
264+
it('should reject JPEG data with image/png content-type', async () => {
265+
if (!inst) inst = await startServer();
266+
const imageData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
267+
const res = await httpRequest(
268+
{
269+
hostname: '127.0.0.1',
270+
port: inst.port,
271+
path: '/api/upload',
272+
method: 'POST',
273+
headers: { 'Content-Type': 'image/png', 'Content-Length': imageData.length },
274+
},
275+
imageData,
276+
);
277+
assert.strictEqual(res.statusCode, 400);
278+
const body = JSON.parse(res.data);
279+
assert.strictEqual(body.error, 'File content does not match declared image type');
280+
});
150281
});
151282

152283
// === Directory listing ===

0 commit comments

Comments
 (0)