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
2 changes: 1 addition & 1 deletion esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const webviewConfig = {
entryPoints: webviewEntryPoints(),
bundle: true,
minify: production,
sourcemap: !production,
sourcemap: production ? false : 'inline',
platform: 'browser',
// Pin the syntax level: webview assets run in the Electron renderer, not Node, so they must not inherit
// esbuild's `esnext` default.
Expand Down
11 changes: 11 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,15 @@ module.exports = [
...js.configs.recommended.rules,
},
},
{
// Webview client scripts run in the Electron renderer, not the extension host
files: ['src/webview/client/**/*.ts'],
languageOptions: {
globals: {
...globals.browser,
// Injected by VS Code into the webview; not a browser global.
acquireVsCodeApi: 'readonly',
},
},
},
];
3 changes: 2 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ComponentDocumentLinkProvider } from './providers/documentLinkProvider'
import { ComponentBrowserProvider } from './providers/componentBrowserProvider';
import { detectIncludeComponent, Component } from './providers/componentDetector';
import { getComponentCacheManager, ComponentCacheManager } from './services/cache/componentCacheManager';
import { assetRoots } from './webview/webviewHtml';
import { Logger } from './utils/logger';
import { ValidationProvider } from './providers/validationProvider';
import type { CachedComponent } from './types/cache';
Expand Down Expand Up @@ -318,7 +319,7 @@ export function activate(context: vscode.ExtensionContext) {
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: []
localResourceRoots: assetRoots(context.extensionUri)
}
);

