mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +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:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md
|
||||
2026-09-07-session-prose-local-media-display.md: fb277ebde175fbf3b331c65d5413400287b2443c
|
||||
2026-09-07-session-prose-local-media-display.zh.md: 6e68482db993d9667348560cd522e686ddb657d9
|
||||
2026-09-07-session-prose-local-media-display.md: bd898e6ced09dd55a6d6eeaee7e73b9187cded9d
|
||||
2026-09-07-session-prose-local-media-display.zh.md: 80ebe968f86022f3261ac5d2929b71dc68dc7f67
|
||||
|
||||
@@ -14,7 +14,7 @@ Local media paths in session prose render through one same-origin file route, wi
|
||||
|
||||
- **Renderer seam (`ui-primitives`)**: `MarkdownText` gained a `MarkdownPathImages` vocabulary (`pathImages` prop) with the same settled-only gate as `fileMentions`: while a message streams, frozen cached blocks never bake in a vocabulary handler; the settled pass rewrites image destinations that fail the remote-URL allowlist, and a rewritten destination is emitted only when it is an absolute `http(s)`/`blob`/`data` URL. Without a vocabulary the renderer output is byte-identical to before.
|
||||
- **Chat wiring (`ui-chat`)**: `AssistantMarkdown` supplies a page-stable vocabulary (`localPathMediaUrl`, module-private in the same file) mapping absolute POSIX paths to same-origin `/api/file?path=…` GETs; non-HTTP transports (Electron `file://`) and relative/protocol-relative destinations stay inert. Windows-style host paths (`C:\...`) are deliberately inert on the client side and are recorded as a limitation in Consequences.
|
||||
- **Host route (`session-controller`)**: `SessionMediaReferences`, a plugin contribution registered beside `SessionFileReferences`, mounts `GET|HEAD /api/file` on the shared authenticated `connection.fetch` channel (same trust fence and browser authentication as `/api` RPC). Per request it fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root (path-component containment, including a filesystem-root workspace), the file must be regular, and its MIME type must belong to the served categories image/video/audio (resolved by the `mime-types` package, excluding `image/svg+xml`); media bytes are never sniffed on this route. Validation and reading bind to the same opened file: the pre-open and opened stat identities are compared, so a replacement or re-linking race is refused instead of followed. Responses stream with HTTP range support parsed by the `range-parser` package: single-range `bytes` requests answer 206, malformed/unknown-unit/multi-range headers are ignored for a full 200 body, HEAD never opens a file stream, and an aborted client destroys the stream. Responses carry `private, no-store` and `nosniff`. The contribution activates only where `connection` and `workspaceRegistry` are composed (pending-until-composed, like the package's other optional contributions).
|
||||
- **Host route (`session-controller`)**: `SessionMediaReferences`, a plugin contribution registered beside `SessionFileReferences`, mounts `GET|HEAD /api/file` on the shared authenticated `connection.fetch` channel (same trust fence and browser authentication as `/api` RPC). Per request it fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root (path-component containment, including a filesystem-root workspace), the file must be regular, and its MIME type must belong to the served categories image/video/audio (resolved by the `mime-types` package, excluding `image/svg+xml`); media bytes are never sniffed on this route. Regular-file checks run before opening (a named pipe or device node is refused instead of blocking the open); reading binds to the opened file, and a stat-identity comparison with the pre-open stat narrows, but does not fully close, a concurrent replacement window. Responses stream with HTTP range support parsed by the `range-parser` package: single-range `bytes` requests answer 206, malformed/unknown-unit/multi-range headers are ignored for a full 200 body, HEAD never opens a file stream, and an aborted client destroys the stream. Responses carry `private, no-store` and `nosniff`. The contribution activates only where `connection` and `workspaceRegistry` are composed (pending-until-composed, like the package's other optional contributions).
|
||||
|
||||
The route is presentational and stateless: it never writes, follows no redirects, and returns 400/403/404/415/416 instead of approximating another file-serving behavior.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Assistant 正文有时用本地文件系统路径引用图片(markdown `**:`MarkdownText` 新增 `MarkdownPathImages` 词表(`pathImages` prop),与 `fileMentions` 同用 settled-only 门:消息流式期间冻结的缓存块绝不烘入词表处理器;落定渲染把未通过远程 URL 白名单的图片目标重写为可展示 URL,且只有结果是绝对 `http(s)`/`blob`/`data` URL 时才发射。不提供词表时渲染输出与之前逐字节一致。
|
||||
- **聊天接线(`ui-chat`)**:`AssistantMarkdown` 提供页面级稳定的词表(`localPathMediaUrl`,与组件同文件的模块私有函数),把绝对 POSIX 路径映射为同源 `GET /api/file?path=…`;非 HTTP 载体(Electron `file://`)与相对/协议相对目标保持惰性。Windows 风格 Host 路径(`C:\...`)在客户端侧刻意保持惰性,作为局限记录于 Consequences。
|
||||
- **Host 路由(`session-controller`)**:`SessionMediaReferences` 是与 `SessionFileReferences` 并列注册的插件贡献,把 `GET|HEAD /api/file` 挂到共享鉴权 `connection.fetch` 通道(与 `/api` RPC 同一 trust fence 与浏览器认证)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内(按路径组件判定包含,含文件系统根目录作为 workspace 的情况)、文件必须是常规文件、其 MIME 类型(由 `mime-types` 解析)必须属于受服务类别 image/video/audio(排除 `image/svg+xml`);该路由从不嗅探媒体字节。校验与读取绑定同一个已打开文件:比较打开前后 stat 身份,替换/重链接竞态被拒绝而非跟随。响应由 `range-parser` 解析 Range 后流式输出:单段 `bytes` 请求回 206,畸形/未知单位/多段 Range 被忽略并回完整 200,HEAD 从不打开文件流,客户端中止即销毁流;响应携带 `private, no-store` 与 `nosniff`。该贡献只在 `connection` 与 `workspaceRegistry` 均被组合时激活(pending-until-composed,与包内其它可选贡献一致)。
|
||||
- **Host 路由(`session-controller`)**:`SessionMediaReferences` 是与 `SessionFileReferences` 并列注册的插件贡献,把 `GET|HEAD /api/file` 挂到共享鉴权 `connection.fetch` 通道(与 `/api` RPC 同一 trust fence 与浏览器认证)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内(按路径组件判定包含,含文件系统根目录作为 workspace 的情况)、文件必须是常规文件、其 MIME 类型(由 `mime-types` 解析)必须属于受服务类别 image/video/audio(排除 `image/svg+xml`);该路由从不嗅探媒体字节。常规文件检查在打开前执行(命名管道与设备节点会被拒绝而不是阻塞打开);读取绑定已打开文件,并与打开前的 stat 做身份比较,收窄(而非完全消除)并发替换窗口。响应由 `range-parser` 解析 Range 后流式输出:单段 `bytes` 请求回 206,畸形/未知单位/多段 Range 被忽略并回完整 200,HEAD 从不打开文件流,客户端中止即销毁流;响应携带 `private, no-store` 与 `nosniff`。该贡献只在 `connection` 与 `workspaceRegistry` 均被组合时激活(pending-until-composed,与包内其它可选贡献一致)。
|
||||
|
||||
该路由纯呈现且无状态:从不写入、不跟随重定向,失败返回 400/403/404/415/416,不近似其它文件服务行为。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: c108687ac712dcc7603d0bfdcac11bdc71826357
|
||||
README.zh.md: 336734ebf4eb677f853a905443e035b8c8fb909a
|
||||
README.md: c7ecb98b4f9a791c65b8915e57e710e4038a90de
|
||||
README.zh.md: 74f70f167454a0ac76b18cc272c0133b427ca223
|
||||
|
||||
@@ -36,7 +36,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio
|
||||
<a id="session-media-references"></a>
|
||||
## Session media references
|
||||
|
||||
`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when both `connection` and `workspaceRegistry` are composed, so conversation prose can display workspace-contained local media (images today; video/audio later) in the browser. Per request the route fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root (path-component containment, including a filesystem-root workspace), the file must be regular, and its `mime-types` category must be image/video/audio (excluding `image/svg+xml`); media bytes are never sniffed. Validation and reading bind to the same opened file, so a replacement or re-linking race is refused. Single-range `bytes` requests stream a 206 slice parsed by `range-parser`; malformed, unknown-unit, and multi-range headers are ignored for a full 200 body; HEAD never opens a file stream; an aborted client destroys it. Responses carry `private, no-store` and `nosniff`; failures answer 400/403/404/415/416. The client-side rewrite vocabulary lives in `ui-chat` (`AssistantMarkdown`); this package owns only the serving contract.
|
||||
`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when both `connection` and `workspaceRegistry` are composed, so conversation prose can display workspace-contained local media (images today; video/audio later) in the browser. Per request the route fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root (path-component containment, including a filesystem-root workspace), the file must be regular, and its `mime-types` category must be image/video/audio (excluding `image/svg+xml`); media bytes are never sniffed. Regular-file checks run before opening, so named pipes and device nodes are refused instead of blocking the open; reading binds to the opened file, and a stat-identity comparison with the pre-open stat narrows (does not fully close) a concurrent replacement window. Single-range `bytes` requests stream a 206 slice parsed by `range-parser`; malformed, unknown-unit, and multi-range headers are ignored for a full 200 body; HEAD never opens a file stream; an aborted client destroys it. Responses carry `private, no-store` and `nosniff`; failures answer 400/403/404/415/416. The client-side rewrite vocabulary lives in `ui-chat` (`AssistantMarkdown`); this package owns only the serving contract.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|
||||
<a id="session-media-references"></a>
|
||||
## 会话媒体引用
|
||||
|
||||
当 `connection` 与 `workspaceRegistry` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`,使会话正文可以引用 workspace 内的本地媒体(图片今天可用;视频/音频后续同一条 URL)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内(按路径组件判定包含,含文件系统根目录作为 workspace)、文件必须是常规文件、其 `mime-types` 类别必须属于 image/video/audio(排除 `image/svg+xml`);该路由从不嗅探媒体字节。校验与读取绑定同一个已打开文件,替换/重链接竞态会被拒绝。单段 `bytes` Range 请求由 `range-parser` 解析后流式返回 206 分片;畸形、未知单位与多段 Range 头被忽略并返回完整 200;HEAD 从不打开文件流;客户端中止即销毁流。响应携带 `private, no-store` 与 `nosniff`;失败返回 400/403/404/415/416。客户端侧的重写词表位于 `ui-chat`(`AssistantMarkdown`);本包只拥有服务契约。
|
||||
当 `connection` 与 `workspaceRegistry` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`,使会话正文可以引用 workspace 内的本地媒体(图片今天可用;视频/音频后续同一条 URL)。每次请求 fail-closed:路径必须绝对、其 `realpath` 必须落在已注册 workspace 根内(按路径组件判定包含,含文件系统根目录作为 workspace)、文件必须是常规文件、其 `mime-types` 类别必须属于 image/video/audio(排除 `image/svg+xml`);该路由从不嗅探媒体字节。常规文件检查在打开前执行,命名管道与设备节点会被拒绝而不是阻塞打开;读取绑定已打开文件,并与打开前的 stat 做身份比较,收窄(而非完全消除)并发替换窗口。单段 `bytes` Range 请求由 `range-parser` 解析后流式返回 206 分片;畸形、未知单位与多段 Range 头被忽略并返回完整 200;HEAD 从不打开文件流;客户端中止即销毁流。响应携带 `private, no-store` 与 `nosniff`;失败返回 400/403/404/415/416。客户端侧的重写词表位于 `ui-chat`(`AssistantMarkdown`);本包只拥有服务契约。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -212,6 +212,13 @@ describe('SessionMediaReferences /api/file', () => {
|
||||
expect(ranged.body).toBeNull()
|
||||
})
|
||||
|
||||
it('answers HEAD errors without a body', async () => {
|
||||
const route = await mount()
|
||||
const response = await route.raw('http://127.0.0.1:3080/api/file', { method: 'HEAD' })
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body).toBeNull()
|
||||
})
|
||||
|
||||
it('denies malformed, missing, empty, and uncontained requests', async () => {
|
||||
const route = await mount()
|
||||
expect((await route.raw('http://127.0.0.1:3080/api/file')).status).toBe(400)
|
||||
@@ -234,6 +241,23 @@ describe('SessionMediaReferences /api/file', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// A FIFO would block a plain open; the pre-open regular-file check refuses
|
||||
// it first. POSIX-only because Windows has no named-pipe path construction
|
||||
// here.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses a FIFO named as media without blocking on the open',
|
||||
async () => {
|
||||
const route = await mount()
|
||||
const pipe = join(root, 'stream.png')
|
||||
const { execFile } = await import('node:child_process')
|
||||
const { promisify } = await import('node:util')
|
||||
await promisify(execFile)('mkfifo', [pipe])
|
||||
const response = await route.call(pipe)
|
||||
expect(response.status).toBe(403)
|
||||
expect(await response.text()).toBe('not a regular file')
|
||||
},
|
||||
)
|
||||
|
||||
it('answers 404 when an existing file cannot be opened for reading', async () => {
|
||||
// A permission-less file passes realpath/stat but fails the open; the
|
||||
// route treats the unreadable file like a missing one. chmod has no
|
||||
|
||||
Reference in New Issue
Block a user