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
35 changes: 23 additions & 12 deletions extensions/git-id-switcher/src/test/htmlTemplates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

import * as assert from 'node:assert';
import {
type SanitizedHtml,
buildCspString,
CspValidationError,
getBaseStyles,
Expand All @@ -42,6 +43,16 @@ import {
buildErrorHtml,
} from '../ui/htmlTemplates';

/**
* Test-only brand lift. Production code MUST obtain `SanitizedHtml` exclusively
* through `renderMarkdown` (see documentationInternal.ts), which is the single
* sanctioned origin of the brand. Tests bypass the sanitizer because they
* exercise `buildDocumentHtml`'s structural contract (shell, nav, escaping),
* not the sanitizer's correctness — `renderMarkdown` has its own dedicated
* test suite in documentation.test.ts.
*/
const asSanitizedHtml = (s: string): SanitizedHtml => s as SanitizedHtml;

// ============================================================================
// Test Constants
// ============================================================================
Expand Down Expand Up @@ -342,7 +353,7 @@ function testAllTemplatesCssQuality(): void {

const templates: ReadonlyArray<readonly [string, () => string]> = [
['document', (): string => buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)],
['loading', (): string => buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE)],
['error', (): string => buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE)],
Expand All @@ -362,7 +373,7 @@ function testAllTemplatesCssQuality(): void {

// Document template uses both tokens explicitly.
const docHtml = buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
);
assert.ok(
docHtml.includes('border-radius: var(--gis-radius-sm)'),
Expand Down Expand Up @@ -443,7 +454,7 @@ function testShellSkeletonIsShared(): void {

const docShell = extractShell(
buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)
);
const loadingShell = extractShell(buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE));
Expand Down Expand Up @@ -490,7 +501,7 @@ function testBodyClassOverrides(): void {

const cases: ReadonlyArray<readonly [string, string, () => string]> = [
['document', 'gis-doc', (): string => buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)],
['loading', 'gis-loading', (): string => buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE)],
['error', 'gis-error', (): string => buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE)],
Expand All @@ -512,7 +523,7 @@ function testBodyClassOverrides(): void {
// Document & error templates must scope their overrides via the class
// selector. Loading has no body override, only the base rule.
const docHtml = buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
);
assert.match(
docHtml, /body\.gis-doc\s*\{/,
Expand Down Expand Up @@ -567,7 +578,7 @@ function testDesignTokenCoverage(): void {
const literalPattern = /1px solid var\(--vscode-panel-border\)/g;
const allTemplates: ReadonlyArray<readonly [string, string]> = [
['document', buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
)],
['loading', buildLoadingHtml(TEST_CSP_SOURCE, TEST_NONCE)],
['error', buildErrorHtml(TEST_CSP_SOURCE, 'network', TEST_NONCE)],
Expand Down Expand Up @@ -603,7 +614,7 @@ function testBuildDocumentHtmlStructure(): void {

const html = buildDocumentHtml(
TEST_CSP_SOURCE,
'<p>Test content</p>',
asSanitizedHtml('<p>Test content</p>'),
'en',
'docs/README.md',
TEST_NONCE,
Expand Down Expand Up @@ -650,7 +661,7 @@ function testBuildDocumentHtmlNavigation(): void {

// nav element with aria-label (semantic HTML)
const htmlWithBack = buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'ja', 'docs/test.md', TEST_NONCE, true
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'ja', 'docs/test.md', TEST_NONCE, true
);
assert.ok(
htmlWithBack.includes('<nav class="nav-bar" aria-label="Document navigation">'),
Expand All @@ -665,7 +676,7 @@ function testBuildDocumentHtmlNavigation(): void {

// Back button disabled when canGoBack=false
const htmlNoBack = buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/test.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/test.md', TEST_NONCE, false
);
assert.ok(
htmlNoBack.includes('<button id="back-btn" disabled>'),
Expand Down Expand Up @@ -696,7 +707,7 @@ function testBuildDocumentHtmlContentEscaping(): void {
// Locale with XSS payload should be escaped
const html = buildDocumentHtml(
TEST_CSP_SOURCE,
'<p>Safe content</p>',
asSanitizedHtml('<p>Safe content</p>'),
'"><script>alert(1)</script>',
'docs/<script>alert(2)</script>.md',
TEST_NONCE,
Expand Down Expand Up @@ -851,15 +862,15 @@ function testLangAttributes(): void {

// Document HTML uses provided locale
const docHtml = buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'ja', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'ja', 'docs/README.md', TEST_NONCE, false
);
assert.ok(
docHtml.includes('<html lang="ja">'),
'Document HTML should use provided locale as lang'
);

const docHtmlEn = buildDocumentHtml(
TEST_CSP_SOURCE, '<p>Content</p>', 'en', 'docs/README.md', TEST_NONCE, false
TEST_CSP_SOURCE, asSanitizedHtml('<p>Content</p>'), 'en', 'docs/README.md', TEST_NONCE, false
);
assert.ok(
docHtmlEn.includes('<html lang="en">'),
Expand Down
16 changes: 13 additions & 3 deletions extensions/git-id-switcher/src/ui/documentationInternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
* allowing them to be tested in isolation without VS Code environment.
*/

// Type-only import: erased at runtime. htmlTemplates.ts has a runtime
// import of `escapeHtmlEntities` from this file, so promoting this to a
// value import would create a real circular dependency. Keep `import type`.
import type { SanitizedHtml } from './htmlTemplates';

// ============================================================================
// Constants
// ============================================================================
Expand Down Expand Up @@ -320,9 +325,12 @@ export function getDocumentDisplayName(path: string): string {
* We preserve HTML while converting Markdown-only sections.
*
* @param raw - Raw Markdown/HTML content
* @returns Safe HTML string
* @returns Safe HTML string, branded as `SanitizedHtml` so downstream
* consumers (e.g. `buildDocumentHtml`) can enforce the trust boundary at
* compile time. The single `as SanitizedHtml` cast at the return site is
* the only sanctioned origin of the brand in the codebase.
*/
export function renderMarkdown(raw: string): string {
export function renderMarkdown(raw: string): SanitizedHtml {
// Content is trusted (self-managed CDN with hash verification)
let html = raw;

Expand Down Expand Up @@ -445,7 +453,9 @@ export function renderMarkdown(raw: string): string {
html = html.replaceAll(new RegExp(String.raw`<p>\s*(</(?:${blockElements})>)`, 'g'), '$1');
html = html.replaceAll(new RegExp(String.raw`(<(?:${blockElements})[^>]*>)\s*</p>`, 'g'), '$1');

return html;
// Single sanctioned origin of the SanitizedHtml brand. All other callers
// MUST go through this function; do not introduce additional casts.
return html as SanitizedHtml;
}

// ============================================================================
Expand Down
30 changes: 25 additions & 5 deletions extensions/git-id-switcher/src/ui/htmlTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ import { escapeHtmlEntities } from './documentationInternal';
/** Error types for documentation display */
export type ErrorType = 'network' | 'notfound' | 'server';

/**
* Branded string type marking HTML that has already passed through a strict
* allowlist sanitizer (see `renderMarkdown`). The brand exists purely at the
* type level — there is no runtime tag — and acts as a trust-boundary marker:
*
* - Only the sanitizer is allowed to produce a value of this type (via a
* single `as SanitizedHtml` cast at its return site).
* - `buildDocumentHtml` accepts only `SanitizedHtml` for its verbatim-injected
* `content` parameter, so a plain `string` (raw markdown, untrusted input,
* a forgotten sanitizer call) fails at compile time instead of reaching the
* webview and relying on CSP as the sole defence.
*
* Why not a class/wrapper object? The value flows through HTML string
* concatenation inside the template, so we need structural `string` identity;
* a branded alias gives us the type check with zero runtime cost.
*/
export type SanitizedHtml = string & { readonly __brand: 'SanitizedHtml' };

// ============================================================================
// CSP Configuration
// ============================================================================
Expand Down Expand Up @@ -229,11 +247,13 @@ ${opts.body}
* Generate the document HTML with content
*
* @param cspSource - Webview CSP source
* @param content - Pre-sanitized HTML content. Caller MUST pass HTML that
* has already been run through a strict allowlist sanitizer (e.g. a
* markdown renderer with HTML escaping). This function injects `content`
* @param content - Pre-sanitized HTML content, typed as `SanitizedHtml` so
* the compiler forbids passing a raw `string`. The brand can only originate
* at the sanitizer boundary (`renderMarkdown`), making the trust boundary
* structurally impossible to bypass. This function injects `content`
* verbatim; CSP blocks `<script>` execution but does NOT stop `<img
* onerror>` or `javascript:` URLs outside the linkInterceptScript path.
* onerror>` or `javascript:` URLs outside the linkInterceptScript path,
* which is exactly why we want the type-level guarantee on top of CSP.
* @param locale - Current locale
* @param currentPath - Current document path (for relative link resolution)
* @param nonce - CSP nonce for inline styles and scripts
Expand All @@ -242,7 +262,7 @@ ${opts.body}
*/
export function buildDocumentHtml(
cspSource: string,
content: string,
content: SanitizedHtml,
locale: string,
currentPath: string,
nonce: string,
Expand Down
5 changes: 3 additions & 2 deletions extensions/git-id-switcher/src/ui/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import * as crypto from 'node:crypto';

import {
type ErrorType,
type SanitizedHtml,
buildCspString,
buildDocumentHtml,
buildLoadingHtml,
buildErrorHtml,
} from './htmlTemplates';
import { renderWithFallback } from './webviewFallback';

export type { ErrorType } from './htmlTemplates';
export type { ErrorType, SanitizedHtml } from './htmlTemplates';

// ============================================================================
// Nonce Generation
Expand Down Expand Up @@ -69,7 +70,7 @@ export function buildCsp(webview: vscode.Webview, nonce: string): string {
*/
export function getDocumentHtml(
webview: vscode.Webview,
content: string,
content: SanitizedHtml,
locale: string,
currentPath: string,
nonce: string,
Expand Down
Loading