Skip to content

Commit dc91502

Browse files
committed
fix(chat): give the hand-off card a CSP and a nonced script
Review, and correct: detachedHtml() served a webview document with an inline <script> and no Content-Security-Policy, while getHtml() and getSessionsHtml() both inject CSP + nonce. Small is not exempt — the card enables scripts and carries one, so it was the single document in the extension whose script surface was undescribed. Worse than untidy: a later tightening elsewhere would have silently stopped its button from working, and "Bring it back" is the only way out of the detached state short of closing the tab. Rather than a third copy of the policy, the construction moves into webviewCsp() and all three documents call it. It had already been duplicated twice; a third would have made "tighten the CSP" a three-file change with one easy to miss. Verified by rendering the card and reading it back: CSP default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-…' script nonce matches the policy no remote origins allowed Two guards, verified non-vacuous four ways — drop the meta (12/15), unnonce the script (12/15), hand-roll a third copy of the policy (13/15), bypass the helper in getHtml (13/15). One of them asserts the policy string exists exactly ONCE in the file, so the next document cannot quietly hand-roll its own. 33 suites green.
1 parent 599e041 commit dc91502

2 files changed

Lines changed: 59 additions & 11 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,10 +2287,18 @@ function replayLiveTranscript(tag) {
22872287
post({ type: 'sessionResumed', id, title: entry.title || 'Session', note: '', tag, icon: 'layout', turns });
22882288
}
22892289

