Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
18482d7
Initial plan
Copilot Mar 13, 2026
3652d8d
fix: pass percent-decoded URI to resolveUri callback and update JSDoc
Copilot Mar 13, 2026
75c623d
Potential fix for pull request finding
ormidales Mar 13, 2026
2c06092
Potential fix for pull request finding
ormidales Mar 13, 2026
007acde
Merge pull request #352 from ormidales/copilot/major-fix-gltloader-ur…
ormidales Mar 13, 2026
4d94ef7
Initial plan
Copilot Mar 13, 2026
aecdcea
fix: use FNV-1a hash as shader cache key instead of raw GLSL source
Copilot Mar 13, 2026
f545e94
Potential fix for pull request finding
ormidales Mar 13, 2026
3e75274
Merge pull request #353 from ormidales/copilot/fix-glsl-source-as-key
ormidales Mar 13, 2026
1d0517b
Initial plan
Copilot Mar 13, 2026
12b9d92
fix: enforce maxJsonBufferBytes on decoded string before JSON.parse
Copilot Mar 13, 2026
0e0f08b
Potential fix for pull request finding
ormidales Mar 13, 2026
3218c0a
Potential fix for pull request finding
ormidales Mar 13, 2026
2401701
Potential fix for pull request finding
ormidales Mar 13, 2026
21c6163
fix: correct test thresholds and JSDoc units for text.length*2 guard
Copilot Mar 13, 2026
66a2b51
Merge pull request #354 from ormidales/copilot/fix-json-parse-unbound…
ormidales Mar 13, 2026
8ac1510
Initial plan
Copilot Mar 13, 2026
1d85646
fix(csp): enforce strict-dynamic with hash; replace bare self with no…
Copilot Mar 13, 2026
db64e80
fix(csp): clarify strict-dynamic requirement in SECURITY.md; add hash…
Copilot Mar 13, 2026
ef0314b
Merge pull request #355 from ormidales/copilot/fix-csp-meta-tag-issue
ormidales Mar 13, 2026
e07fcb0
Initial plan
Copilot Mar 13, 2026
4f6c9e9
fix(ShaderCache): validate explicit key length — throw RangeError for…
Copilot Mar 13, 2026
a0f4392
Merge pull request #356 from ormidales/copilot/patch-fix-shadercache-…
ormidales Mar 13, 2026
2311f4a
Initial plan
Copilot Mar 13, 2026
6e96d64
fix: add URI length cap in validateExternalUri strict mode to prevent…
Copilot Mar 13, 2026
ed64cb9
fix: clarify MAX_URI_LENGTH comment and include len/max in rejection …
Copilot Mar 13, 2026
acfd81a
Merge pull request #357 from ormidales/copilot/patch-fix-validate-ext…
ormidales Mar 13, 2026
c5adddf
Bump version from 1.1.13 to 1.1.14
ormidales Mar 13, 2026
3319786
Bump version from 1.1.13 to 1.1.14
ormidales Mar 13, 2026
7243366
Initial plan
Copilot Mar 13, 2026
fa861dc
fix: address review feedback for GltfLoader and ShaderCache
Copilot Mar 13, 2026
209675a
fix: validate JSON size options, add GLB chunk bounds check, fix brit…
Copilot Mar 13, 2026
febc750
Merge pull request #359 from ormidales/copilot/sub-pr-358
ormidales Mar 13, 2026
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
69 changes: 69 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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
<script type="module">import '/src/main.ts'</script>
```

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.
4 changes: 2 additions & 2 deletions demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; 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';" />
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';" />
<meta
name="description"
content="Interactive microgl demo scene showcasing WebGL 2.0 rendering, ECS systems, and camera controls."
Expand All @@ -13,6 +13,6 @@
<link rel="stylesheet" href="/src/styles/theme.css" />
</head>
<body>
<script type="module" src="/src/main.ts"></script>
<script type="module">import '/src/main.ts'</script>
</body>
</html>
2 changes: 1 addition & 1 deletion demos.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; 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';" />
<meta
name="description"
content="Browse microgl technical demos including ECS stress tests, orbital camera interactions, and render loop examples."
Expand Down
2 changes: 1 addition & 1 deletion gallery.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; 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';" />
<meta
name="description"
content="microgl gallery page listing upcoming WebGL 2.0 scenes and technical showcase previews."
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; 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';" />
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';" />
<meta
name="description"
content="microgl homepage presenting a minimal WebGL 2.0 engine with ECS architecture and gl-matrix utilities."
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "microgl",
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"description": "Minimalist WebGL 2.0 3D rendering engine based on an ECS architecture, with support for glTF 2.0 and strict TypeScript.",
"scripts": {
Expand Down
139 changes: 128 additions & 11 deletions src/core/GltfLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const GLB_CHUNK_JSON = 0x4E4F534A;
const GLB_CHUNK_BIN = 0x004E4942;
const UTF8_DECODER = new TextDecoder();
const MAX_JSON_BUFFER_BYTES = 64 * 1024 * 1024;
/** Maximum URI length accepted in strict mode (guards against excessively long URIs that could cause resource exhaustion). */
const MAX_URI_LENGTH = 2048;

/**
* Deep-clone arbitrary JSON-like data into "data-only" structures:
Expand Down Expand Up @@ -126,15 +128,40 @@ function safeParseGltfJson(text: string): GltfAsset {
export interface GltfLoaderOptions {
/**
* Callback invoked to resolve external buffer URIs referenced by the glTF asset.
* Receives the raw URI string and must return the corresponding binary data.
* Receives a URI string that the loader has attempted to percent-decode and must
* return the corresponding binary data. If the URI contains invalid percent-encoding,
* decoding may fail and the original (possibly still percent-encoded) URI string
* will be passed to this callback.
* Required when loading plain `.gltf` files that reference external `.bin` files.
*
* **Security warning**: the URI received by this callback has already been validated
* and normalization / percent-decoding have been applied on a best-effort basis by
* the loader. Do not perform additional URI resolution or decoding inside this
* callback — doing so may re-introduce path-traversal or SSRF vulnerabilities that
* the loader's validation was designed to prevent.
*/
resolveUri?: (uri: string) => Promise<ArrayBuffer>;
/**
* 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.
Expand Down Expand Up @@ -228,16 +255,28 @@ function wrapGltfError(prefix: string, cause: unknown): Error {
*/
export function parseContainer(
buffer: ArrayBuffer,
options?: Pick<GltfLoaderOptions, 'maxJsonBufferBytes'>,
options?: Pick<GltfLoaderOptions, 'maxJsonBufferBytes' | 'maxJsonStringBytes'>,
): {
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
Expand All @@ -248,14 +287,25 @@ 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 };
}

/**
* 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;
} {
Expand All @@ -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;
Expand Down Expand Up @@ -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.`,
);
}
}
}

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading