fix(api): refuse non-regular files before open and bound full streams

- A media-named FIFO or device node is refused before the open would
  block on it (pre-open isFile check); POSIX FIFO regression test added.
- The full-body stream is bounded to the stat'ed size so concurrent
  appends cannot exceed the declared Content-Length.
- Error responses carry no body for HEAD requests.
- Docs no longer overclaim the replacement race: the stat-identity
  comparison narrows (does not fully close) the replacement window, and
  the earlier realpath-to-stat window is acknowledged. Pairs re-recorded.
This commit is contained in:
_Kerman
2026-09-07 18:04:07 +08:00
parent ba4a536030
commit 67c5136c7e
8 changed files with 66 additions and 25 deletions
@@ -12,10 +12,11 @@
* - its MIME type (resolved by `mime-types`) must belong to the served
* categories image/video/audio, excluding `image/svg+xml`; media bytes are
* never sniffed here, and a corrupt payload fails in the browser;
* - the file must exist and be a regular file; validation and reading bind to
* the same opened file (a replacement or re-linking race between the
* containment check and the read is detected by comparing the pre-open and
* opened stat identities and refused);
* - the file must exist and be a regular file (checked before opening, so a
* named pipe or device node is refused instead of blocking the open);
* reading binds to the opened file, and comparing its stat identity with
* the pre-open stat narrows (does not fully close) a concurrent
* replacement window;
* - multi-range requests are answered with the full 200 body (no Range is
* honored) rather than a mislabeled single-segment 206;
* - responses are private, uncached, sniff-proof, and stream with HTTP range
@@ -58,44 +59,57 @@ async function serveMediaReference(
request: Request,
roots: readonly { path: string }[],
): Promise<Response> {
// Error responses carry a text body for GET debugging; HEAD responses must
// never carry one.
const fail = (status: number, text: string): Response =>
new Response(request.method === 'HEAD' ? null : text, { status })
const path = new URL(request.url).searchParams.get('path')
if (path === null || path.length === 0) return new Response('missing path', { status: 400 })
if (path === null || path.length === 0) return fail(400, 'missing path')
if (path.includes('\0') || !isAbsolute(path)) {
return new Response('absolute path required', { status: 400 })
return fail(400, 'absolute path required')
}
let canonical: string
try {
canonical = await realpath(path)
} catch {
return new Response('not found', { status: 404 })
return fail(404, 'not found')
}
// Containment compares path components; a filesystem root already ends in
// the separator and prefixes every absolute path.
const contained = roots.some(({ path: root }) =>
canonical === root || canonical.startsWith(root.endsWith(sep) ? root : root + sep))
if (!contained) return new Response('outside workspace roots', { status: 403 })
if (!contained) return fail(403, 'outside workspace roots')
const mediaType = mime.lookup(canonical)
if (mediaType === false || mediaType === 'image/svg+xml' || !SERVED_MEDIA_TYPE.test(mediaType)) {
return new Response('not an allowlisted media type', { status: 415 })
return fail(415, 'not an allowlisted media type')
}
// The stat identity of the validated path is compared with the identity of
// the file actually opened, so a replacement or re-linking race between the
// containment check and the read is refused instead of followed.
// Open only after proving the validated path is a regular file: opening a
// FIFO or device node first would block or read unbounded data.
let before
let handle
try {
before = await stat(canonical)
} catch {
/* v8 ignore next 1 -- the path cannot vanish between the realpath above and this stat except in a concurrent deletion race */
return fail(404, 'not found')
}
if (!before.isFile()) return fail(403, 'not a regular file')
let handle
try {
handle = await open(canonical, 'r')
} catch {
return new Response('not found', { status: 404 })
return fail(404, 'not found')
}
let streamed = false
try {
const after = await handle.stat()
if (!after.isFile()) return new Response('not a regular file', { status: 403 })
/* v8 ignore next 1 -- only a concurrent replacement could make the opened file non-regular after the pre-open check */
if (!after.isFile()) return fail(403, 'not a regular file')
// The opened handle's identity must match the validated stat; this only
// narrows a replacement race between the stat and the open, it does not
// eliminate the earlier realpath-to-stat window.
/* v8 ignore next 2 -- the replacement race cannot be produced deterministically; this arm refuses it when it happens */
if (after.dev !== before.dev || after.ino !== before.ino) {
return new Response('file changed during validation', { status: 403 })
return fail(403, 'file changed during validation')
}
const total = after.size
const rangeHeader = request.headers.get('range')?.trim() ?? null
@@ -129,7 +143,10 @@ async function serveMediaReference(
return new Response(null, { status, headers })
}
streamed = true
const source = addAbortSignal(request.signal, handle.createReadStream(slice))
// Bound the full-body stream to the stat'ed size so concurrent appends
// cannot push the payload past the declared Content-Length.
const stream = handle.createReadStream(slice ?? { start: 0, end: total - 1 })
const source = addAbortSignal(request.signal, stream)
return new Response(Readable.toWeb(source) as ReadableStream<Uint8Array>, { status, headers })
} finally {
if (!streamed) await handle.close()