2290-
/** The sidebar slot while the chat is an editor tab. Deliberately tiny — it is a signpost, not a UI. */
2290+
/**
2291+
* The sidebar slot while the chat is an editor tab. Deliberately tiny — it is a signpost, not a UI.
2292+
*
2293+
* Small does not mean exempt: it enables scripts and carries an inline one, so it gets the same
2294+
* CSP + nonce as the chat and sessions documents. Anything less and this would be the one webview
2295+
* whose script surface is undescribed.
2296+
*/
22912297
function detachedHtml() {
2298+
const { nonce, csp } = webviewCsp();
22922299
const bg = 'var(--vscode-sideBar-background)', fg = 'var(--vscode-foreground)';
22932300
return '<!DOCTYPE html><html><head><meta charset="utf-8">'
2301+
+ '<meta http-equiv="Content-Security-Policy" content="' + csp + '">'
22942302
+ '<style>'
22952303
+ 'body{margin:0;padding:28px 22px;background:' + bg + ';color:' + fg + ';'
22962304
+ 'font-family:var(--vscode-font-family);font-size:var(--vscode-font-size);text-align:center}'
@@ -2303,17 +2311,30 @@ function detachedHtml() {
23032311
+ '<div class="t">Chat is open in the editor</div>'
23042312
+ '<div class="s">The conversation moved to a tab so it has room. Closing that tab brings it back here.</div>'
23052313
+ '<button id="b">Bring it back</button>'
2306-
+ '<script>const v=acquireVsCodeApi();document.getElementById("b").onclick=()=>v.postMessage({type:"reattach"});</script>'
2314+
+ '<script nonce="' + nonce + '">const v=acquireVsCodeApi();document.getElementById("b").onclick=()=>v.postMessage({type:"reattach"});</script>'
23072315
+ '</body></html>';
23082316
}
23092317

2310-
function getHtml() {
2318+
/**
2319+
* A webview Content-Security-Policy and the nonce it authorises.
2320+
*
2321+
* Every document this extension serves goes through here, so the script surface is described in ONE
2322+
* place: no remote anything (`default-src 'none'`), inline styles allowed because the documents are
2323+
* self-contained, and inline script allowed ONLY for the exact nonce minted per render. A document
2324+
* that forgets this is not merely inconsistent — a later CSP tightening elsewhere would silently
2325+
* stop its script from running.
2326+
*/
2327+
function webviewCsp() {
23112328
const nonce = String(Math.random()).slice(2) + String(Date.now());
2312-
const csp = [
2329+
return { nonce, csp: [
23132330
"default-src 'none'",
23142331
"style-src 'unsafe-inline'",
23152332
"script-src 'nonce-" + nonce + "'"
2316-
].join('; ');
2333+
].join('; ') };
2334+
}
2335+
2336+
function getHtml() {
2337+
const { nonce, csp } = webviewCsp();
23172338
const html = fs.readFileSync(path.join(ctx.extensionPath, 'media', 'chat.html'), 'utf8');
23182339
return html.replace(/__CSP__/g, csp).replace(/__NONCE__/g, nonce);
23192340
}
@@ -2346,12 +2367,7 @@ class SessionsViewProvider {
23462367
}
23472368

23482369
function getSessionsHtml() {
2349-
const nonce = String(Math.random()).slice(2) + String(Date.now());
2350-
const csp = [
2351-
"default-src 'none'",
2352-
"style-src 'unsafe-inline'",
2353-
"script-src 'nonce-" + nonce + "'"
2354-
].join('; ');
2370+
const { nonce, csp } = webviewCsp();
23552371
const html = fs.readFileSync(path.join(ctx.extensionPath, 'media', 'sessionsView.html'), 'utf8');
23562372
return html.replace(/__CSP__/g, csp).replace(/__NONCE__/g, nonce);
23572373
}

extensions/levelcode-ai/test/chatSurface.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,38 @@ test('COMMAND: it is registered and discoverable in the palette', () => {
170170
assert.match(cmd.title, /Chat in Editor/);
171171
});
172172

173+
// ---- 6. Every webview document describes its own script surface --------------------------------
174+
175+
test('CSP: the hand-off card carries a policy and a nonced script, like the other documents', () => {
176+
// It shipped without one. Small is not exempt: the card enables scripts and carries an inline one,
177+
// so without a CSP it was the single webview in the extension whose script surface was undescribed
178+
// — and a later tightening elsewhere would have silently stopped its button from working.
179+
const card = fnBody(ext, 'detachedHtml');
180+
assert.match(card, /Content-Security-Policy/, 'no CSP meta — the card is unlike every other document here');
181+
assert.match(card, /<script nonce="' \+ nonce \+ '"/,
182+
'the inline script is not nonced, so the policy above would block it');
183+
assert.match(card, /const \{ nonce, csp \} = webviewCsp\(\)/,
184+
'it must use the shared helper, not hand-roll a third copy of the policy');
185+
});
186+
187+
test('CSP: one helper describes the policy for every document the extension serves', () => {
188+
// The construction was duplicated in getHtml and getSessionsHtml before this; a third copy would
189+
// have made "tighten the CSP" a three-file change with one of them easy to miss.
190+
const helper = fnBody(ext, 'webviewCsp');
191+
assert.match(helper, /default-src 'none'/, 'no remote origins');
192+
assert.match(helper, /script-src 'nonce-/, 'inline script is nonce-gated');
193+
assert.ok(!/style-src[^;]*http/.test(helper), 'no remote stylesheets');
194+
195+
// Nobody may build the policy by hand any more.
196+
const handRolled = (ext.match(/"default-src 'none'"/g) || []).length;
197+
assert.strictEqual(handRolled, 1, 'the policy string appears ' + handRolled + ' times — it should exist only inside webviewCsp()');
198+
199+
// And every document generator goes through it.
200+
for (const fn of ['getHtml', 'getSessionsHtml', 'detachedHtml']) {
201+
assert.match(fnBody(ext, fn), /webviewCsp\(\)/, fn + '() does not use the shared policy');
202+
}
203+
});
204+
173205
test('COMMAND: it has a BUTTON on the chat header, not only the palette', () => {
174206
// A feature whose whole point is "I did not know I could do that" is not served by a
175207
// palette-only entry — nobody searches for a capability they do not know exists. This shipped

0 commit comments

Comments
 (0)