fix(wiki): tighten CSP with nonce and stricter LLM error matching

Address PR review feedback: replace 'unsafe-inline' script-src with a
per-build nonce, and parse error JSON before matching max_tokens
rejection to avoid false positives.
This commit is contained in:
Jobin Kurian 2026-04-01 09:23:05 +05:30
parent 505228b851
commit c2e9190405
3 changed files with 43 additions and 19 deletions

View file

@ -5,6 +5,7 @@
* module tree, and metadata — viewable offline in any browser.
*/
import { randomBytes } from 'crypto';
import fs from 'fs/promises';
import path from 'path';
@ -90,6 +91,8 @@ function buildHTML(
const treeJSON = safeJSON(moduleTree);
const metaJSON = safeJSON(meta);
const nonce = randomBytes(16).toString('base64');
const parts: string[] = [];
// ── Head ──
@ -98,11 +101,11 @@ function buildHTML(
parts.push('<head>');
parts.push('<meta charset="UTF-8">');
parts.push('<meta name="viewport" content="width=device-width, initial-scale=1.0">');
parts.push('<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; script-src \'unsafe-inline\' \'unsafe-eval\' https://cdn.jsdelivr.net; style-src \'unsafe-inline\'; img-src data: https:;">');
parts.push(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'unsafe-inline'; img-src data: https:;">`);
parts.push('<title>' + esc(projectName) + ' — Wiki</title>');
parts.push('<script src="https://cdn.jsdelivr.net/npm/marked@11.0.0/marked.min.js"><\/script>');
parts.push(`<script nonce="${nonce}" src="https://cdn.jsdelivr.net/npm/marked@11.0.0/marked.min.js"><\/script>`);
parts.push(
'<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"><\/script>',
`<script nonce="${nonce}" src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"><\/script>`,
);
parts.push('<style>');
parts.push(CSS);
@ -136,7 +139,7 @@ function buildHTML(
parts.push('</div>');
// ── Script ──
parts.push('<script>');
parts.push(`<script nonce="${nonce}">`);
parts.push('var PAGES = ' + pagesJSON + ';');
parts.push('var TREE = ' + treeJSON + ';');
parts.push('var META = ' + metaJSON + ';');

View file

@ -211,11 +211,16 @@ export async function callLLM(
continue;
}
// Auto-switch max_tokens → max_completion_tokens when the model rejects max_tokens
// Auto-switch max_tokens -> max_completion_tokens when the model rejects it
let tokenErrMsg = errorText;
try {
const parsed = JSON.parse(errorText);
if (parsed?.error?.message) tokenErrMsg = parsed.error.message;
} catch { /* use raw errorText */ }
if (
!switchedTokenParam &&
response.status === 400 &&
(errorText.includes("'max_tokens'") || errorText.includes('"max_tokens"')) &&
/['"]max_tokens['"]/.test(tokenErrMsg) &&
body.max_tokens !== undefined
) {
console.warn(

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
@ -54,17 +54,33 @@ describe('safeJSON', () => {
});
describe('generateHTMLViewer — CSP meta tag', () => {
it('includes a Content-Security-Policy meta tag in generated HTML', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-csp-'));
try {
await fs.writeFile(path.join(tmpDir, 'overview.md'), '# Hello');
await fs.writeFile(path.join(tmpDir, 'module_tree.json'), '[]');
const outputPath = await generateHTMLViewer(tmpDir, 'TestProject');
const html = await fs.readFile(outputPath, 'utf-8');
expect(html).toContain('Content-Security-Policy');
expect(html).toContain("'unsafe-eval'");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
let tmpDir: string;
let html: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-csp-'));
await fs.writeFile(path.join(tmpDir, 'overview.md'), '# Hello');
await fs.writeFile(path.join(tmpDir, 'module_tree.json'), '[]');
const outputPath = await generateHTMLViewer(tmpDir, 'TestProject');
html = await fs.readFile(outputPath, 'utf-8');
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('includes a nonce-based Content-Security-Policy (no unsafe-inline for scripts)', () => {
expect(html).toContain('Content-Security-Policy');
expect(html).toContain("'unsafe-eval'");
expect(html).toMatch(/script-src 'nonce-[A-Za-z0-9+/=]+'/);;
expect(html).not.toMatch(/script-src[^;]*'unsafe-inline'/);
});
it('applies the nonce attribute to every <script> tag', () => {
const scriptTags = html.match(/<script[\s>]/g) ?? [];
expect(scriptTags.length).toBeGreaterThanOrEqual(3);
const noncedScripts = html.match(/<script\s+nonce="[A-Za-z0-9+/=]+"[\s>]/g) ?? [];
expect(noncedScripts.length).toBe(scriptTags.length);
});
});