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',