Skip to content

Commit 6c4fd97

Browse files
author
IM.codes
committed
Harden legacy file upload validation
1 parent 05329c8 commit 6c4fd97

4 files changed

Lines changed: 195 additions & 1 deletion

File tree

server/src/routes/file-transfer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const fileTransferRoutes = new Hono<{ Bindings: Env; Variables: { userId:
1919
// URL more than once, so tokens are resource-bound and short-lived with a small
2020
// use budget instead of being consumed on the first GET.
2121
const DOWNLOAD_TOKEN_MAX_USES = 5;
22+
const MULTIPART_UPLOAD_OVERHEAD_BYTES = 1024 * 1024;
2223
const downloadTokens = new Map<string, {
2324
serverId: string;
2425
attachmentId: string;
@@ -68,6 +69,18 @@ fileTransferRoutes.post('/:id/upload', async (c) => {
6869
const role = await resolveServerRole(c.env.DB, serverId, userId);
6970
if (role === 'none') return c.json({ error: 'forbidden' }, 403);
7071

72+
const contentLengthHeader = c.req.header('content-length');
73+
const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : Number.NaN;
74+
if (
75+
Number.isFinite(contentLength)
76+
&& contentLength > FILE_TRANSFER_LIMITS.MAX_FILE_SIZE + MULTIPART_UPLOAD_OVERHEAD_BYTES
77+
) {
78+
return c.json({
79+
error: 'file_too_large',
80+
maxBytes: FILE_TRANSFER_LIMITS.MAX_FILE_SIZE,
81+
}, 413);
82+
}
83+
7184
// Parse multipart
7285
const formData = await c.req.formData().catch(() => null);
7386
if (!formData) return c.json({ error: 'invalid_body' }, 400);
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { Hono } from 'hono';
3+
import { FILE_TRANSFER_LIMITS } from '../../shared/transport/file-transfer.js';
4+
5+
const { sendFileTransferRequestMock, isDaemonConnectedMock } = vi.hoisted(() => ({
6+
sendFileTransferRequestMock: vi.fn(),
7+
isDaemonConnectedMock: vi.fn(),
8+
}));
9+
10+
vi.mock('../src/security/authorization.js', () => ({
11+
requireAuth: () => async (c: { req: { header: (name: string) => string | undefined }; set: (key: string, value: string) => void }, next: () => Promise<void>) => {
12+
if (!c.req.header('Authorization')) {
13+
return new Response(JSON.stringify({ error: 'unauthorized' }), {
14+
status: 401,
15+
headers: { 'Content-Type': 'application/json' },
16+
});
17+
}
18+
c.set('userId', 'user-1');
19+
return next();
20+
},
21+
resolveServerRole: vi.fn().mockResolvedValue('owner'),
22+
}));
23+
24+
vi.mock('../src/ws/bridge.js', () => ({
25+
WsBridge: {
26+
get: () => ({
27+
isDaemonConnected: isDaemonConnectedMock,
28+
sendFileTransferRequest: sendFileTransferRequestMock,
29+
}),
30+
},
31+
}));
32+
33+
vi.mock('../src/util/logger.js', () => ({
34+
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
35+
}));
36+
37+
vi.mock('../src/security/crypto.js', () => ({
38+
randomHex: (bytes: number) => 'a'.repeat(bytes * 2),
39+
}));
40+
41+
import { fileTransferRoutes } from '../src/routes/file-transfer.js';
42+
43+
function makeApp(): Hono {
44+
const app = new Hono();
45+
app.use('/*', async (c, next) => {
46+
(c as never as { env: { DB: unknown } }).env = { DB: {} };
47+
return next();
48+
});
49+
app.route('/api/server', fileTransferRoutes);
50+
return app;
51+
}
52+
53+
describe('file-transfer upload route', () => {
54+
beforeEach(() => {
55+
sendFileTransferRequestMock.mockReset();
56+
isDaemonConnectedMock.mockReset();
57+
isDaemonConnectedMock.mockReturnValue(true);
58+
sendFileTransferRequestMock.mockResolvedValue({
59+
type: 'file.upload_done',
60+
attachment: {
61+
id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.txt',
62+
source: 'upload',
63+
daemonPath: '/tmp/upload.txt',
64+
downloadable: true,
65+
},
66+
});
67+
});
68+
69+
it('rejects oversized legacy uploads from content-length before daemon relay', async () => {
70+
const res = await makeApp().request('/api/server/srv-1/upload', {
71+
method: 'POST',
72+
headers: {
73+
Authorization: 'Bearer test',
74+
'Content-Type': 'multipart/form-data; boundary=x',
75+
'Content-Length': String(FILE_TRANSFER_LIMITS.MAX_FILE_SIZE + 1024 * 1024 + 1),
76+
},
77+
body: '--x\r\n',
78+
});
79+
80+
expect(res.status).toBe(413);
81+
await expect(res.json()).resolves.toEqual({
82+
error: 'file_too_large',
83+
maxBytes: FILE_TRANSFER_LIMITS.MAX_FILE_SIZE,
84+
});
85+
expect(sendFileTransferRequestMock).not.toHaveBeenCalled();
86+
});
87+
88+
it('relays a valid legacy upload with base64 content', async () => {
89+
const form = new FormData();
90+
form.append('file', new File(['hello'], 'hello.txt', { type: 'text/plain' }));
91+
92+
const res = await makeApp().request('/api/server/srv-1/upload', {
93+
method: 'POST',
94+
headers: { Authorization: 'Bearer test' },
95+
body: form,
96+
});
97+
98+
expect(res.status).toBe(200);
99+
await expect(res.json()).resolves.toMatchObject({
100+
ok: true,
101+
attachment: {
102+
serverId: 'srv-1',
103+
daemonPath: '/tmp/upload.txt',
104+
},
105+
});
106+
expect(sendFileTransferRequestMock).toHaveBeenCalledWith(
107+
expect.any(String),
108+
expect.objectContaining({
109+
type: 'file.upload',
110+
originalName: 'hello.txt',
111+
mime: 'text/plain',
112+
size: 5,
113+
content: Buffer.from('hello').toString('base64'),
114+
}),
115+
FILE_TRANSFER_LIMITS.UPLOAD_TIMEOUT_MS,
116+
);
117+
});
118+
});

src/daemon/file-transfer-handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ export async function handleFileUpload(cmd: Record<string, unknown>, serverLink:
157157
}
158158

159159
const buffer = Buffer.from(content, 'base64');
160+
if (buffer.length > FILE_TRANSFER_LIMITS.MAX_FILE_SIZE) {
161+
throw new Error('file_too_large');
162+
}
163+
if (typeof msg.size === 'number' && buffer.length !== msg.size) {
164+
throw new Error('size_mismatch');
165+
}
160166
await writeFile(resolved, buffer);
161167

162168
// Write metadata sidecar for recovery after daemon restart

test/daemon/file-transfer-handler.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@ import { tmpdir } from 'node:os';
44
import path from 'node:path';
55
import { FILE_TRANSFER_LIMITS } from '../../shared/transport/file-transfer.js';
66

7-
async function loadFileTransferHandler(fakeHome: string) {
7+
async function loadFileTransferHandler(fakeHome: string, options?: { maxFileSize?: number }) {
88
vi.resetModules();
99
vi.doMock('node:os', async (importOriginal) => {
1010
const actual = await importOriginal<typeof import('node:os')>();
1111
return { ...actual, homedir: () => fakeHome };
1212
});
13+
if (options?.maxFileSize !== undefined) {
14+
vi.doMock('../../shared/transport/file-transfer.js', async (importOriginal) => {
15+
const actual = await importOriginal<typeof import('../../shared/transport/file-transfer.js')>();
16+
return {
17+
...actual,
18+
FILE_TRANSFER_LIMITS: {
19+
...actual.FILE_TRANSFER_LIMITS,
20+
MAX_FILE_SIZE: options.maxFileSize,
21+
},
22+
};
23+
});
24+
}
1325
vi.doMock('../../src/util/logger.js', () => ({
1426
default: {
1527
info: vi.fn(),
@@ -47,6 +59,7 @@ describe('file-transfer local handle hardening', () => {
4759
afterEach(async () => {
4860
vi.restoreAllMocks();
4961
vi.doUnmock('node:os');
62+
vi.doUnmock('../../shared/transport/file-transfer.js');
5063
vi.doUnmock('../../src/util/logger.js');
5164
vi.resetModules();
5265
await rm(rootDir, { recursive: true, force: true });
@@ -188,4 +201,48 @@ describe('file-transfer local handle hardening', () => {
188201
message: 'expired',
189202
});
190203
});
204+
205+
it('rejects legacy uploads over the active single-frame cap', async () => {
206+
const transfer = await loadFileTransferHandler(fakeHome, { maxFileSize: 4 });
207+
const failed = createServerLinkMock();
208+
209+
await transfer.handleFileUpload(
210+
{
211+
type: 'file.upload',
212+
uploadId: 'upload-too-large',
213+
filename: 'safe.txt',
214+
size: 5,
215+
content: Buffer.from('hello').toString('base64'),
216+
},
217+
failed.serverLink as never,
218+
);
219+
220+
expect(failed.sent[0]).toMatchObject({
221+
type: 'file.upload_error',
222+
uploadId: 'upload-too-large',
223+
message: 'file_too_large',
224+
});
225+
});
226+
227+
it('rejects legacy upload payloads whose decoded byte count does not match the declared size', async () => {
228+
const transfer = await loadFileTransferHandler(fakeHome);
229+
const failed = createServerLinkMock();
230+
231+
await transfer.handleFileUpload(
232+
{
233+
type: 'file.upload',
234+
uploadId: 'upload-size-mismatch',
235+
filename: 'safe.txt',
236+
size: 99,
237+
content: Buffer.from('hello').toString('base64'),
238+
},
239+
failed.serverLink as never,
240+
);
241+
242+
expect(failed.sent[0]).toMatchObject({
243+
type: 'file.upload_error',
244+
uploadId: 'upload-size-mismatch',
245+
message: 'size_mismatch',
246+
});
247+
});
191248
});

0 commit comments

Comments
 (0)