diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b0d4e0c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Content Security Policy + +All public HTML pages in this project set a `Content-Security-Policy` meta tag. +The policy is intentionally strict and follows these principles: + +### `script-src` — no bare `'self'` + +Allowing `script-src 'self'` permits any script file served from the same origin +to execute, including any accidentally committed debug script or a compromised +static asset. Instead, each page uses the minimum required permission: + +| Page | Approach | Rationale | +|------|----------|-----------| +| `index.html` | `'none'` | No script is loaded | +| `demos.html` | `'none'` | No script is loaded | +| `gallery.html` | `'none'` | No script is loaded | +| `demo.html` | SHA-256 hash + `'strict-dynamic'` | Restricts execution to the single known inline bootstrap import | + +#### Hash-based policy for `demo.html` + +`demo.html` boots the application with a single inline module: + +```html + +``` + +The SHA-256 hash of that exact inline content (`import '/src/main.ts'`) is +pre-computed and embedded in the `script-src` directive: + +``` +script-src 'sha256-NDWEjzGVmgdl6gIijt3W2YpACKUzjdbNjuRCLQIRDKo=' 'strict-dynamic' +``` + +`'strict-dynamic'` propagates the trust granted by the hash to any modules +dynamically imported by that script (i.e. the rest of the application bundle). +**`demo.html` requires `'strict-dynamic'` support to function correctly.** +Without it, the browser will allow the inline bootstrap script (whose hash +matches) but block its `import '/src/main.ts'` call, because no host source +such as `'self'` is present to authorize the external module load. All browsers +that support WebGL 2.0 also support `'strict-dynamic'` (Chrome 52+, Firefox 52+, +Safari 15.4+), so this is not a practical limitation for this project. + +#### Recomputing the hash + +If the inline bootstrap script ever changes, recompute the hash with: + +```bash +printf "import '/src/main.ts'" | openssl dgst -sha256 -binary | base64 +``` + +Replace the `sha256-…` value in `demo.html` with the new output. + +### Other directives + +| Directive | Value | Reason | +|-----------|-------|--------| +| `object-src` | `'none'` | Disables Flash and other plug-in content | +| `base-uri` | `'self'` | Prevents base-tag hijacking | +| `default-src` | `'self'` | Safe fallback for unlisted resource types | +| `connect-src` | `'self' ws://localhost:* …` | Permits Vite HMR WebSocket in development | +| `unsafe-eval` | absent | Disallows `eval()` and similar constructs | + +## Reporting a Vulnerability + +If you discover a security vulnerability in this project, please open a GitHub +issue with the label **security**. For sensitive reports you may contact the +maintainers directly via the repository's GitHub profile. diff --git a/demo.html b/demo.html index 2b533bd..a8174c5 100644 --- a/demo.html +++ b/demo.html @@ -4,7 +4,7 @@ + content="default-src 'self'; script-src 'sha256-NDWEjzGVmgdl6gIijt3W2YpACKUzjdbNjuRCLQIRDKo=' 'strict-dynamic'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:*; object-src 'none'; base-uri 'self';" /> - + diff --git a/demos.html b/demos.html index 9eadcb7..45d52ba 100644 --- a/demos.html +++ b/demos.html @@ -4,7 +4,7 @@ + content="default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:*; object-src 'none'; base-uri 'self';" /> + content="default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:*; object-src 'none'; base-uri 'self';" /> + content="default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:*; object-src 'none'; base-uri 'self';" /> Promise; /** - * Maximum accepted byte size for a plain JSON glTF payload. + * Maximum accepted raw byte length for a JSON glTF payload (plain `.gltf`) or + * the JSON chunk of a GLB file, checked before UTF-8 decoding. + * * Defaults to 64 MiB. Raise this value only when loading unusually large assets. */ maxJsonBufferBytes?: number; + /** + * Maximum accepted approximate in-memory UTF-16 heap footprint of the decoded + * JSON string (`text.length * 2`), checked before `JSON.parse` is called. + * Enforced for both plain `.gltf` payloads and the JSON chunk of GLB files. + * + * Because a JavaScript string stores each code unit as two bytes (UTF-16), a + * 64 MiB ASCII JSON buffer decodes to a string with a ~128 MiB heap footprint. + * Setting this separately from `maxJsonBufferBytes` lets callers tune raw-byte + * and heap-footprint limits independently. + * + * Defaults to twice `maxJsonBufferBytes` (128 MiB). Raise this value only when + * loading unusually large assets. + */ + maxJsonStringBytes?: number; /** * When `true`, each VEC3 normal vector is re-normalized to unit length after * loading. Useful when the source asset was exported with non-unit normals. @@ -228,16 +255,28 @@ function wrapGltfError(prefix: string, cause: unknown): Error { */ export function parseContainer( buffer: ArrayBuffer, - options?: Pick, + options?: Pick, ): { json: GltfAsset; binChunk: ArrayBuffer | undefined; } { const header = new DataView(buffer); const maxJsonBufferBytes = options?.maxJsonBufferBytes ?? MAX_JSON_BUFFER_BYTES; + const maxJsonStringBytes = options?.maxJsonStringBytes ?? maxJsonBufferBytes * 2; + + if (!Number.isFinite(maxJsonBufferBytes) || maxJsonBufferBytes < 0) { + throw new RangeError( + `maxJsonBufferBytes must be a finite non-negative number (got ${maxJsonBufferBytes}).`, + ); + } + if (!Number.isFinite(maxJsonStringBytes) || maxJsonStringBytes < 0) { + throw new RangeError( + `maxJsonStringBytes must be a finite non-negative number (got ${maxJsonStringBytes}).`, + ); + } if (buffer.byteLength >= 12 && header.getUint32(0, true) === GLB_MAGIC) { - return parseGlb(buffer); + return parseGlb(buffer, maxJsonBufferBytes, maxJsonStringBytes); } // Treat the whole buffer as UTF-8 JSON @@ -248,6 +287,13 @@ export function parseContainer( ); } const text = UTF8_DECODER.decode(buffer); + // Approximate heap usage of the decoded UTF-16 string (2 bytes per code unit) + if (text.length * 2 > maxJsonStringBytes) { + throw new Error( + `JSON glTF string too large (~${text.length * 2} UTF-16 bytes). ` + + `Maximum supported decoded size is ${maxJsonStringBytes} bytes.`, + ); + } const json = safeParseGltfJson(text); return { json, binChunk: undefined }; } @@ -255,7 +301,11 @@ export function parseContainer( /** * Parse a GLB (binary glTF) container according to the glTF 2.0 spec §5. */ -function parseGlb(buffer: ArrayBuffer): { +function parseGlb( + buffer: ArrayBuffer, + maxJsonBufferBytes: number, + maxJsonStringBytes: number, +): { json: GltfAsset; binChunk: ArrayBuffer | undefined; } { @@ -275,11 +325,29 @@ function parseGlb(buffer: ArrayBuffer): { if (chunkLength === 0) { throw new Error(`Invalid chunk length: ${chunkLength}`); } + if (offset + 8 + chunkLength > buffer.byteLength) { + throw new Error( + `GLB chunk at offset ${offset} extends beyond end of file ` + + `(chunk needs ${offset + 8 + chunkLength} bytes, file is ${buffer.byteLength} bytes).`, + ); + } const chunkType = view.getUint32(offset + 4, true); const chunkData = buffer.slice(offset + 8, offset + 8 + chunkLength); if (chunkType === GLB_CHUNK_JSON) { + if (chunkData.byteLength > maxJsonBufferBytes) { + throw new Error( + `GLB JSON chunk too large (${chunkData.byteLength} bytes). ` + + `Maximum supported size is ${maxJsonBufferBytes} bytes.`, + ); + } const text = UTF8_DECODER.decode(chunkData); + if (text.length * 2 > maxJsonStringBytes) { + throw new Error( + `GLB JSON chunk string too large (~${text.length * 2} UTF-16 bytes). ` + + `Maximum supported decoded size is ${maxJsonStringBytes} bytes.`, + ); + } json = safeParseGltfJson(text); } else if (chunkType === GLB_CHUNK_BIN) { binChunk = chunkData; @@ -361,11 +429,19 @@ function validateExternalUri(uri: string, bufferIndex: number, strict?: boolean) ); } - if (strict && !candidates.every((v) => /^[A-Za-z0-9._\-/]+$/.test(v))) { - throw new Error( - `Buffer ${bufferIndex}: external URI "${uri}" contains characters not permitted in strict mode. ` + - `Only alphanumeric characters, dots, hyphens, underscores, and forward slashes are allowed.`, - ); + if (strict) { + if (candidates.some((v) => v.length > MAX_URI_LENGTH)) { + const len = Math.max(...candidates.map((v) => v.length)); + throw new Error( + `Buffer ${bufferIndex}: URI exceeds maximum allowed length in strict mode (len=${len}, max=${MAX_URI_LENGTH}).`, + ); + } + if (!candidates.every((v) => /^[A-Za-z0-9._\-/]+$/.test(v))) { + throw new Error( + `Buffer ${bufferIndex}: external URI "${uri}" contains characters not permitted in strict mode. ` + + `Only alphanumeric characters, dots, hyphens, underscores, and forward slashes are allowed.`, + ); + } } } @@ -398,6 +474,40 @@ async function resolveBuffers( } }; + /** + * Fully percent-decode a URI in a bounded loop. + * + * This prevents double-encoded traversal/scheme payloads from slipping + * through validation when consumers perform an additional decode. + * + * If further decoding would fail (malformed escape sequences), the last + * successfully decoded value is used. After the loop, if the string still + * contains percent-encoded bytes, the URI is rejected as suspicious. + */ + const fullyDecodeUri = (uri: string, maxIterations = 5): string => { + let current = uri; + for (let i = 0; i < maxIterations; i++) { + let decoded: string; + try { + decoded = decodeURIComponent(current); + } catch { + // Stop decoding on failure and use the last valid value. + break; + } + if (decoded === current) { + break; + } + current = decoded; + } + // If there are still percent-encoded octets present, treat as suspicious. + if (/%[0-9a-fA-F]{2}/.test(current)) { + throw new Error( + `Rejected suspicious percent-encoded URI after decoding: ${JSON.stringify(current)}`, + ); + } + return current; + }; + let binChunkConsumed = false; for (let i = 0; i < gltfBuffers.length; i++) { const buf = gltfBuffers[i]; @@ -433,8 +543,15 @@ async function resolveBuffers( `Buffer ${i} references external URI "${buf.uri}" but no resolveUri callback was provided.`, ); } + // Validate the original URI as authored in the glTF asset. validateExternalUri(buf.uri, i, options?.strict); - const externalBuffer = await resolveUri(buf.uri); + // Fully percent-decode the URI in a bounded loop so that any traversal + // or scheme payload only expressed after multiple decodes is exposed. + const fullyDecodedUri = fullyDecodeUri(buf.uri); + // Validate the fully decoded form as well, since this is what will be + // passed to the resolveUri callback and potentially used by consumers. + validateExternalUri(fullyDecodedUri, i, options?.strict); + const externalBuffer = await resolveUri(fullyDecodedUri); assertByteLength(externalBuffer, buf.byteLength, i); resolved.push(externalBuffer); } diff --git a/src/core/ShaderCache.ts b/src/core/ShaderCache.ts index 66daa9e..89b5d52 100644 --- a/src/core/ShaderCache.ts +++ b/src/core/ShaderCache.ts @@ -6,6 +6,9 @@ import { createShader, createProgram } from './ShaderUtils'; export class ShaderCache { + /** Maximum allowed length (in characters) for an explicit cache key. */ + static readonly MAX_KEY_LENGTH = 512; + private static readonly FNV1A_OFFSET_BASIS = 0x811c9dc5; // Second independent seed for the verification hash. Chosen as the next // published 32-bit FNV offset basis candidate so the two hashes are @@ -64,6 +67,31 @@ export class ShaderCache { return `fnv1a2-${ShaderCache.fnv1aSources(vertSrc, fragSrc, ShaderCache.FNV1A_OFFSET_BASIS_2)}`; } + /** + * Compute a compact, fixed-length cache key for a single GLSL source string + * using a composite of two independent 32-bit FNV-1a hashes so the raw + * source is never stored as a Map key while achieving ~64-bit effective + * collision resistance. + */ + private static hashShaderSource(source: string): string { + const primary = ShaderCache.fnv1aSources(source, '', ShaderCache.FNV1A_OFFSET_BASIS); + const secondary = ShaderCache.fnv1aSources(source, '', ShaderCache.FNV1A_OFFSET_BASIS_2); + return `fnv1a-shader-${primary}-${secondary}`; + } + + /** + * Throws a {@link RangeError} if `key` is defined and exceeds + * {@link MAX_KEY_LENGTH}. Call this at the top of every public method that + * accepts an optional explicit key. + */ + private static assertKeyLength(key: string | undefined): void { + if (key !== undefined && key.length > ShaderCache.MAX_KEY_LENGTH) { + throw new RangeError( + `ShaderCache: explicit key exceeds maximum length of ${ShaderCache.MAX_KEY_LENGTH} characters.`, + ); + } + } + /** key → compiled WebGLShader */ private readonly shaders: Map = new Map(); @@ -93,10 +121,11 @@ export class ShaderCache { * * @param type `gl.VERTEX_SHADER` or `gl.FRAGMENT_SHADER` * @param source GLSL source code - * @param key Optional cache key. Defaults to the source string itself. + * @param key Optional cache key. Defaults to an FNV-1a hash of the source string. */ getShader(type: number, source: string, key?: string): WebGLShader { - const cacheKey = key ?? source; + ShaderCache.assertKeyLength(key); + const cacheKey = key ?? ShaderCache.hashShaderSource(source); const existing = this.shaders.get(cacheKey); if (existing) return existing; @@ -116,6 +145,7 @@ export class ShaderCache { getProgram(vertexSource: string, fragmentSource: string, key?: string): WebGLProgram { // Explicit-key path: bypass auto-hashing entirely. if (key !== undefined) { + ShaderCache.assertKeyLength(key); const existing = this.programs.get(key); if (existing !== undefined) return existing; return this.compileAndCache(vertexSource, fragmentSource, key, undefined); @@ -171,8 +201,8 @@ export class ShaderCache { cacheKey: string, secondaryKey: string | undefined, ): WebGLProgram { - const vertexShaderKey = vertexSource; - const fragmentShaderKey = fragmentSource; + const vertexShaderKey = ShaderCache.hashShaderSource(vertexSource); + const fragmentShaderKey = ShaderCache.hashShaderSource(fragmentSource); const vsPreExisted = this.shaders.has(vertexShaderKey); const fsPreExisted = this.shaders.has(fragmentShaderKey); let vs: WebGLShader; @@ -233,7 +263,10 @@ export class ShaderCache { * hash string; for explicitly-keyed programs it is the supplied `key` value. */ getProgramKey(vertexSource: string, fragmentSource: string, key?: string): string { - if (key !== undefined) return key; + if (key !== undefined) { + ShaderCache.assertKeyLength(key); + return key; + } const hashKey = ShaderCache.hashSources(vertexSource, fragmentSource); const secondaryKey = ShaderCache.hashSources2(vertexSource, fragmentSource); const collisionKey = `${hashKey}:${secondaryKey}`; diff --git a/tests/csp.test.ts b/tests/csp.test.ts index dd7841e..865cd5f 100644 --- a/tests/csp.test.ts +++ b/tests/csp.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; @@ -46,4 +47,40 @@ describe('Content-Security-Policy', () => { expect(csp).toContain("base-uri 'self'"); } }); + + it("does not use a bare 'self' in script-src without a complementary nonce or hash", () => { + for (const path of pagePaths) { + const html = readFileSync(new URL(path, import.meta.url), 'utf8'); + const csp = extractCspContent(html); + if (csp === null) throw new Error(`${path} must have a CSP meta tag`); + const scriptSrcMatch = csp.match(/script-src\s+([^;]+)/); + if (scriptSrcMatch === null) continue; + const scriptSrc = scriptSrcMatch[1]; + if (scriptSrc.includes("'self'")) { + const hasNonce = /'nonce-[^']+'/.test(scriptSrc); + const hasHash = /'sha(?:256|384|512)-[^']+'/.test(scriptSrc); + expect( + hasNonce || hasHash, + `${path} uses bare 'self' in script-src without a nonce or hash`, + ).toBe(true); + } + } + }); + + it('CSP hash in demo.html matches the actual inline bootstrap script content', () => { + const demoPath = new URL('../demo.html', import.meta.url); + const html = readFileSync(demoPath, 'utf8'); + const csp = extractCspContent(html); + if (csp === null) throw new Error('demo.html must have a CSP meta tag'); + + const scriptMatch = html.match(/]*type="module"[^>]*>([\s\S]*?)<\/script>/); + expect(scriptMatch, 'demo.html must contain an inline