From 044c98e6d2c4fbe8f38ce4bf288cb602b0049265 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:15:04 +0200 Subject: [PATCH] fix: cap the size of a synced settings blob --- app/api/settings/route.ts | 14 +++++++++++++- lib/__tests__/settings-route-account-key.test.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index a3ed0f676..8772f4fc3 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -12,6 +12,9 @@ import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { mergeStoredTemplates } from '@/lib/template-merge'; import { mergeStoredSubscriptions } from '@/lib/calendar-subscription-sync'; +/** Largest settings blob a client may store (templates and signatures included). */ +const SETTINGS_MAX_BYTES = 2 * 1024 * 1024; + function classifyError(error: unknown): { message: string; status: number } { const code = (error as NodeJS.ErrnoException).code; const msg = error instanceof Error ? error.message : 'Unknown error'; @@ -142,7 +145,16 @@ export async function POST(request: NextRequest) { } try { - const { username, serverUrl, settings } = await request.json(); + // One settings blob per account, read back on every sync: bound it + // before parsing, the way the account archive is. + if (Number(request.headers.get('content-length') ?? 0) > SETTINGS_MAX_BYTES) { + return NextResponse.json({ error: 'Settings are too large' }, { status: 413 }); + } + const text = await request.text(); + if (text.length > SETTINGS_MAX_BYTES) { + return NextResponse.json({ error: 'Settings are too large' }, { status: 413 }); + } + const { username, serverUrl, settings } = JSON.parse(text); if (!username || !serverUrl || !settings) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } diff --git a/lib/__tests__/settings-route-account-key.test.ts b/lib/__tests__/settings-route-account-key.test.ts index f73fe0aed..93ae44e28 100644 --- a/lib/__tests__/settings-route-account-key.test.ts +++ b/lib/__tests__/settings-route-account-key.test.ts @@ -128,4 +128,20 @@ describe('settings route keys on the account the credential belongs to', () => { })); expect([...store.keys()]).toEqual([`alice@example.org|${SERVER}`]); }); + + it('refuses a settings blob over the size cap before parsing it', async () => { + // One blob per account, re-read on every sync: it was accepted at any size. + await signIn({ username: 'alice@example.org' }); + const { POST } = await import('@/app/api/settings/route'); + const big = JSON.stringify({ username: 'alice@example.org', serverUrl: SERVER, settings: { signature: 'x'.repeat(2 * 1024 * 1024 + 10) } }); + const res = await POST(new NextRequest('https://webmail.example/api/settings', { method: 'POST', headers: SAME_ORIGIN, body: big })); + expect(res.status).toBe(413); + expect(store.size).toBe(0); + const claimed = await POST(new NextRequest('https://webmail.example/api/settings', { + method: 'POST', + headers: { ...SAME_ORIGIN, 'content-length': String(3 * 1024 * 1024) }, + body: JSON.stringify({ username: 'alice@example.org', serverUrl: SERVER, settings: { theme: 'dark' } }), + })); + expect(claimed.status).toBe(413); + }); });