From d822ceeaf7daa17f6ed0d1244a66dc04896d20c4 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 02:50:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(share):=20=D0=BD=D0=B5=20=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D1=8C=20errorText=20=D1=82=D1=83=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC=D1=83?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=88=D1=8D=D1=80=D0=B5=20(closes=20#394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY. В публичном share-чате сырой текст ошибки тула или провайдера утекал анонимному читателю. Три слоя, все обязательны: (1) Рендер-гейт: prop showErrors в ToolCallCard (протянут через MessageList/ MessageItem), share-виджет передаёт false — сырой errorText не рисуется. Но рендер-гейт маскирует только DOM, не байты. (2) Санитизация на уровне share-тулсета (авторитетно): forShare оборачивает execute каждого тула catch'ем. Своя ShareToolError (безопасные строки: «page not available in this share») пробрасывается, ЛЮБАЯ другая ошибка → generic «tool could not complete», полный текст только в серверный лог. Одно место закрывает байты (атомарный tool-output-error фрейм v6), рендер и контекст модели; self- correction сохранена. (3) Анонимный onError пайпа: ShareToolError → её безопасное сообщение; иначе describeProviderError (statusCode + тело: внутренний baseUrl/модель) только в лог, читателю — фиксированная классифицированная строка (rate-limited/unavailable/ provider error). Тест: интеграционный с РЕАЛЬНЫМ падением тула и провайдера — assert по СЫРЫМ SSE- БАЙТАМ (не по DOM): секрет/baseUrl/стек отсутствуют, видна безопасная строка, полный текст провайдера ушёл в серверный лог. --- .../ai-chat/components/message-item.tsx | 10 + .../ai-chat/components/message-list.tsx | 8 + .../ai-chat/components/tool-call-card.tsx | 13 +- .../share/components/share-ai-widget.tsx | 4 + .../public-share-chat.error-leak.spec.ts | 241 ++++++++++++++++++ .../core/ai-chat/public-share-chat.service.ts | 56 +++- .../tools/public-share-chat-tools.service.ts | 75 +++++- 7 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/core/ai-chat/public-share-chat.error-leak.spec.ts diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 130b15dc..878477b4 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -47,6 +47,13 @@ interface MessageItemProps { * agent's raw query/argument text. */ showInput?: boolean; + /** + * Forwarded to ToolCallCard: whether a failed tool card renders its raw + * errorText. Defaults to true (internal chat). The public share passes false so + * internal detail in a tool error is never painted (belt to the server-side + * byte sanitization). + */ + showErrors?: boolean; /** * Neutralize internal/relative markdown links in the rendered answer (drop * their href so they become inert text). Defaults to false (internal chat, @@ -125,6 +132,7 @@ function MessageItem({ message, showCitations = true, showInput = true, + showErrors = true, neutralizeInternalLinks = false, assistantName, turnStreaming = false, @@ -219,6 +227,7 @@ function MessageItem({ part={part as unknown as ToolUiPart} showCitations={showCitations} showInput={showInput} + showErrors={showErrors} /> ); } @@ -284,6 +293,7 @@ export function arePropsEqual( prev.signature === next.signature && prev.showCitations === next.showCitations && prev.showInput === next.showInput && + prev.showErrors === next.showErrors && prev.neutralizeInternalLinks === next.neutralizeInternalLinks && prev.assistantName === next.assistantName && // The turn-end flip re-renders every row once (cheap, terminal event) — diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index d5c9c377..a208361f 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -32,6 +32,12 @@ interface MessageListProps { * doesn't see the agent's raw query/argument text. */ showInput?: boolean; + /** + * Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders + * its raw errorText. Defaults to true (internal chat). The public share passes + * false so internal detail in a tool error is never painted. + */ + showErrors?: boolean; /** * Forwarded to MessageItem: neutralize internal/relative markdown links in * the rendered answers (drop their href so they render as inert text). @@ -127,6 +133,7 @@ export default function MessageList({ emptyState, showCitations = true, showInput = true, + showErrors = true, neutralizeInternalLinks = false, assistantName, }: MessageListProps) { @@ -217,6 +224,7 @@ export default function MessageList({ signature={messageSignature(message)} showCitations={showCitations} showInput={showInput} + showErrors={showErrors} neutralizeInternalLinks={neutralizeInternalLinks} assistantName={assistantName} // Turn-level liveness, gated to the TAIL row: only the tail message diff --git a/apps/client/src/features/ai-chat/components/tool-call-card.tsx b/apps/client/src/features/ai-chat/components/tool-call-card.tsx index 30efb19f..faaea631 100644 --- a/apps/client/src/features/ai-chat/components/tool-call-card.tsx +++ b/apps/client/src/features/ai-chat/components/tool-call-card.tsx @@ -30,6 +30,16 @@ interface ToolCallCardProps { * the extra summary line, leaving the card (the action log) intact. */ showInput?: boolean; + /** + * Whether to render the tool's raw errorText on a failed call. Defaults to true + * (the internal chat, where the operator may debug). The public share passes + * false: a tool error string can carry internal detail (an internal page title, + * a stack fragment, a provider message). This is the RENDER gate only — the + * authoritative fix also sanitizes the bytes server-side (see + * PublicShareChatToolsService.forShare), so a share reader never receives raw + * error text over the wire, not just never sees it painted (#394). + */ + showErrors?: boolean; } /** @@ -41,6 +51,7 @@ export default function ToolCallCard({ part, showCitations = true, showInput = true, + showErrors = true, }: ToolCallCardProps) { const { t } = useTranslation(); const toolName = getToolName(part); @@ -74,7 +85,7 @@ export default function ToolCallCard({ )} - {state === "error" && part.errorText && ( + {state === "error" && showErrors && part.errorText && ( {part.errorText} diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx index 59b2efb5..6a1930c8 100644 --- a/apps/client/src/features/share/components/share-ai-widget.tsx +++ b/apps/client/src/features/share/components/share-ai-widget.tsx @@ -168,6 +168,10 @@ export default function ShareAiWidget({ // Anonymous reader: suppress the tool-argument summary line so the // agent's raw query/argument text isn't shown on the public share. showInput={false} + // Anonymous reader: never paint a tool's raw errorText (it can carry + // internal detail). This is the render gate; the bytes are also + // sanitized server-side in PublicShareChatToolsService.forShare (#394). + showErrors={false} // Anonymous reader: neutralize internal/relative links in the // assistant's markdown so internal UUIDs/auth-gated routes don't // leak as clickable links (external http(s) links are kept). diff --git a/apps/server/src/core/ai-chat/public-share-chat.error-leak.spec.ts b/apps/server/src/core/ai-chat/public-share-chat.error-leak.spec.ts new file mode 100644 index 00000000..4d6151de --- /dev/null +++ b/apps/server/src/core/ai-chat/public-share-chat.error-leak.spec.ts @@ -0,0 +1,241 @@ +// Break the editor-ext import chain (share.service -> collaboration.util -> +// @docmost/editor-ext -> @tiptap/core) that is unresolvable in this jest env and +// pre-existingly breaks these specs. jsonToMarkdown is never reached in these +// tests (the tools fail before rendering markdown). +jest.mock('../../collaboration/collaboration.util', () => ({ + jsonToMarkdown: () => '', +})); + +import { Logger } from '@nestjs/common'; +import { MockLanguageModelV3, simulateReadableStream } from 'ai/test'; +import { PublicShareChatService } from './public-share-chat.service'; +import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service'; + +/** + * SECURITY integration guard for #394 (commit 5): a tool's or the provider's raw + * error text must NOT leak to an anonymous public-share reader. + * + * The render gate (ToolCallCard showErrors=false) hides the text in the DOM but + * NOT on the wire, so this test asserts on the RAW SSE BYTES the server writes — + * exactly the channel the render gate masks. We drive the real + * PublicShareChatService.stream() with a real share toolset (its underlying + * services mocked to fail) and a mock model, then inspect every byte piped to the + * fake socket. + */ + +// A minimal ServerResponse stand-in that records every written chunk. +class FakeSocket { + chunks: string[] = []; + statusCode = 200; + writableEnded = false; + destroyed = false; + headersSent = false; + writeHead(): this { + this.headersSent = true; + return this; + } + setHeader(): void {} + removeHeader(): void {} + getHeader(): undefined { + return undefined; + } + flushHeaders(): void {} + write(chunk: unknown): boolean { + this.chunks.push( + typeof chunk === 'string' ? chunk : Buffer.from(chunk as never).toString('utf8'), + ); + return true; + } + end(chunk?: unknown): void { + if (chunk) this.write(chunk); + this.writableEnded = true; + } + on(): this { + return this; + } + once(): this { + return this; + } + get body(): string { + return this.chunks.join(''); + } +} + +/** Mock model that issues one getSharePage tool call, then finishes with text. */ +function toolCallingModel(): MockLanguageModelV3 { + let call = 0; + return new MockLanguageModelV3({ + doStream: async () => { + call++; + if (call === 1) { + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start' as const, warnings: [] }, + { type: 'tool-input-start' as const, id: 't1', toolName: 'getSharePage' }, + { type: 'tool-input-end' as const, id: 't1' }, + { + type: 'tool-call' as const, + toolCallId: 't1', + toolName: 'getSharePage', + input: '{"pageId":"secret-page"}', + }, + { + type: 'finish' as const, + finishReason: { unified: 'tool-calls' as const, raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, + }, + }, + ], + }), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start' as const, warnings: [] }, + { type: 'text-start' as const, id: '1' }, + { type: 'text-delta' as const, id: '1', delta: 'Sorry.' }, + { type: 'text-end' as const, id: '1' }, + { + type: 'finish' as const, + finishReason: { unified: 'stop' as const, raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 1, text: 1, reasoning: undefined }, + }, + }, + ], + }), + }; + }, + }); +} + +/** Mock model whose stream emits a provider error carrying an internal secret. */ +function providerErrorModel(secret: string): MockLanguageModelV3 { + return new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start' as const, warnings: [] }, + { + type: 'error' as const, + error: { + statusCode: 503, + message: 'Service Unavailable', + responseBody: `upstream ${secret} model=internal-gpt`, + }, + }, + ], + }), + }), + }); +} + +function makeService(toolsService: PublicShareChatToolsService): { + svc: PublicShareChatService; + logSpy: jest.SpyInstance; +} { + const svc = Object.create(PublicShareChatService.prototype); + const logger = new Logger('test'); + const logSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined); + jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + svc.tools = toolsService; + svc.logger = logger; + svc.tokenBudget = { record: jest.fn().mockResolvedValue(undefined) }; + return { svc, logSpy }; +} + +async function runStream( + svc: PublicShareChatService, + model: MockLanguageModelV3, +): Promise { + const socket = new FakeSocket(); + await svc.stream({ + workspaceId: 'ws1', + shareId: 'share1', + share: { id: 'share1', pageId: 'p1', sharedPage: { id: 'p1', title: 'Docs' } }, + openedPage: null, + messages: [ + { id: 'm1', role: 'user', parts: [{ type: 'text', text: 'read the page' }] } as never, + ], + res: { raw: socket } as never, + signal: new AbortController().signal, + model: model as never, + role: null, + }); + // Let the piped stream drain fully. + await new Promise((r) => setTimeout(r, 300)); + return socket; +} + +describe('public share chat error leak (#394)', () => { + afterEach(() => jest.restoreAllMocks()); + + it('does NOT leak a tool\'s raw internal error to the SSE bytes (generic classified string instead)', async () => { + const SECRET = 'INTERNAL_baseUrl_http://provider.internal:8080/v1'; + const shareService = { + // The canonical boundary throws a RAW internal error (with a secret). + resolveReadableSharePage: jest + .fn() + .mockRejectedValue(new Error(`db failed at ${SECRET} stack@line42`)), + }; + const tools = new PublicShareChatToolsService( + shareService as never, + {} as never, + {} as never, + ); + const { svc } = makeService(tools); + + const socket = await runStream(svc, toolCallingModel()); + + // The tool-output-error frame is present on the wire... + expect(socket.body).toContain('tool-output-error'); + // ...but it carries ONLY the generic classified string — never the secret, + // the raw driver message, or a stack fragment. + expect(socket.body).toContain('The tool could not complete the request.'); + expect(socket.body).not.toContain(SECRET); + expect(socket.body).not.toContain('stack@line42'); + expect(socket.body).not.toContain('db failed'); + }); + + it('passes a SAFE ShareToolError message (page not available) through to the bytes', async () => { + const shareService = { + // Not found in this share -> the tool throws the classified SAFE message. + resolveReadableSharePage: jest.fn().mockResolvedValue(null), + }; + const tools = new PublicShareChatToolsService( + shareService as never, + {} as never, + {} as never, + ); + const { svc } = makeService(tools); + + const socket = await runStream(svc, toolCallingModel()); + expect(socket.body).toContain('tool-output-error'); + expect(socket.body).toContain('not available in this share'); + }); + + it('does NOT leak a provider error (statusCode + response body) to the SSE bytes', async () => { + const SECRET = 'http://provider.internal:8080'; + const tools = new PublicShareChatToolsService( + {} as never, + {} as never, + {} as never, + ); + const { svc, logSpy } = makeService(tools); + + const socket = await runStream(svc, providerErrorModel(SECRET)); + + // The anon sees a fixed classified string, not the provider body/baseUrl/model. + expect(socket.body).toContain('temporarily unavailable'); + expect(socket.body).not.toContain(SECRET); + expect(socket.body).not.toContain('internal-gpt'); + // The FULL provider detail is logged server-side only. + const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain(SECRET); + }); +}); diff --git a/apps/server/src/core/ai-chat/public-share-chat.service.ts b/apps/server/src/core/ai-chat/public-share-chat.service.ts index a98e738f..236067eb 100644 --- a/apps/server/src/core/ai-chat/public-share-chat.service.ts +++ b/apps/server/src/core/ai-chat/public-share-chat.service.ts @@ -12,7 +12,10 @@ import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles import { AiAgentRole } from '@docmost/db/types/entity.types'; import { AiService } from '../../integrations/ai/ai.service'; import { AiSettingsService } from '../../integrations/ai/ai-settings.service'; -import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service'; +import { + PublicShareChatToolsService, + ShareToolError, +} from './tools/public-share-chat-tools.service'; import { buildShareSystemPrompt } from './public-share-chat.prompt'; import { roleModelOverride } from './roles/role-model-config'; import { @@ -102,6 +105,30 @@ export function filterShareTranscript(messages: UIMessage[]): UIMessage[] { ); } +/** + * Fixed, classified strings an ANONYMOUS share reader may see when the assistant + * stream fails (#394). These reveal NOTHING about the internal provider, its + * baseUrl, the model name, or the raw response body — unlike describeProviderError + * (which is for the server log / the authenticated operator only). We classify by + * HTTP status where available so the reader still gets a useful hint (retry vs. + * give up) without any internal detail. + */ +export function classifyAnonStreamError(error: unknown): string { + const status = + typeof error === 'object' && error !== null + ? (error as { statusCode?: number }).statusCode + : undefined; + if (status === 429) { + return 'The assistant is receiving too many requests right now. Please try again shortly.'; + } + if (typeof status === 'number' && status >= 500) { + return 'The assistant is temporarily unavailable. Please try again.'; + } + // Any other failure (including a bare connection error with no status): a + // single neutral line. No provider identity, no config, no response body. + return 'The assistant could not complete your request. Please try again.'; +} + /** * Anonymous, read-only AI assistant for a single PUBLIC share tree. * @@ -318,11 +345,28 @@ export class PublicShareChatService { result.pipeUIMessageStreamToResponse(res.raw, { headers: { 'X-Accel-Buffering': 'no' }, onError: (error: unknown) => { - // Reuse the shared formatter so provider error formatting stays - // unified between the log line and the streamed error message — a - // share reader sees 402/429/503 causes consistently with the - // authenticated path. - return describeProviderError(error, 'AI stream error'); + // SECURITY (#394): the string this returns is written verbatim into the + // SSE error frame delivered to an ANONYMOUS reader (for a tool failure + // it becomes the atomic `tool-output-error` frame's errorText; for a + // stream/provider failure, the terminal error frame). + // + // A ShareToolError is already a classified, safe tool message (see + // PublicShareChatToolsService.wrapToolErrors) — pass it through so the + // reader still gets the useful "page not available in this share" hint. + if (error instanceof ShareToolError) { + return error.message; + } + // Anything else is a provider/stream error. describeProviderError + // bundles the provider statusCode AND response body, which can carry the + // internal baseUrl or model name — NEVER expose that to the public. Log + // the full detail server-side only and return a fixed classified string. + this.logger.error( + `Public share chat pipe error: ${describeProviderError( + error, + 'AI stream error', + )}`, + ); + return classifyAnonStreamError(error); }, }); diff --git a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts index 2d2da79d..5467234f 100644 --- a/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/public-share-chat-tools.service.ts @@ -7,6 +7,22 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { jsonToMarkdown } from '../../../collaboration/collaboration.util'; import { modelFriendlyInput } from './model-friendly-input'; +/** + * A tool error whose message is DELIBERATELY safe to expose to an anonymous + * share reader (and to the model, for self-correction). Every OTHER thrown error + * is treated as internal and replaced with a generic string by `wrapToolErrors`, + * so a raw exception message — an internal page title, a DB/stack fragment, a + * driver detail — never rides the public UI stream (#394). + */ +export class ShareToolError extends Error {} + +// The only two classified strings an anonymous reader may ever see from a tool +// failure. The specific one keeps the model's self-correction useful ("try a +// different page"); the generic one reveals nothing about the internal fault. +const SHARE_TOOL_ERROR_NOT_AVAILABLE = + 'The requested page is not available in this share.'; +const SHARE_TOOL_ERROR_GENERIC = 'The tool could not complete the request.'; + /** * Isolated, READ-ONLY toolset for the ANONYMOUS public-share assistant. * @@ -44,7 +60,7 @@ export class PublicShareChatToolsService { * are NO write tools, NO comments/history, NO cross-space or external tools. */ forShare(shareId: string, workspaceId: string): Record { - return { + return this.wrapToolErrors({ searchSharePages: tool({ description: 'Search the pages of THIS published documentation share for a ' + @@ -96,7 +112,7 @@ export class PublicShareChatToolsService { execute: async ({ pageId }) => { const id = (pageId ?? '').trim(); if (!id) { - throw new Error('A pageId is required.'); + throw new ShareToolError('A pageId is required.'); } // Resolve via the SINGLE canonical share-access boundary: confirms the // page resolves to THIS share (recursive CTE up the tree, honouring @@ -112,7 +128,7 @@ export class PublicShareChatToolsService { workspaceId, ); if (!resolved) { - throw new Error('That page is not part of this published share.'); + throw new ShareToolError(SHARE_TOOL_ERROR_NOT_AVAILABLE); } const { page } = resolved; @@ -193,6 +209,57 @@ export class PublicShareChatToolsService { } }, }), - }; + }); + } + + /** + * Wrap every tool's `execute` so a THROWN error is sanitized in ONE place — + * closing the byte leak, the render, and the model context at once (#394). + * + * The AI SDK surfaces a tool-execution throw as an atomic `tool-output-error` + * frame on the v6 UI stream whose `errorText` is the thrown message; on the + * public share that frame goes straight to an anonymous reader. Unwrapped, a + * raw exception (an internal page title, a DB/stack fragment, a driver detail) + * would ride that frame verbatim. Here we catch it, LOG the full detail + * server-side only, and re-throw a CLASSIFIED, safe error: the tool's own + * intentional ShareToolError messages pass through (they keep the model's + * self-correction useful), everything else collapses to a generic string. + */ + private wrapToolErrors( + tools: Record, + ): Record { + const wrapped: Record = {}; + for (const [name, t] of Object.entries(tools)) { + const original = t.execute; + if (typeof original !== 'function') { + wrapped[name] = t; + continue; + } + wrapped[name] = { + ...t, + execute: async (args: unknown, options: unknown) => { + try { + return await ( + original as (a: unknown, o: unknown) => Promise + )(args, options); + } catch (err) { + const safe = + err instanceof ShareToolError + ? err.message + : SHARE_TOOL_ERROR_GENERIC; + // Full detail to the server log ONLY — never to the anon. + this.logger.warn( + `Public share tool "${name}" failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + // This safe string is ALL that rides the tool-output-error frame, + // becomes model context, and could be rendered — one choke point. + throw new ShareToolError(safe); + } + }, + } as Tool; + } + return wrapped; } }