Browse Source

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.
_Kerman 5 days ago
parent
commit
67c5136c7e

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml

@@ -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

+ 1 - 1
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md

@@ -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
.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md

@@ -14,7 +14,7 @@ Assistant 正文有时用本地文件系统路径引用图片(markdown `![](/U
 
 - **渲染缝(`ui-primitives`)**:`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 - 2
packages/api/session-controller/README.i18n.yaml

@@ -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

+ 1 - 1
packages/api/session-controller/README.md

@@ -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.
 
 -----
 

+ 1 - 1
packages/api/session-controller/README.zh.md

@@ -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`);本包只拥有服务契约。
 
 -----
 

+ 34 - 17
packages/api/session-controller/src/media-references.ts

@@ -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()

+ 24 - 0
packages/api/session-controller/tests/media-references.host.spec.ts

@@ -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