diff --git a/.changeset/fix-web-image-csp.md b/.changeset/fix-web-image-csp.md new file mode 100644 index 0000000000..ce769f7377 --- /dev/null +++ b/.changeset/fix-web-image-csp.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix uploaded and persisted images failing to display on non-loopback server connections. diff --git a/packages/kap-server/src/middleware/securityHeaders.ts b/packages/kap-server/src/middleware/securityHeaders.ts index 21f27767dd..412218b1a2 100644 --- a/packages/kap-server/src/middleware/securityHeaders.ts +++ b/packages/kap-server/src/middleware/securityHeaders.ts @@ -9,8 +9,9 @@ * Headers: * - `X-Content-Type-Options: nosniff` — stop MIME sniffing. * - `Referrer-Policy: no-referrer` — never leak the URL to third parties. - * - `Content-Security-Policy: default-src 'self'` — the bundled Web UI is - * same-origin, so `'self'` covers it; tighten later if needed. + * - `Content-Security-Policy` — the bundled Web UI is same-origin. Images + * additionally allow `data:` for persisted base64 content and `blob:` for + * local attachment previews and authenticated media responses. * - `Strict-Transport-Security` — ONLY when `opts.tls === true`. In this * phase TLS is terminated by a reverse proxy (Caddy/nginx), so `start.ts` * passes `tls: false` and HSTS is omitted here; the proxy is responsible @@ -25,6 +26,7 @@ export interface SecurityHeadersOptions { } const HSTS_VALUE = 'max-age=31536000'; +const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data: blob:"; /** * Build the `onSend` hook. Returns the payload unchanged so Fastify continues @@ -36,7 +38,7 @@ export function createSecurityHeadersHook( return async (_req, reply, payload) => { reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Referrer-Policy', 'no-referrer'); - reply.header('Content-Security-Policy', "default-src 'self'"); + reply.header('Content-Security-Policy', CONTENT_SECURITY_POLICY); if (opts.tls === true) { reply.header('Strict-Transport-Security', HSTS_VALUE); } diff --git a/packages/kap-server/test/hostExposure.e2e.test.ts b/packages/kap-server/test/hostExposure.e2e.test.ts index 7e62ebf7ea..da03e9a78a 100644 --- a/packages/kap-server/test/hostExposure.e2e.test.ts +++ b/packages/kap-server/test/hostExposure.e2e.test.ts @@ -115,7 +115,9 @@ describe('real password path (verifyPassword)', () => { }); expect(res.status).toBe(200); expect(res.headers.get('x-content-type-options')).toBe('nosniff'); - expect(res.headers.get('content-security-policy')).toBe("default-src 'self'"); + expect(res.headers.get('content-security-policy')).toBe( + "default-src 'self'; img-src 'self' data: blob:", + ); }); it('accepts the persistent token on a public bind', async () => { diff --git a/packages/kap-server/test/securityExposure.test.ts b/packages/kap-server/test/securityExposure.test.ts index 169b6fd382..4d4fdc06c2 100644 --- a/packages/kap-server/test/securityExposure.test.ts +++ b/packages/kap-server/test/securityExposure.test.ts @@ -72,7 +72,9 @@ describe('server-v2 exposure hardening hooks', () => { expect(res.statusCode).toBe(200); expect(res.headers['x-content-type-options']).toBe('nosniff'); expect(res.headers['referrer-policy']).toBe('no-referrer'); - expect(res.headers['content-security-policy']).toBe("default-src 'self'"); + expect(res.headers['content-security-policy']).toBe( + "default-src 'self'; img-src 'self' data: blob:", + ); expect(res.headers['strict-transport-security']).toBeUndefined(); });