fix(web): validate search hit provenance (round 2)

This commit is contained in:
Hypatia May
2026-07-27 12:27:11 +08:00
parent 891e9035e7
commit 222096e3cf
3 changed files with 47 additions and 6 deletions
@@ -8,9 +8,9 @@
- img
- button "Create workspace":
- img
- button "Search sessions":
- button "搜索会话":
- img
- textbox "Search name, keywords..."
- textbox "搜索名称或关键词…"
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
+8 -4
View File
@@ -630,10 +630,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
limit: SESSION_SEARCH_LIMIT,
}, { signal })
if (isAborted(signal)) return cancelled()
// The id filter is the authorization boundary. Re-check the provider
// projection before emitting it so a backend regression cannot leak
// a session that `session.list` withheld.
const authorized = page.items.filter(hit => visibleIds.has(hit.header.id))
// The filters are the authorization boundary. Re-check the complete
// provider provenance before emitting its snippet so a backend
// regression cannot pair an allowed header with excluded content.
const authorized = page.items.filter(hit =>
visibleIds.has(hit.header.id)
&& hit.bestMatch.sessionId === hit.header.id
&& hit.bestMatch.surface === 'current'
&& MESSAGE_TYPES.has(hit.bestMatch.type))
return ok(request, {
items: authorized.slice(0, SESSION_SEARCH_LIMIT).map(hit => ({
sessionId: hit.header.id,
@@ -160,6 +160,43 @@ describe('session.search', () => {
expect(searchSessions).not.toHaveBeenCalled()
})
it('rejects snippets whose provider provenance violates the Host filters', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const withBestMatch = (
index: number,
bestMatch: Partial<SessionSearchHit['bestMatch']>,
): SessionSearchHit => {
const base = hit('visible', index)
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({
items: [
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
],
nextCursor: 'more',
}),
} as never)
const response = await createApiProxy(ctx, defaults).sessions.search(
request('match'),
new AbortController().signal,
)
expect(response.result).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
hasMore: true,
},
})
})
it('enforces the 20-item Host boundary even if a provider overproduces', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))