Skip to content

Commit d250485

Browse files
nullvariantclaude
andauthored
feat(git-id-switcher): add SHA-256 hash verification for CDN content (v0.13.4) (#177)
* feat(documentation): add SHA-256 hash verification for CDN content Add cryptographic integrity verification for all 32 documentation files fetched from CDN: - DOCUMENT_HASHES constant with SHA-256 hashes for all documents - computeSha256Hash() function using Web Crypto API - verifyContentHash() function with allowlist approach - Integration in fetchDocumentByPath() to reject unknown/modified content Security improvements: - Unknown paths are rejected (allowlist approach) - Hash mismatches are logged and content is rejected - Protects against CDN cache poisoning and MITM attacks 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Model-Raw: claude-opus-4-5-20251101 * test(documentation): add comprehensive unit tests for hash verification Add tests for SHA-256 hash verification functions: - testDocumentHashes: validates DOCUMENT_HASHES constant (32 entries, hex format) - testComputeSha256Hash: known test vectors, determinism, Unicode support - testVerifyContentHash: unknown paths, hash mismatches, edge cases Also add missing tests for classifyUrl (ftp://, file://, ssh:// protocols) Coverage for documentation.internal.ts: 100% 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Model-Raw: claude-opus-4-5-20251101 * refactor(documentation): reduce cognitive complexity in fetchDocumentByPath Extract helper functions to reduce cognitive complexity from 18 to under 15: - logHashFailure(): handles hash verification failure logging - isContentSizeValid(): validates content size against limits Flattened nested if-statements with early returns for better readability. 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Model-Raw: claude-opus-4-5-20251101 * refactor(documentation): move helper functions to internal module Move logHashFailure and isContentSizeValid from documentation.ts to documentation.internal.ts for unit testability. Changes: - Extract helpers to documentation.internal.ts with full test coverage - Add tests for logHashFailure (both branches) - Add tests for isContentSizeValid (all edge cases) - Update documentation.ts to import helpers from internal module Coverage: documentation.internal.ts 100% 🖥️ IDE: [Cursor](https://cursor.sh) 🔌 Extension: [Claude Code](https://claude.ai/download) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Model-Raw: claude-opus-4-5-20251101 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 841013a commit d250485

5 files changed

Lines changed: 400 additions & 19 deletions

File tree

extensions/git-id-switcher/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.13.4] - 2026-01-18
11+
12+
### Security
13+
14+
- **CDN Content Hash Verification**: Added SHA-256 hash verification for all documentation files
15+
- 32 documents now have cryptographic integrity checks (SHA-256)
16+
- Uses allowlist approach: unknown paths are rejected
17+
- Hash mismatches are logged and content is rejected
18+
- Protects against CDN cache poisoning and MITM attacks on documentation
19+
1020
## [0.13.3] - 2026-01-18
1121

1222
### Changed

extensions/git-id-switcher/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "git-id-switcher",
33
"displayName": "%extension.displayName%",
44
"description": "%extension.description%",
5-
"version": "0.13.3",
5+
"version": "0.13.4",
66
"publisher": "nullvariant",
77
"icon": "images/icon.png",
88
"engines": {

extensions/git-id-switcher/src/documentation.internal.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,55 @@
1313
// Constants
1414
// ============================================================================
1515

16+
/**
17+
* Document hash map for integrity verification (SHA-256)
18+
* These hashes are generated from the actual CDN content.
19+
* Update these values when documentation content changes.
20+
*
21+
* SECURITY: Allowlist approach - only registered paths with matching hashes are allowed.
22+
* Unknown paths or hash mismatches result in content rejection.
23+
*/
24+
export const DOCUMENT_HASHES: Record<string, string> = {
25+
// Root and shared documentation
26+
'README.md': 'cd4e471b3e96bbafd059be67a8604dfc1e7abd043dfeab127dd7d7bc52849ed5',
27+
'CHANGELOG.md': 'd028e3d603f95effc531c1e41162ea9fe48f79ccf3d948736be358fd582fe4dd',
28+
'LICENSE': 'e2383295422577622666fa2ff00e5f03abd8f2772d74cca5d5443020ab23d03d',
29+
'docs/CONTRIBUTING.md': '7d6ad2bc4d8c838790754cb9df848cb65f9fdce7e1a13e5c965b83a3d5b6378c',
30+
'docs/DESIGN_PHILOSOPHY.md': 'f9718b61ac161cb466dbc76845688e7acacf4e5fdc4b8b9553269dba4a094f6b',
31+
'docs/LANGUAGES.md': 'da50222843094479fd826837038dd62d619ecbc87d67f0b2c299973587abe8e9',
32+
// Major languages
33+
'docs/i18n/en/README.md': 'e89536f73b40093e4c1a0fd4fcdf8c998e0af3aa740dd849c5234221843880b4',
34+
'docs/i18n/ja/README.md': '554ef7da0677dbad0723948c223784e09f15a46236d7310b422d633df9d252a6',
35+
'docs/i18n/zh-CN/README.md': '4d15188903c5e4d44f47f7ad986aa51e324b9691d09a5fa1f39adff7d1a7277f',
36+
'docs/i18n/zh-TW/README.md': 'b412bcdbff254fb0ddf05bc785474f11303c2a7c6a7238b75344b0f48a903206',
37+
'docs/i18n/ko/README.md': 'e17f46a1af8ed8dcf787ede04b60928a5e99a0820c5624657305766e3a023787',
38+
'docs/i18n/de/README.md': '905f4d0681bb9d760f4f0b96c3297006aafb872ab64a006d17192041f83ef3a8',
39+
'docs/i18n/fr/README.md': 'e015fe6533c9f306b4bf54b1ef698a35fc47f2a58087444ced276bc050c46e85',
40+
'docs/i18n/es/README.md': '189c497ea18fc9ab2abd2286e0f8d78099e2ec590e55a6178ffb16d0834417b9',
41+
'docs/i18n/it/README.md': '00df278c9f784b759df06c6c69babfb2c046908f4ed9438753aec507e6c72aae',
42+
'docs/i18n/pt-BR/README.md': 'a37c03a3a8b01fd8e5e870df868d000d4b16193ab1f5b6460dc6366f49166329',
43+
'docs/i18n/ru/README.md': '33bfe1df04dce44b0a89c8e1800abda43b5986a8d37d871247c7d763c4d371e8',
44+
// European languages
45+
'docs/i18n/pl/README.md': '97d08d8cd428d5d1082b76c0dde039070b978620dd3bf8e7329e5c08ca05fa92',
46+
'docs/i18n/tr/README.md': '4a7b6c1d2bda57318d34fdf7d1bd5b9d725024ece12189ae7f739493d9245f79',
47+
'docs/i18n/uk/README.md': '9e97e18375dfdc0b42886dde9170f1fae32a63123c94092c2a5786aad16cf2e8',
48+
'docs/i18n/cs/README.md': '9dab8d3b35483f695c49c58f319534f643c9c4112f700bfe582ff7ca61a6371a',
49+
'docs/i18n/hu/README.md': 'ebd646d42bedc1b7fbd33c4b389d4cc516c8b18941fcfba8e229b05e6c3e29e5',
50+
'docs/i18n/bg/README.md': 'fb92c646ffc2712e4a0c57b2260a2cafb4eb0df398fac7f9253871eea4c33c0c',
51+
// Endangered/minority languages
52+
'docs/i18n/ain/README.md': '16104bc0ec4b3218f2956c7d2b5e93a186dad56fe0a0b3257a1023eb69c2ced3',
53+
'docs/i18n/ryu/README.md': '70574661485bc126c2422adecd054df1601650567b788706a641c1dd84071e99',
54+
'docs/i18n/haw/README.md': '4cf065164637933dd93ceca35b577b89fcab0fd1719b5de797a8f815e1432be4',
55+
// Constructed languages
56+
'docs/i18n/eo/README.md': 'f7c1892558dbce8672a99e534828a16bd3322e350b280a1386b1a15fc0b0947e',
57+
'docs/i18n/tlh/README.md': '9f34e65e1e98e31700ce3d074278084353b1816d5554b7a4466a395062726686',
58+
'docs/i18n/tok/README.md': '3d2fd4446ba8c2d48a7410e4b6e7f1d98a163d834412ba744916520c33a8f63b',
59+
// Novelty locales
60+
'docs/i18n/x-pirate/README.md': 'edc4ccf03dc61dc88504698dea1ea57c144e3e3e712cd748d4b1ceecea6e59ee',
61+
'docs/i18n/x-shakespeare/README.md': '16380f8d7e472a73ed8720b4368f00bc7130ae2811451d3a086bd7d1e347d223',
62+
'docs/i18n/x-lolcat/README.md': 'e486f9f1a2ba2738015d504a7220d6fa7084e75c18d763361d15bc5a443d18e0',
63+
};
64+
1665
/** Supported locales for documentation */
1766
export const SUPPORTED_LOCALES = [
1867
'en', 'ja', 'zh-CN', 'zh-TW', 'ko', 'de', 'fr', 'es', 'it', 'pt-BR', 'ru',
@@ -32,6 +81,93 @@ export const LOCALE_MAP: Record<string, string> = {
3281
// Security Utilities
3382
// ============================================================================
3483

84+
/**
85+
* Compute SHA-256 hash of a string
86+
*
87+
* @param content - Content to hash
88+
* @returns Hex-encoded SHA-256 hash
89+
*/
90+
export async function computeSha256Hash(content: string): Promise<string> {
91+
const encoder = new TextEncoder();
92+
const data = encoder.encode(content);
93+
94+
// Use Web Crypto API (available in both Node.js and browser)
95+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
96+
const hashArray = Array.from(new Uint8Array(hashBuffer));
97+
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
98+
}
99+
100+
/**
101+
* Verify content hash against expected value
102+
*
103+
* SECURITY: Uses SHA-256 for cryptographic integrity verification.
104+
* Returns false for unknown paths (allowlist approach) or hash mismatches.
105+
*
106+
* @param path - Document path relative to extension root
107+
* @param content - Document content to verify
108+
* @returns Object with verification result and computed hash (for debugging)
109+
*/
110+
export async function verifyContentHash(path: string, content: string): Promise<{
111+
valid: boolean;
112+
expectedHash: string | undefined;
113+
actualHash: string;
114+
}> {
115+
const expectedHash = DOCUMENT_HASHES[path];
116+
const actualHash = await computeSha256Hash(content);
117+
118+
if (!expectedHash) {
119+
// Unknown path - reject (allowlist approach)
120+
return { valid: false, expectedHash: undefined, actualHash };
121+
}
122+
123+
const valid = actualHash === expectedHash;
124+
return { valid, expectedHash, actualHash };
125+
}
126+
127+
/**
128+
* Log hash verification failure with appropriate message
129+
*
130+
* @param path - Document path that failed verification
131+
* @param hashResult - Result containing expected and actual hashes
132+
*/
133+
export function logHashFailure(
134+
path: string,
135+
hashResult: { expectedHash: string | undefined; actualHash: string }
136+
): void {
137+
if (hashResult.expectedHash === undefined) {
138+
console.warn(`[Git ID Switcher] Unknown document path rejected: ${path}`);
139+
} else {
140+
console.warn(
141+
`[Git ID Switcher] Hash mismatch for ${path}: ` +
142+
`expected ${hashResult.expectedHash}, got ${hashResult.actualHash}`
143+
);
144+
}
145+
}
146+
147+
/**
148+
* Validate content size against maximum allowed size
149+
*
150+
* @param contentLength - Content-Length header value (may be null)
151+
* @param actualLength - Actual content length in bytes
152+
* @param maxSize - Maximum allowed size in bytes
153+
* @returns true if content size is valid, false if too large
154+
*/
155+
export function isContentSizeValid(
156+
contentLength: string | null,
157+
actualLength: number,
158+
maxSize: number
159+
): boolean {
160+
if (contentLength && Number.parseInt(contentLength, 10) > maxSize) {
161+
console.warn('[Git ID Switcher] Documentation too large, rejecting');
162+
return false;
163+
}
164+
if (actualLength > maxSize) {
165+
console.warn('[Git ID Switcher] Documentation content too large, rejecting');
166+
return false;
167+
}
168+
return true;
169+
}
170+
35171
/**
36172
* Sanitize HTML to remove dangerous elements while preserving safe HTML
37173
*

extensions/git-id-switcher/src/documentation.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020
classifyUrl,
2121
getDocumentDisplayName,
2222
getDocumentLocaleFromString,
23+
verifyContentHash,
24+
logHashFailure,
25+
isContentSizeValid,
2326
} from './documentation.internal';
2427

2528
// ============================================================================
@@ -429,31 +432,31 @@ async function fetchDocumentByPath(path: string): Promise<string | null> {
429432
}
430433

431434
const response = await fetch(url, { signal: controller.signal, headers });
432-
433435
clearTimeout(timeoutId);
434436

435-
if (response.ok) {
436-
// DoS prevention: Check Content-Length header before reading body
437-
const contentLength = response.headers.get('content-length');
438-
if (contentLength && Number.parseInt(contentLength, 10) > MAX_CONTENT_SIZE) {
439-
console.warn('[Git ID Switcher] Documentation too large, rejecting');
440-
return null;
441-
}
437+
if (!response.ok) {
438+
return null;
439+
}
442440

443-
const content = await response.text();
441+
const contentLength = response.headers.get('content-length');
442+
const content = await response.text();
444443

445-
// Double-check actual content size (header may be missing or wrong)
446-
if (content.length > MAX_CONTENT_SIZE) {
447-
console.warn('[Git ID Switcher] Documentation content too large, rejecting');
448-
return null;
449-
}
444+
if (!isContentSizeValid(contentLength, content.length, MAX_CONTENT_SIZE)) {
445+
return null;
446+
}
450447

451-
if (content.trim().length > 0) {
452-
return content;
453-
}
448+
if (content.trim().length === 0) {
449+
return null;
454450
}
455451

456-
return null;
452+
// Verify content hash for integrity (allowlist approach)
453+
const hashResult = await verifyContentHash(path, content);
454+
if (!hashResult.valid) {
455+
logHashFailure(path, hashResult);
456+
return null;
457+
}
458+
459+
return content;
457460
} catch {
458461
clearTimeout(timeoutId);
459462
return null;

0 commit comments

Comments
 (0)