diff --git a/apps/web/src/lib/rewriteModelResponse.ts b/apps/web/src/lib/rewriteModelResponse.ts index 159fe1bf75..32bfe004e0 100644 --- a/apps/web/src/lib/rewriteModelResponse.ts +++ b/apps/web/src/lib/rewriteModelResponse.ts @@ -11,6 +11,7 @@ import { db } from '@/lib/drizzle'; import { KILO_ORGANIZATION_ID } from '@/lib/organizations/constants'; import { errorExceptInTest, logExceptInTest } from '@/lib/utils.server'; import { withRequestId } from '@/lib/ai-gateway/request-id'; +import { sanitizeJsonbValue } from '@/lib/sanitize-jsonb'; import type { EventSourceMessage } from 'eventsource-parser'; import { createParser } from 'eventsource-parser'; import { after, NextResponse } from 'next/server'; @@ -109,9 +110,9 @@ async function createRequestLogCapture( status_code: status, model, provider, - request: request.body, + request: sanitizeJsonbValue(request.body), response: responseText, - error, + error: sanitizeJsonbValue(error), }) .returning({ id: api_request_log.id }); logExceptInTest( diff --git a/apps/web/src/lib/sanitize-jsonb.test.ts b/apps/web/src/lib/sanitize-jsonb.test.ts new file mode 100644 index 0000000000..d2a9afd305 --- /dev/null +++ b/apps/web/src/lib/sanitize-jsonb.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from '@jest/globals'; +import { sanitizeJsonbValue } from './sanitize-jsonb'; + +describe('sanitizeJsonbValue', () => { + test('replaces JSONB-incompatible characters in nested values and object keys', () => { + const value = { + [`bad\ud800key`]: ['before\udc00after', { text: 'still\ud800broken' }], + [`nul\0key`]: 'nul\0value', + }; + + expect(sanitizeJsonbValue(value)).toEqual({ + ['bad\ufffdkey']: ['before\ufffdafter', { text: 'still\ufffdbroken' }], + ['nul\ufffdkey']: 'nul\ufffdvalue', + }); + }); + + test('preserves valid surrogate pairs and does not mutate the input', () => { + const value = { text: 'hello 😀' }; + + const sanitized = sanitizeJsonbValue(value); + + expect(sanitized).toEqual(value); + expect(sanitized).not.toBe(value); + }); +}); diff --git a/apps/web/src/lib/sanitize-jsonb.ts b/apps/web/src/lib/sanitize-jsonb.ts new file mode 100644 index 0000000000..c3afad9cbb --- /dev/null +++ b/apps/web/src/lib/sanitize-jsonb.ts @@ -0,0 +1,33 @@ +/** + * PostgreSQL JSONB rejects escaped NUL characters and lone UTF-16 surrogates. + * JavaScript strings can contain both, so repair them before sending values to + * a JSONB column. + */ +function sanitizeJsonbString(value: string): string { + if (value.isWellFormed() && !value.includes('\0')) { + return value; + } + + return value.toWellFormed().replaceAll('\0', '\ufffd'); +} + +export function sanitizeJsonbValue(value: unknown): unknown { + if (typeof value === 'string') { + return sanitizeJsonbString(value); + } + + if (Array.isArray(value)) { + return value.map(sanitizeJsonbValue); + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + sanitizeJsonbString(key), + sanitizeJsonbValue(nestedValue), + ]) + ); + } + + return value; +}