fix(web): validate search hit provenance (round 2)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user