Expand Down
11 changes: 5 additions & 6 deletions src/providers/componentBrowserProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { generateComponentText } from './componentBrowserGenerate';
import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit';
import { transformCachedComponentsToGroups } from './componentBrowserTransform';
import { buildVersionLabels, compileTagTemplate, stripTagPrefix } from '../services/component/tagScoping';
import { assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml';
import { assetRoots, assetUri, createNonce, cspMetaTag } from '../webview/webviewHtml';

/**
* Component shape carried through the detach-hover webview's "Open in Detailed View" round trip.
Expand Down Expand Up @@ -92,9 +92,7 @@ export class ComponentBrowserProvider {
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview')
]
localResourceRoots: assetRoots(this.context.extensionUri)
}
);

Expand Down Expand Up @@ -492,7 +490,8 @@ export class ComponentBrowserProvider {
`Component: ${component.name}`,
vscode.ViewColumn.Beside,
{
enableScripts: true
enableScripts: true,
localResourceRoots: assetRoots(this.context.extensionUri)
}
);

Expand Down Expand Up @@ -664,7 +663,7 @@ export class ComponentBrowserProvider {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${cspMetaTag(webview, nonce)}
${cspMetaTag(webview.cspSource, nonce)}
<link rel="stylesheet" href="${styleUri}">
<title>GitLab CI/CD Components</title>
</head>
Expand Down
52 changes: 52 additions & 0 deletions src/webview/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Content-Security-Policy plumbing for webview documents.
*
* `vscode`-free and pure so the unit suite can drive it directly: `cspMetaTag` takes the webview's `cspSource` as a
* string rather than the webview itself, leaving `src/webview/webviewHtml.ts` to hold the parts that need the API.
*/

const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const NONCE_LENGTH = 32;

/**
* Generates a random nonce for the CSP `script-src 'nonce-...'` directive.
*
* The alphabet is 64 characters so that indexing by a random byte (`byte % 64`) draws uniformly — a 62-character
* alphabet would over-represent its first two characters. `crypto.getRandomValues` is available in the extension
* host runtime and is preferred over `Math.random` for a value that gates script execution.
*
* @returns 32 characters drawn from `[A-Za-z0-9-_]`, unique per call.
*/
export function createNonce(): string {
const bytes = new Uint8Array(NONCE_LENGTH);
crypto.getRandomValues(bytes);
let nonce = '';
for (const byte of bytes) {
nonce += NONCE_CHARS[byte % NONCE_CHARS.length];
}
return nonce;
}

/**
* Builds the Content-Security-Policy meta tag for a webview document.
*
* Styles and scripts must come from files under the webview's own origin — no inline `<style>`, no `style=`
* attribute, and scripts only with the supplied nonce. Theme colours reach an external stylesheet as CSS custom
* properties (`var(--vscode-*)`), so nothing here needs inline style capability. A document that inlines a style
* will be blocked, which is the signal to move it into a stylesheet.
*
* @param cspSource The webview's `cspSource`, naming the origin its own assets are served from.
* @param nonce The per-render nonce, as returned by {@link createNonce}.
* @returns A `<meta http-equiv="Content-Security-Policy">` tag, for the document's `<head>`.
*/
export function cspMetaTag(cspSource: string, nonce: string): string {
return [
`<meta http-equiv="Content-Security-Policy" content="`,
`default-src 'none'; `,
`style-src ${cspSource}; `,
`img-src ${cspSource} https: data:; `,
`font-src ${cspSource}; `,
`script-src 'nonce-${nonce}';`,
`">`,
].join('');
}
66 changes: 26 additions & 40 deletions src/webview/webviewHtml.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,51 @@
import * as vscode from 'vscode';

/**
* Shared helpers for rendering webview HTML with a Content-Security-Policy,
* a per-render nonce, and webview-safe asset URIs.
* Webview-safe asset URIs and resource roots.
*
* VS Code webviews cannot load extension files by path: every <link>/<script>
* src must be passed through webview.asWebviewUri, and a CSP restricts which
* origins and inline content are allowed. A nonce permits exactly the scripts
* we emit while still blocking arbitrary inline script injection.
* VS Code webviews cannot load extension files by path: every `<link>`/`<script>` src must be passed through
* `webview.asWebviewUri`, and the panel must declare the roots it may load from. The CSP and nonce helpers live in
* `./csp` so they stay `vscode`-free and unit-testable; they are re-exported here so callers have one import.
*/

const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const NONCE_LENGTH = 32;
export { createNonce, cspMetaTag } from './csp';

/**
* Generates a random nonce for the CSP `script-src 'nonce-...'` directive.
* `crypto.getRandomValues` is available in the extension host runtime and is
* preferred over Math.random for a value that gates script execution.
*/
export function createNonce(): string {
const bytes = new Uint8Array(NONCE_LENGTH);
crypto.getRandomValues(bytes);
let nonce = '';
for (const byte of bytes) {
nonce += NONCE_CHARS[byte % NONCE_CHARS.length];
}
return nonce;
}
/** Directory the webview build emits to, relative to the extension root. */
const ASSET_ROOT = ['out', 'webview'];

/**
* Resolves an asset under the webview output directory to a webview-safe URI.
* `relativePath` is relative to `out/webview` (e.g. 'styles/loading.css').
*
* @param webview The webview the URI is being resolved for.
* @param extensionUri The extension's root URI, from the activation context.
* @param relativePath Path under `out/webview`, e.g. `styles/loading.css`. `.` and `..` segments are rejected so a
* computed path cannot escape the asset root the panel declares.
* @returns A `vscode-webview://` URI the document can load the asset from.
* @throws If `relativePath` is empty or contains a `.` or `..` segment.
*/
export function assetUri(
webview: vscode.Webview,
extensionUri: vscode.Uri,
relativePath: string
): vscode.Uri {
const parts = relativePath.split('/').filter(Boolean);
if (parts.length === 0 || parts.some(part => part === '.' || part === '..')) {
throw new Error(`Invalid webview asset path: ${relativePath}`);
}
return webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'out', 'webview', ...parts)
vscode.Uri.joinPath(extensionUri, ...ASSET_ROOT, ...parts)
);
}

