Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/api/biometrics/watch-upload/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<HealthData/>',
}))
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' }))
Expand Down
10 changes: 9 additions & 1 deletion app/api/biometrics/watch-upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<Response> {
// 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)])
Expand Down Expand Up @@ -62,6 +67,9 @@ export async function POST(request: Request): Promise<Response> {
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<string>((resolve, reject) => {
Expand Down
24 changes: 5 additions & 19 deletions proxy.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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) {
Expand Down Expand Up @@ -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)$).*)',
],
}
16 changes: 16 additions & 0 deletions src/lib/hostAllowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
)
}
14 changes: 9 additions & 5 deletions tests/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading