From 8aad07f6aede14c27a2bac388b2805b41ef68cfe Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sat, 17 Oct 2026 15:52:29 -0700 Subject: [PATCH] fix(biometrics): keep the proxy off Health export uploads so Next stops cutting bodies off at 10 MB --- app/api/biometrics/watch-upload/route.test.ts | 21 ++++++++++++++++ app/api/biometrics/watch-upload/route.ts | 10 +++++++- proxy.ts | 24 ++++--------------- src/lib/hostAllowlist.ts | 16 +++++++++++++ tests/proxy.test.ts | 14 +++++++---- 5 files changed, 60 insertions(+), 25 deletions(-) diff --git a/app/api/biometrics/watch-upload/route.test.ts b/app/api/biometrics/watch-upload/route.test.ts index 888b8b88..ea8cb733 100644 --- a/app/api/biometrics/watch-upload/route.test.ts +++ b/app/api/biometrics/watch-upload/route.test.ts @@ -93,6 +93,27 @@ it('accepts a same-origin browser upload even though Next reports the 0.0.0.0 bi expect(await readdir(directory)).toEqual([]) }) +it('rejects a DNS-rebinding Host itself, since proxy.ts skips this route', async () => { + const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { + method: 'POST', headers: { origin: 'http://attacker.example.com:3000', host: 'attacker.example.com:3000' }, body: '', + })) + expect(response.status).toBe(403) + expect(await readdir(directory)).toEqual([]) +}) + +it('reports a body cut short of its Content-Length instead of a generic import failure', async () => { + const before = requests.length + const zip = new Uint8Array(4096).fill(0x41) + zip.set([0x50, 0x4B, 0x03, 0x04]) + const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { + method: 'POST', headers: { 'content-length': String(zip.byteLength * 2) }, body: zip, + })) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Upload incomplete: the pod received 4096 of 8192 bytes. Nothing was imported; retry the upload.') + expect(requests.length).toBe(before) + expect(await readdir(directory)).toEqual([]) +}) + it('rejects cross-origin requests, missing side, and oversized bodies before import', async () => { for (const origin of ['http://evil', 'http://192.168.1.88:8080', 'null']) { const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { origin, host: '192.168.1.88:3000' }, body: 'x' })) diff --git a/app/api/biometrics/watch-upload/route.ts b/app/api/biometrics/watch-upload/route.ts index c5658c0d..d241eba0 100644 --- a/app/api/biometrics/watch-upload/route.ts +++ b/app/api/biometrics/watch-upload/route.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process' import { mkdir, mkdtemp, open, rm } from 'node:fs/promises' import path from 'node:path' import { z } from 'zod' +import { rejectUnknownHost } from '@/src/lib/hostAllowlist' import { WATCH_IMPORT_TIMEOUT_MS, WATCH_UPLOAD_TIMEOUT_MS } from '@/src/utils/watchUploadLimits' export const runtime = 'nodejs' @@ -28,11 +29,15 @@ function isSameOrigin(request: Request): boolean { /** Raw export.zip/export.xml body, streamed to flash. Uses the same importer as the CLI. */ export async function POST(request: Request): Promise { + // proxy.ts skips this route so Next streams the body instead of truncating it at 10 MB. + const rejectedHost = rejectUnknownHost(request) + if (rejectedHost) return rejectedHost if (!isSameOrigin(request)) return Response.json({ error: 'Cross-origin upload is not allowed' }, { status: 403 }) const parsed = options.safeParse(Object.fromEntries(new URL(request.url).searchParams)) if (!parsed.success) return Response.json({ error: 'Specify side=left|right and days=1..90' }, { status: 400 }) if (!request.body) return Response.json({ error: 'Send export.zip or export.xml as the request body' }, { status: 400 }) - if (Number(request.headers.get('content-length')) > MAX_BYTES) return Response.json({ error: 'Upload exceeds 512 MiB' }, { status: 413 }) + const declaredBytes = Number(request.headers.get('content-length')) + if (declaredBytes > MAX_BYTES) return Response.json({ error: 'Upload exceeds 512 MiB' }, { status: 413 }) if (shared.watchUploadBusy) return Response.json({ error: 'Another Watch import is running; retry when it finishes' }, { status: 409 }) shared.watchUploadBusy = true let signal = AbortSignal.any([request.signal, AbortSignal.timeout(WATCH_UPLOAD_TIMEOUT_MS)]) @@ -62,6 +67,9 @@ export async function POST(request: Request): Promise { await file.close() } if (bytes === 0) return Response.json({ error: 'The uploaded file is empty' }, { status: 400 }) + if (declaredBytes > 0 && bytes !== declaredBytes) { + return Response.json({ error: `Upload incomplete: the pod received ${bytes} of ${declaredBytes} bytes. Nothing was imported; retry the upload.` }, { status: 400 }) + } signal = AbortSignal.any([request.signal, AbortSignal.timeout(WATCH_IMPORT_TIMEOUT_MS)]) const { side, days } = parsed.data const stdout = await new Promise((resolve, reject) => { diff --git a/proxy.ts b/proxy.ts index 0ea24cfe..69aa40cc 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,7 @@ import linguiConfig from 'lingui.config' import Negotiator from 'negotiator' import { type NextRequest, NextResponse } from 'next/server' -import { ALLOWED_HOSTS_ENV, isHostAllowed, parseAllowedHosts } from '@/src/lib/hostAllowlist' +import { rejectUnknownHost } from '@/src/lib/hostAllowlist' const { locales } = linguiConfig @@ -23,22 +23,6 @@ const getRequestLocale = (requestHeaders: Headers): string => { return activeLocale } -// DNS-rebinding guard (see src/lib/hostAllowlist.ts). A request without a -// Host header passes: Node rejects HTTP/1.1 requests that lack one, and the -// browsers that carry a rebinding attack always send it, so only HTTP/1.0 -// tools get here without one. The Vercel demo is served from a public domain -// with no pod behind it, so it skips the check. -const rejectUnknownHost = (request: NextRequest): NextResponse | undefined => { - if (process.env.NEXT_PUBLIC_DEMO === '1') return - const host = request.headers.get('host') - if (host === null) return - if (isHostAllowed(host, parseAllowedHosts(process.env[ALLOWED_HOSTS_ENV]))) return - return new NextResponse( - `Host not allowed. Use the pod's IP address, or add this host to ${ALLOWED_HOSTS_ENV}.\n`, - { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } }, - ) -} - export const proxy = (request: NextRequest) => { const rejected = rejectUnknownHost(request) if (rejected) { @@ -71,10 +55,12 @@ export const proxy = (request: NextRequest) => { } // Every API route and page runs the Host check. Build output and images are -// left out: they carry no pod data. +// left out: they carry no pod data. Next buffers a matched request's body and +// silently truncates it at 10 MB (proxyClientMaxBodySize), so the Health +// export upload is left out too and runs the Host check itself. export const config = { matcher: [ - '/api/:path*', + '/api/((?!biometrics/watch-upload(?:/|$)).*)', '/((?!api|_vercel|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } diff --git a/src/lib/hostAllowlist.ts b/src/lib/hostAllowlist.ts index 947c58e4..3f148819 100644 --- a/src/lib/hostAllowlist.ts +++ b/src/lib/hostAllowlist.ts @@ -55,3 +55,19 @@ export function isHostAllowed(host: string, extraHosts: readonly string[] = []): return extraHosts.some(entry => matchesEntry(normalized, hostname, entry)) } + +// DNS-rebinding guard for proxy.ts and routes it skips. A request without a +// Host header passes: Node rejects HTTP/1.1 requests that lack one, and the +// browsers that carry a rebinding attack always send it, so only HTTP/1.0 +// tools get here without one. The Vercel demo is served from a public domain +// with no pod behind it, so it skips the check. +export function rejectUnknownHost(request: Request): Response | undefined { + if (process.env.NEXT_PUBLIC_DEMO === '1') return + const host = request.headers.get('host') + if (host === null) return + if (isHostAllowed(host, parseAllowedHosts(process.env[ALLOWED_HOSTS_ENV]))) return + return new Response( + `Host not allowed. Use the pod's IP address, or add this host to ${ALLOWED_HOSTS_ENV}.\n`, + { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } }, + ) +} diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 21f4f3ec..8e14f081 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -1,4 +1,5 @@ // @vitest-environment node +import { tryToParsePath } from 'next/dist/lib/try-to-parse-path' import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { config, proxy } from '../proxy' @@ -63,15 +64,14 @@ describe('proxy host check', () => { }) describe('proxy matcher', () => { - const matches = (path: string) => config.matcher.some((pattern) => { - if (pattern === '/api/:path*') return path === '/api' || path.startsWith('/api/') - return new RegExp(`^${pattern}$`).test(path) - }) + // Compile each source with the path-to-regexp parser next build uses, so the test sees what the server sees. + const compiled = config.matcher.map(source => new RegExp(tryToParsePath(source).regexStr ?? '$^')) + const matches = (path: string) => compiled.some(regexp => regexp.test(path)) it.each([ '/api/trpc/device.getStatus', '/api/mcp', - '/api/biometrics/watch-upload', + '/api/biometrics/reference-stages', '/api/raw/SEQNO.RAW', '/api/export/archive', '/api/db-backup', @@ -85,7 +85,11 @@ describe('proxy matcher', () => { expect(matches(path)).toBe(true) }) + // A matched request's body is buffered and cut off at 10 MB before the route + // reads it, which broke Health export uploads (the route checks Host itself). it.each([ + '/api/biometrics/watch-upload', + '/api/biometrics/watch-upload/', '/_next/static/chunks/main.js', '/_next/image', '/favicon.ico',