diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e94f71fd3f..8126dd432d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -667,16 +667,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...cursor === undefined ? {} : { cursor }, }, { signal }) if (isAborted(signal)) return cancelled() - if (page.items.length > SESSION_SEARCH_LIMIT) { + const providerItemCount = page.items.length + if (providerItemCount > SESSION_SEARCH_LIMIT) { throw new Error( - `session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`, + `session search provider returned ${providerItemCount} items; maximum is ${SESSION_SEARCH_LIMIT}`, ) } // Host visibility is the authorization boundary. Consume the // provider's globally ranked stream rather than binding every // visible id into one SQLite statement, then re-check complete - // provenance before emitting any snippet. - for (const hit of page.items) { + // provenance before emitting any snippet. Inspect exactly the + // declared array entries so a custom iterator cannot overproduce. + for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) { + const hit = page.items[itemIndex] + if (hit === undefined) { + throw new Error(`session search provider omitted item at index ${itemIndex}`) + } + if (authorized.length > SESSION_SEARCH_LIMIT) continue if ( !visibleIds.has(hit.header.id) || hit.bestMatch.sessionId !== hit.header.id @@ -689,14 +696,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId: hit.header.id, snippet: hit.bestMatch.snippet, }) - if (authorized.length > SESSION_SEARCH_LIMIT) break } - if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break - if (seenCursors.has(page.nextCursor)) { - throw new Error('session search provider repeated a continuation cursor') + const nextCursor = page.nextCursor + if (nextCursor !== undefined) { + if (seenCursors.has(nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(nextCursor) } - seenCursors.add(page.nextCursor) - cursor = page.nextCursor + if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break + cursor = nextCursor } return ok(request, { items: authorized.slice(0, SESSION_SEARCH_LIMIT), diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 840535ba2d..734ddfc499 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -272,6 +272,33 @@ describe('session.search', () => { expect(iterate).not.toHaveBeenCalled() }) + it('inspects only numerically stored items when a compliant page overrides iteration', async () => { + const ctx = await baseContext() + const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of visible) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const stored = visible.slice(0, 1) + const iterate = vi.fn(() => visible.values()) + Object.defineProperty(stored, Symbol.iterator, { value: iterate }) + const searchSessions = vi.fn(() => Promise.resolve({ items: stored })) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('custom-iterator'), + new AbortController().signal, + ) + + expect(response.result).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible-0', snippet: 'match 0' }], + hasMore: false, + }, + }) + expect(iterate).not.toHaveBeenCalled() + }) + it('fails closed when the provider repeats a continuation cursor', async () => { const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) @@ -292,6 +319,32 @@ describe('session.search', () => { expect(searchSessions).toHaveBeenCalledTimes(2) }) + it('validates a repeated cursor before accepting the authorized lookahead', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' }) + .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) + ctx.provide('sessionQuery', { searchSessions } as never) + + const response = await createApiProxy(ctx, defaults).sessions.search( + request('repeated-lookahead-cursor'), + new AbortController().signal, + ) + + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(response.result).not.toHaveProperty('value') + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.message).toContain('repeated a continuation cursor') + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + it('does not count duplicate session ids toward the result or lookahead boundary', async () => { const ctx = await baseContext() const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))