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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-08-26
skip_specs: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Why

The user reported "No timeline data" in every timeline webview (both
the single-change command from `2026-08-26-add-change-timeline-view`
and the comparison command from
`2026-08-26-add-multi-change-timeline-view`) after installing the real
extension. Root cause: `TimelineWebviewPanel`'s CSP
(`script-src ${webview.cspSource}`) does not include `'unsafe-inline'`
or a nonce, so the browser silently blocks the inline
`<script>window.__OPENSPEC_UI_TIMELINE__ = ...;</script>` tag used to
embed the already-fetched data — the external `<script src=...>` tag
loading the bundle still matches `script-src` and runs fine, so the
page renders (React mounts, the "No timeline data." fallback shows),
masking the failure instead of erroring visibly. Every smoke test
performed while building both prior changes loaded the built bundle in
a bare Playwright page with no CSP at all, which never exercised this
code path — the exact gap this change's own verification specifically
targets.

## What Changes

- Add a random nonce per `getHtml()` call
(`randomBytes(16).toString("base64")`), included in the CSP as
`script-src ${webview.cspSource} 'nonce-${nonce}'` and on the inline
data-injection `<script nonce="${nonce}">` tag. A nonce, not a
blanket `'unsafe-inline'`, authorizes only this one inline script —
keeping CSP's protection against injected scripts intact everywhere
else on the page, per the [Content Security Policy nonce
pattern](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#unsafe_inline_script)
VS Code's own webview guidance recommends for exactly this scenario.
- Add `packages/extension/src/webview/timeline-panel.test.ts`: asserts
the nonce appears in both the CSP and the inline script tag, that
`'unsafe-inline'` is never used for `script-src`, that each panel
gets a distinct nonce, and that the existing `</script>`-injection
escaping still holds.
- No spec text change: `openspec/specs/vscode-extension/spec.md`'s
existing Requirements (added by the two prior changes) already
describe the intended behavior correctly — the code just failed to
satisfy it. `.openspec.yaml` sets `skip_specs: true`.

## Capabilities

### Modified Capabilities

(none in the specified-behavior sense — restores already-specified
behavior; `.openspec.yaml` sets `skip_specs: true`)

## Impact

- `packages/extension/src/webview/timeline-panel.ts`
- `packages/extension/src/webview/timeline-panel.test.ts` (new)
- `.changeset/*.md` (new changeset file)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## 1. Fix

- [x] 1.1 Add a per-panel nonce in `timeline-panel.ts`'s `getHtml()`,
included in the CSP's `script-src` and on the inline data-injection
`<script>` tag.

## 2. Verification

- [x] 2.1 Reproduced the exact bug and confirmed the fix in a
Playwright test loading real HTML with the actual CSP meta tag
applied (not a bare, unrestricted page, unlike prior smoke tests):
without a nonce, the inline script is blocked and no console error
is visible (matching the user's silent "No timeline data" report);
with the matching nonce, zero CSP violations and real data renders.
- [x] 2.2 Add `timeline-panel.test.ts` covering the nonce/CSP
relationship as a lasting regression test, not just an ad hoc script.
- [x] 2.3 `npm run typecheck` and `npm run test` pass for
`openspec-ui-vscode`; `npm run lint` (including `lint:english`)
passes workspace-wide.
- [x] 2.4 Rebuild the VSIX (`npm run package --workspace
openspec-ui-vscode`) and confirm it packages without error.
- [x] 2.5 Propose a changeset (`npx changeset`) for `openspec-ui-vscode`
(patch: bug fix, no new capability) instead of hand-editing
`version`/`CHANGELOG.md`; apply it via `npx changeset version`.
- [x] 2.6 Run `openspec change validate --strict
fix-timeline-webview-csp-inline-script`.
9 changes: 9 additions & 0 deletions packages/extension/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.24.1

### Patch Changes

- Fix the change timeline webview showing "No timeline data" for every
user: its CSP blocked the inline script that embeds the fetched data
(the bundle's own external script tag still loaded, masking the
failure instead of erroring). Fixed via a per-panel CSP nonce.

## 0.24.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"displayName": "OpenSpec Workbench",
"description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.",
"publisher": "openspec-ui",
"version": "0.24.0",
"version": "0.24.1",
"icon": "media/icon.png",
"license": "MIT",
"repository": {
Expand Down
90 changes: 90 additions & 0 deletions packages/extension/src/webview/timeline-panel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import { createVscodeMock } from "../test-utils/vscode-mock.js";

const vscodeMock = createVscodeMock();
vi.mock("vscode", () => vscodeMock);

const { TimelineWebviewPanel } = await import("./timeline-panel.js");

function createPanelFixture() {
const webview = {
cspSource: "vscode-webview:",
html: "",
asWebviewUri: vi.fn((uri: { toString(): string }) => uri),
};
const panel = { webview };
vscodeMock.window.createWebviewPanel.mockReturnValue(panel);
return panel;
}

function createTimelinePanel() {
return new TimelineWebviewPanel({ extensionUri: vscodeMock.Uri.file("/extension") as never });
}

/** Extracts the `nonce="..."` attribute from the first inline `<script>`
* tag that has one (the data-injection script, not the external
* `<script src=...>` tag, which has no nonce attribute). */
function extractInlineScriptNonce(html: string): string | undefined {
return html.match(/<script nonce="([^"]+)">window\./)?.[1];
}

describe("TimelineWebviewPanel", () => {
it("embeds the change timeline behind a CSP nonce, not 'unsafe-inline'", () => {
const panel = createPanelFixture();
const timelinePanel = createTimelinePanel();

timelinePanel.show("my-change", { changeName: "my-change" } as never);

// The inline data-injection script must carry a nonce that also
// appears in the CSP's script-src — without it, a real VS Code
// webview's CSP silently blocks the inline script (no console
// error visible to the user), leaving window.__OPENSPEC_UI_TIMELINE__
// unset and the page rendering "No timeline data." forever. A
// blanket 'unsafe-inline' would also fix this, but weakens CSP for
// every other inline script on the page — a nonce scopes the
// exception to only this one script.
const nonce = extractInlineScriptNonce(panel.webview.html);
expect(nonce).toBeTruthy();
expect(panel.webview.html).toContain(`script-src vscode-webview: 'nonce-${nonce}'`);
expect(panel.webview.html).not.toContain("script-src vscode-webview: 'unsafe-inline'");
expect(panel.webview.html).toContain('window.__OPENSPEC_UI_TIMELINE__ = {"changeName":"my-change"}');
});

it("embeds the multi-change payload behind a CSP nonce", () => {
const panel = createPanelFixture();
const timelinePanel = createTimelinePanel();

timelinePanel.showMulti({ timelines: [], rangeStart: "2026-01-01T00:00:00.000Z", rangeEnd: "2026-01-02T00:00:00.000Z" });

const nonce = extractInlineScriptNonce(panel.webview.html);
expect(nonce).toBeTruthy();
expect(panel.webview.html).toContain(`script-src vscode-webview: 'nonce-${nonce}'`);
expect(panel.webview.html).toContain("window.__OPENSPEC_UI_MULTI_TIMELINE__ =");
});

it("uses a different nonce for each panel", () => {
const panel = createPanelFixture();
const timelinePanel = createTimelinePanel();

timelinePanel.show("first", { changeName: "first" } as never);
const firstHtml = panel.webview.html;
timelinePanel.show("second", { changeName: "second" } as never);
const secondHtml = panel.webview.html;

expect(extractInlineScriptNonce(firstHtml)).not.toBe(extractInlineScriptNonce(secondHtml));
});

it("escapes an embedded </script> sequence so it cannot close the script tag early", () => {
const panel = createPanelFixture();
const timelinePanel = createTimelinePanel();

timelinePanel.show("my-change", { changeName: "my-change", proposal: "</script><script>alert(1)</script>" } as never);

// Escaping every literal `<` to `\u003c` is sufficient on its own —
// the HTML tokenizer's script-end-tag detection requires a real `<`
// character, so the raw substring `</script` (which would end the
// legitimate data-injection script early) must not appear anywhere.
expect(panel.webview.html).not.toContain("</script><script>alert(1)");
expect(panel.webview.html).toContain("\\u003c/script>\\u003cscript>alert(1)\\u003c/script>");
});
});
12 changes: 10 additions & 2 deletions packages/extension/src/webview/timeline-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// `ChangeTimeline` via a direct `@openspec-ui/core` import and embeds it
// in the webview's initial HTML; `timeline-entry.tsx` just renders it.

import { randomBytes } from "node:crypto";
import * as vscode from "vscode";
import type { ChangeTimeline } from "@openspec-ui/core";

Expand Down Expand Up @@ -46,7 +47,14 @@ export class TimelineWebviewPanel {

private getHtml(webview: vscode.Webview, globalName: string, payload: unknown): string {
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.deps.extensionUri, "dist", "timeline.js"));
const csp = `default-src 'none'; script-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline';`;
// A nonce, not a blanket 'unsafe-inline', authorizes only this one
// inline script — CSP's `script-src` otherwise blocks inline
// scripts entirely, which silently dropped the data-injection
// script below (the bundle's own `<script src=...>` tag still
// matched `webview.cspSource` and ran fine, so the page rendered
// its "no timeline data" fallback rather than failing loudly).
const nonce = randomBytes(16).toString("base64");
const csp = `default-src 'none'; script-src ${webview.cspSource} 'nonce-${nonce}'; style-src ${webview.cspSource} 'unsafe-inline';`;
// `<` -> `<` prevents an embedded `</script>` sequence (e.g. inside
// markdown content) from closing the script tag early.
const payloadJson = JSON.stringify(payload).replaceAll("<", "\\u003c");
Expand All @@ -59,7 +67,7 @@ export class TimelineWebviewPanel {
</head>
<body>
<div id="root"></div>
<script>window.${globalName} = ${payloadJson};</script>
<script nonce="${nonce}">window.${globalName} = ${payloadJson};</script>
<script src="${scriptUri.toString()}"></script>
</body>
</html>`;
Expand Down
Loading