/**
* Builds the Content-Security-Policy meta tag for a webview document.
* The `localResourceRoots` a panel needs to load anything {@link assetUri} resolves.
*
* A webview may only load local files from the roots its panel declares, and an empty array permits nothing. Every
* panel rendering a document with a `<link>` or `<script>` asset must pass this.
*
* Styles and scripts must come from files under the webview's own origin — no inline `<style>`, no `style=`
* attribute, and scripts only with the supplied nonce. Theme colours reach an external stylesheet as CSS custom
* properties (`var(--vscode-*)`), so nothing here needs inline style capability. A document that inlines a style
* will be blocked, which is the signal to move it into a stylesheet.
* @param extensionUri The extension's root URI, from the activation context.
* @returns The roots to pass as a panel's `localResourceRoots`.
*/
export function cspMetaTag(webview: vscode.Webview, nonce: string): string {
const source = webview.cspSource;
return [
`<meta http-equiv="Content-Security-Policy" content="`,
`default-src 'none'; `,
`style-src ${source}; `,
`img-src ${source} https: data:; `,
`font-src ${source}; `,
`script-src 'nonce-${nonce}';`,
`">`,
].join('');
export function assetRoots(extensionUri: vscode.Uri): vscode.Uri[] {
return [vscode.Uri.joinPath(extensionUri, ...ASSET_ROOT)];
}
66 changes: 66 additions & 0 deletions tests/unit/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @mocha
/**
* Tests src/webview/csp.ts — the nonce and Content-Security-Policy meta tag every webview document is rendered with.
*/

import * as assert from 'node:assert/strict';
import { createNonce, cspMetaTag } from '../../src/webview/csp';

const CSP_SOURCE = 'vscode-webview://test-origin';

suite('createNonce', () => {
test('returns 32 characters from the nonce alphabet', () => {
const nonce = createNonce();
assert.equal(nonce.length, 32);
assert.match(nonce, /^[A-Za-z0-9\-_]{32}$/);
});

test('returns a different value on each call', () => {
const nonces = new Set(Array.from({ length: 50 }, () => createNonce()));
assert.equal(nonces.size, 50);
});

test('draws uniformly across the alphabet', () => {
const counts = new Map<string, number>();
for (let i = 0; i < 2000; i++) {
for (const char of createNonce()) {
counts.set(char, (counts.get(char) ?? 0) + 1);
}
}

const frequencies = [...counts.values()];
const expected = (2000 * 32) / 64;
assert.equal(counts.size, 64, 'every character in the alphabet should appear');
assert.ok(
Math.max(...frequencies) < expected * 1.15,
`no character should be over-represented: max ${Math.max(...frequencies)} vs expected ${expected}`,
);
});
});

suite('cspMetaTag', () => {
test('denies everything by default', () => {
assert.match(cspMetaTag(CSP_SOURCE, 'abc123'), /default-src 'none'/);
});

test('admits scripts only with the supplied nonce', () => {
const tag = cspMetaTag(CSP_SOURCE, 'abc123');
assert.match(tag, /script-src 'nonce-abc123'/);
assert.doesNotMatch(tag, /script-src[^;]*'unsafe-inline'/);
});

test('admits styles from the webview origin but not inline', () => {
const tag = cspMetaTag(CSP_SOURCE, 'abc123');
assert.match(tag, new RegExp(`style-src ${CSP_SOURCE}`));
// Inline styles are what the external-stylesheet pattern exists to avoid; permitting them here would silently
// undo it for every document built on this helper.
assert.doesNotMatch(tag, /style-src[^;]*'unsafe-inline'/);
});

test('is a well-formed meta tag carrying the webview origin', () => {
const tag = cspMetaTag(CSP_SOURCE, 'abc123');
assert.ok(tag.startsWith('<meta http-equiv="Content-Security-Policy" content="'));
assert.ok(tag.endsWith('">'));
assert.ok(tag.includes(CSP_SOURCE));
});
});
Loading