mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user