Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-math-spoons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix LaTeX formulas rendering as garbled overlapping text when the web UI is accessed over the network; the server's content security policy now allows the inline styles that math and code highlighting rely on, while scripts remain strictly restricted.
8 changes: 7 additions & 1 deletion packages/kap-server/src/middleware/securityHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
* Invariant: the served bundle must contain no inline scripts (guarded
* by a kimi-web test), so plain `script-src` falling back to
* `default-src 'self'` suffices.
* `style-src` needs 'unsafe-inline': KaTeX math and Shiki highlighting
* are rendered off-thread and injected via innerHTML with per-glyph
* `style="…"` attributes (KaTeX carries ALL vertical/font sizing in
* them — stripping collapses formulas into overlapping glyphs), and
* Mermaid embeds an inline <style> in its SVG. Only styles are
* relaxed; script-src stays strict.
* - `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
Expand All @@ -35,7 +41,7 @@ export interface SecurityHeadersOptions {

const HSTS_VALUE = 'max-age=31536000';
const CONTENT_SECURITY_POLICY =
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'";
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'";

/**
* Build the `onSend` hook. Returns the payload unchanged so Fastify continues
Expand Down
2 changes: 1 addition & 1 deletion packages/kap-server/test/hostExposure.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ 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'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'",
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'",
);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/kap-server/test/securityExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('server-v2 exposure hardening hooks', () => {
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'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'",
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'",
);
expect(res.headers['strict-transport-security']).toBeUndefined();
});
Expand Down
74 changes: 74 additions & 0 deletions packages/kap-server/test/securityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';

import { createSecurityHeadersHook } from '../src/middleware/securityHeaders';

function captureHeaders() {
const headers = new Map<string, string>();
const reply = {
header(name: string, value: string) {
headers.set(name.toLowerCase(), value);
return reply;
},
};
return { headers, reply };
}

/** Split a CSP header value into directive → source-list tokens. */
function parseCsp(csp: string): Map<string, string[]> {
const directives = new Map<string, string[]>();
for (const part of csp.split(';')) {
const tokens = part.trim().split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) continue;
directives.set(tokens[0] as string, tokens.slice(1));
}
return directives;
}

describe('createSecurityHeadersHook', () => {
it('stamps the defensive headers and returns the payload unchanged', async () => {
const { headers, reply } = captureHeaders();
const hook = createSecurityHeadersHook({ tls: false });
const payload = { ok: true };
const result = await hook({} as never, reply as never, payload);
expect(result).toBe(payload);
expect(headers.get('x-content-type-options')).toBe('nosniff');
expect(headers.get('referrer-policy')).toBe('no-referrer');
expect(headers.get('content-security-policy')).toBeDefined();
});

// KaTeX math and Shiki highlighting are injected via innerHTML with
// per-glyph `style="…"` attributes (KaTeX carries ALL vertical/font sizing
// in them — stripping collapses formulas into overlapping glyphs), and
// Mermaid embeds an inline <style> in its SVG. style-src must therefore
// allow inline styles; script-src must stay strict.
it('allows inline styles while keeping inline scripts forbidden', async () => {
const { headers, reply } = captureHeaders();
const hook = createSecurityHeadersHook({ tls: false });
await hook({} as never, reply as never, 'payload');
const csp = headers.get('content-security-policy');
expect(csp).toBeDefined();
const directives = parseCsp(csp ?? '');
const styleSrc = directives.get('style-src');
expect(styleSrc).toContain("'self'");
expect(styleSrc).toContain("'unsafe-inline'");
// Assert the EFFECTIVE script policy — script-src, falling back to
// default-src when absent — rather than matching an exact substring, so
// regressions like default-src gaining 'unsafe-inline' (which would
// allow inline <script> through the fallback) also fail here.
const effectiveScriptSrc = directives.get('script-src') ?? directives.get('default-src');
expect(effectiveScriptSrc).toBeDefined();
expect(effectiveScriptSrc).not.toContain("'unsafe-inline'");
expect(effectiveScriptSrc).not.toContain("'unsafe-eval'");
expect(effectiveScriptSrc).not.toContain('data:');
});

it('emits HSTS only when TLS is terminated at the server', async () => {
const plain = captureHeaders();
await createSecurityHeadersHook({ tls: false })({} as never, plain.reply as never, '');
expect(plain.headers.has('strict-transport-security')).toBe(false);

const tls = captureHeaders();
await createSecurityHeadersHook({ tls: true })({} as never, tls.reply as never, '');
expect(tls.headers.get('strict-transport-security')).toBe('max-age=31536000');
});
});
Loading