Просмотр исходного кода

fix(api): honor only bytes ranges and ignore malformed or multi-range headers

range-parser never validates the range unit, so /api/file now parses only
headers that start with the bytes unit: unsatisfiable ranges answer 416,
while malformed, unknown-unit, and multi-range headers are ignored for a
full 200 body per RFC 9110. The filesystem-root workspace case stays
POSIX-only in tests (a Windows drive-root spelling cannot be constructed
portably); containment already treats any separator-terminated root the
same. Docs (README pair, Agent Note pair) re-recorded.
_Kerman 1 месяц назад
Родитель
Сommit
ba4a536030

+ 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: 6599d3e7c1e9838f9191e339fcca3bd811951af8
-2026-09-07-session-prose-local-media-display.zh.md: 8d2121dd206a0719a48a09e91973eb5edf970a6a
+2026-09-07-session-prose-local-media-display.md: fb277ebde175fbf3b331c65d5413400287b2443c
+2026-09-07-session-prose-local-media-display.zh.md: 6e68482db993d9667348560cd522e686ddb657d9

+ 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 requests answer 206, 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. 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).
 
 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 后流式输出:单段请求回 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: f92fb3eec7fc44a4d83be916bcea80805e2ef2a0
-README.zh.md: 0b2c54e9ac1f42993a8b7eaebf24b0570108ee38
+README.md: c108687ac712dcc7603d0bfdcac11bdc71826357
+README.zh.md: 336734ebf4eb677f853a905443e035b8c8fb909a

+ 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 requests stream a 206 slice parsed by `range-parser`; 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. 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.
 
 -----
 

+ 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`);该路由从不嗅探媒体字节。校验与读取绑定同一个已打开文件,替换/重链接竞态会被拒绝。单段 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`);该路由从不嗅探媒体字节。校验与读取绑定同一个已打开文件,替换/重链接竞态会被拒绝。单段 `bytes` Range 请求由 `range-parser` 解析后流式返回 206 分片;畸形、未知单位与多段 Range 头被忽略并返回完整 200;HEAD 从不打开文件流;客户端中止即销毁流。响应携带 `private, no-store` 与 `nosniff`;失败返回 400/403/404/415/416。客户端侧的重写词表位于 `ui-chat`(`AssistantMarkdown`);本包只拥有服务契约。
 
 -----
 

+ 12 - 5
packages/api/session-controller/src/media-references.ts

@@ -98,15 +98,22 @@ async function serveMediaReference(
       return new Response('file changed during validation', { status: 403 })
     }
     const total = after.size
-    const rangeHeader = request.headers.get('range')
-    const ranges = rangeHeader === null ? undefined : rangeParser(total, rangeHeader)
-    if (ranges === -1 || ranges === -2) {
-      // Unsatisfiable and malformed ranges answer the same way.
+    const rangeHeader = request.headers.get('range')?.trim() ?? null
+    // range-parser never validates the range unit, so only a `bytes` header is
+    // parsed here; malformed, unknown-unit, and multi-range headers are ignored
+    // per RFC 9110 and get the full 200 body below.
+    const ranges = rangeHeader !== null && rangeHeader.toLowerCase().startsWith('bytes=')
+      ? rangeParser(total, rangeHeader)
+      : undefined
+    if (ranges === -1) {
+      // Only an unsatisfiable range answers 416 with the total size. Malformed
+      // (-2) and unknown-unit or multi-range headers are ignored per RFC 9110
+      // and get the full 200 body below.
       const headers = { ...BASE_HEADERS, 'Content-Range': `bytes */${total}` }
       return new Response(null, { status: 416, headers })
     }
     // Only a single range is honored; a multi-range header gets the full body.
-    const slice = ranges?.length === 1 ? ranges[0] : undefined
+    const slice = Array.isArray(ranges) && ranges.length === 1 ? ranges[0] : undefined
     const { start, end } = slice ?? { start: 0, end: total - 1 }
     const headers: Record<string, string> = {
       ...BASE_HEADERS,

+ 29 - 12
packages/api/session-controller/tests/media-references.host.spec.ts

@@ -162,14 +162,25 @@ describe('SessionMediaReferences /api/file', () => {
     expect(await responseBytes(clamped)).toEqual(PNG_BYTES.slice(6))
   })
 
-  it('answers unsatisfiable and malformed ranges with 416 and the total size', async () => {
+  it('answers unsatisfiable ranges with 416 and the total size', async () => {
     const route = await mount()
     const path = join(root, 'graph.png')
     await writeFile(path, PNG_BYTES)
-    for (const range of ['bytes=999-', 'bytes=abc']) {
+    const response = await route.call(path, { headers: { range: 'bytes=999-' } })
+    expect(response.status).toBe(416)
+    expect(response.headers.get('content-range')).toBe(`bytes */${PNG_BYTES.length}`)
+  })
+
+  it('ignores malformed and unknown-unit ranges for a full 200 body', async () => {
+    const route = await mount()
+    const path = join(root, 'graph.png')
+    await writeFile(path, PNG_BYTES)
+    for (const range of ['bytes=abc', 'items=0-0']) {
       const response = await route.call(path, { headers: { range } })
-      expect(response.status, range).toBe(416)
-      expect(response.headers.get('content-range')).toBe(`bytes */${PNG_BYTES.length}`)
+      expect(response.status, range).toBe(200)
+      expect(response.headers.get('content-range')).toBeNull()
+      expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length))
+      expect(await responseBytes(response), range).toEqual(PNG_BYTES)
     }
   })
 
@@ -250,14 +261,20 @@ describe('SessionMediaReferences /api/file', () => {
     expect(await response.text()).toBe('not a regular file')
   })
 
-  it('serves files when the registered workspace is the filesystem root', async () => {
-    const route = await mount(sep)
-    const path = join(root, 'graph.png')
-    await writeFile(path, PNG_BYTES)
-    const response = await route.call(path)
-    expect(response.status).toBe(200)
-    expect(await responseBytes(response)).toEqual(PNG_BYTES)
-  })
+  // A POSIX filesystem root is the separator itself; the Windows drive-root
+  // spelling cannot be produced portably, so the case stays POSIX-only. The
+  // containment code treats any separator-terminated root the same way.
+  it.skipIf(process.platform === 'win32')(
+    'serves files when the registered workspace is the filesystem root',
+    async () => {
+      const route = await mount(sep)
+      const path = join(root, 'graph.png')
+      await writeFile(path, PNG_BYTES)
+      const response = await route.call(path)
+      expect(response.status).toBe(200)
+      expect(await responseBytes(response)).toEqual(PNG_BYTES)
+    },
+  )
 
   it('follows symlinks for the containment check', async () => {
     const route = await mount()