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
36 changes: 22 additions & 14 deletions extensions/git-id-switcher/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,32 +645,40 @@ src/

## Webview Content Security Policy

This extension currently does not use Webviews for rendering. If Webviews are added in the future, the following CSP **must** be applied:
The authoritative CSP implementation lives in `src/ui/htmlTemplates/csp.ts` (`buildCspString`). The current policy:

```
```text
default-src 'none';
script-src 'nonce-${nonce}';
base-uri 'none';
form-action 'none';
frame-ancestors 'none';
img-src ${cspSource} https://assets.nullvariant.com https://img.shields.io https://avatars.githubusercontent.com;
style-src 'nonce-${nonce}';
img-src ${webview.cspSource} data:;
font-src ${webview.cspSource};
script-src 'nonce-${nonce}';
connect-src https://assets.nullvariant.com;
font-src ${cspSource};
```

### Policy Rationale

| Directive | Value | Why |
| ------------- | ----------------- | -------------------------------------------------------- |
| `default-src` | `'none'` | Deny everything by default |
| `script-src` | `'nonce-...'` | Only allow scripts with a per-render cryptographic nonce |
| `style-src` | `'nonce-...'` | Only allow styles with a nonce (no `'unsafe-inline'`) |
| `img-src` | `cspSource data:` | Allow VS Code resource images and inline SVG icons |
| `font-src` | `cspSource` | Allow VS Code bundled fonts only |
| Directive | Value | Why |
| ----------------- | ------------------------------------- | -------------------------------------------------------------------------- |
| `default-src` | `'none'` | Deny everything by default |
| `base-uri` | `'none'` | Not covered by `default-src`; prevents `<base>` injection (CSP3 §6.1) |
| `form-action` | `'none'` | Not covered by `default-src`; prevents form submission to attacker origins |
| `frame-ancestors` | `'none'` | Not covered by `default-src`; prevents clickjacking via iframe embedding |
| `img-src` | `cspSource`, CDN, shields.io, avatars | VS Code resources, CDN assets, README badges, GitHub contributor avatars |
| `style-src` | `'nonce-...'` | Nonce-only; `cspSource` removed to close stylesheet bypass |
| `script-src` | `'nonce-...'` | Only allow scripts with a per-render cryptographic nonce |
| `connect-src` | CDN only | Fetch README/documentation from CDN; no other network access |
| `font-src` | `cspSource` | Allow VS Code bundled fonts only |

### Prohibited Patterns
### CSP Prohibited Patterns

- `'unsafe-inline'` — Enables XSS via injected `<script>` tags
- `'unsafe-eval'` — Enables code injection via `eval()`
- `*` or `https:` in any directive — Allows loading from arbitrary origins
- `connect-src`No network access from Webviews
- Wildcard subdomains (e.g. `*.githubusercontent.com`)Only specific subdomains are allowed

---

Expand Down
2 changes: 1 addition & 1 deletion extensions/git-id-switcher/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export default [
},
},
{
files: ["**/validators/common.ts", "**/core/constants.ts", "**/ui/htmlTemplates/csp.ts"],
files: ["**/validators/common.ts", "**/core/constants.ts", "**/ui/htmlTemplates/csp.ts", "**/ui/htmlTemplates/shell.ts"],
rules: {
"no-restricted-syntax": "off",
"no-magic-numbers": "off",
Expand Down
32 changes: 25 additions & 7 deletions extensions/git-id-switcher/src/test/htmlTemplates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,36 @@ function testBuildCspString(): void {
'CSP should have default-src none'
);

// Should include img-src with cspSource and CDN
// img-src: extract the directive to scope assertions precisely.
const imgSrc = csp.split('; ').find(d => d.startsWith('img-src')) ?? '';
assert.ok(
csp.includes(`img-src ${TEST_CSP_SOURCE}`),
'CSP should include cspSource in img-src'
imgSrc.includes(`img-src ${TEST_CSP_SOURCE}`),
'img-src should include cspSource'
);
assert.ok(
csp.includes('https://assets.nullvariant.com'),
'CSP should include CDN in img-src'
imgSrc.includes('https://assets.nullvariant.com'),
'img-src should include CDN'
);
assert.ok(
csp.includes('https://img.shields.io'),
'CSP should include shields.io in img-src'
imgSrc.includes('https://img.shields.io'),
'img-src should include shields.io'
);
assert.ok(
imgSrc.includes('https://avatars.githubusercontent.com'),
'img-src should include avatars.githubusercontent.com'
);

// img-src absence checks — wildcard *.githubusercontent.com must not
// appear; only the avatars subdomain is needed. raw.githubusercontent.com
// would allow loading arbitrary files from attacker-controlled
// repositories (Issue-00196).
assert.ok(
!imgSrc.includes('*.githubusercontent.com'),
'img-src must not contain wildcard *.githubusercontent.com'
);
assert.ok(
!imgSrc.includes('raw.githubusercontent.com'),
'img-src must not include raw.githubusercontent.com'
);

// style-src must be nonce-only — cspSource removed to close
Expand Down
2 changes: 1 addition & 1 deletion extensions/git-id-switcher/src/ui/documentationInternal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const DOCUMENT_HASHES: Record<string, string> = {
'CODE_OF_CONDUCT.md': 'a0e9cb2e004663cdedef4e1adc0e429417ccfc479e367cbc17b869f62ae759d2',
'CONTRIBUTING.md': 'ed4d1f391ffe04e3031dfc9f16fd8fd5dcd54ba23af3b3202c07adac5ba23da7',
'extensions/git-id-switcher/CHANGELOG.md': 'b11d9b619f23b9e55c31302b9a55f455ade9c58f65ce485b0d6ae4ddeb289e7a',
'extensions/git-id-switcher/docs/ARCHITECTURE.md': 'db6ba2f7809b2c7aa831eda3a4b9bb80521577e4e267c7b6ccad17ffba847548',
'extensions/git-id-switcher/docs/ARCHITECTURE.md': 'd5d879d988054d208739497962a0f937f2f21bdaab51776c4e8363cba99d634c',
'extensions/git-id-switcher/docs/CONTRIBUTING.md': '7d6ad2bc4d8c838790754cb9df848cb65f9fdce7e1a13e5c965b83a3d5b6378c',
'extensions/git-id-switcher/docs/DESIGN_PHILOSOPHY.md': 'f9718b61ac161cb466dbc76845688e7acacf4e5fdc4b8b9553269dba4a094f6b',
'extensions/git-id-switcher/docs/i18n/ain/README.md': 'bd740f8772789dfca74a67339d18ffc8bf1d2501fe4c4091120830410e4b5abd',
Expand Down
31 changes: 7 additions & 24 deletions extensions/git-id-switcher/src/ui/htmlTemplates/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
* Owns the CSP trust boundary for the webview shell:
* - `CspValidationError` (the narrow-able class thrown on format failures)
* - Format patterns for nonce / cspSource / lang
* - `assertValidNonce` / `assertValidLang` / `coerceLang`
* - `assertValidNonce` / `assertValidLang`
* - `buildCspString` (assembles the final `content` attribute value)
*
* `coerceLang` and `STYLE_CLOSE_PATTERN` were moved to `shell.ts` as
* they are shell-rendering concerns, not CSP concerns.
*
* Split from shell.ts in Issue-00243 so shell.ts can shrink to the skeleton
* wrapper alone. All functions are pure and free of VS Code dependencies.
*
Expand Down Expand Up @@ -92,8 +95,8 @@ export function assertValidNonce(nonce: string): void {
* Validate a BCP 47 language tag for interpolation into `<html lang="…">`.
* Exported for defense-in-depth alongside `assertValidNonce`. Callers that
* may legitimately receive an empty locale (e.g. bootstrap before i18n is
* ready) should pass the empty string through `coerceLang` first rather than
* duplicating the fallback logic.
* ready) should coerce the empty string to a safe default first rather than
* duplicating the fallback logic (see `coerceLang` in `shell.ts`).
*
* @throws {CspValidationError} with a static, scrubbed message.
*/
Expand All @@ -103,26 +106,6 @@ export function assertValidLang(lang: string): void {
}
}

/**
* Coerce a possibly-empty locale to a safe default before validation. Kept
* separate from `assertValidLang` so that the validator remains fail-closed
* for *all* callers — only the shell, which owns the rendering contract,
* opts into the fallback.
*/
export function coerceLang(lang: string): string {
return lang === '' ? 'en' : lang;
}

/**
* Detects the `</style` substring (case-insensitive) in a raw-text element
* context. HTML5 raw-text elements (`<style>`, `<script>`) terminate at the
* first occurrence of their own closing tag — any `</style` inside a `<style>`
* block therefore breaks out of the element and re-enters HTML context,
* enabling attribute injection. Used by `buildHtmlShell` to fail-closed on
* `extraStyles` before interpolation.
*/
export const STYLE_CLOSE_PATTERN = /<\/style/i;

/**
* Build Content Security Policy header value
*
Expand Down Expand Up @@ -159,7 +142,7 @@ export function buildCspString(cspSource: string, nonce: string): string {
`form-action 'none'`,
`frame-ancestors 'none'`,
// Allow images from: VSCode, our CDN, shields.io badges, GitHub avatars
`img-src ${cspSource} https://assets.nullvariant.com https://img.shields.io https://*.githubusercontent.com`,
`img-src ${cspSource} https://assets.nullvariant.com https://img.shields.io https://avatars.githubusercontent.com`,
// style-src is nonce-only: dropping cspSource closes the
// `<link rel="stylesheet" href="${cspSource}/…">` bypass.
`style-src 'nonce-${nonce}'`,
Expand Down
22 changes: 20 additions & 2 deletions extensions/git-id-switcher/src/ui/htmlTemplates/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,30 @@ import { escapeHtmlEntities } from '../documentationInternal';
import { getBaseStyles } from './baseStyles';
import {
CspValidationError,
STYLE_CLOSE_PATTERN,
assertValidLang,
assertValidNonce,
buildCspString,
coerceLang,
} from './csp';

/**
* Coerce a possibly-empty locale to a safe default before validation. Kept
* separate from `assertValidLang` so that the validator remains fail-closed
* for *all* callers — only the shell, which owns the rendering contract,
* opts into the fallback.
*/
function coerceLang(lang: string): string {
return lang === '' ? 'en' : lang;
}

/**
* Detects the `</style` substring (case-insensitive) in a raw-text element
* context. HTML5 raw-text elements (`<style>`, `<script>`) terminate at the
* first occurrence of their own closing tag — any `</style` inside a `<style>`
* block therefore breaks out of the element and re-enters HTML context,
* enabling attribute injection. Used by `buildHtmlShell` to fail-closed on
* `extraStyles` before interpolation.
*/
const STYLE_CLOSE_PATTERN = /<\/style/i;
import { type BodyClass, type SanitizedHtml, VALID_BODY_CLASSES } from './types';

/**
Expand Down
Loading