diff --git a/src/core/GltfLoader.ts b/src/core/GltfLoader.ts index 5ff41c5..97438cb 100644 --- a/src/core/GltfLoader.ts +++ b/src/core/GltfLoader.ts @@ -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: @@ -388,11 +390,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.`, + ); + } } } diff --git a/tests/gltf.test.ts b/tests/gltf.test.ts index 54f9477..c3f62d4 100644 --- a/tests/gltf.test.ts +++ b/tests/gltf.test.ts @@ -1149,6 +1149,39 @@ describe('loadGltf', () => { expect(resolveUri).not.toHaveBeenCalled(); }); + it('rejects URI exceeding 2048 characters in strict mode', async () => { + const { json, bin } = triangleAsset(); + // Build a 2049-character URI using only allowed characters so it would pass + // the allowlist regex — only the length cap should trigger the rejection. + const longUri = 'a'.repeat(2045) + '.bin'; + json.buffers = [{ uri: longUri, byteLength: bin.byteLength }]; + const buffer = jsonToBuffer(json); + const resolveUri = vi.fn().mockResolvedValue(bin); + await expect(loadGltf(buffer, { resolveUri, strict: true })).rejects.toThrow(/exceeds maximum allowed length in strict mode/); + expect(resolveUri).not.toHaveBeenCalled(); + }); + + it('allows URI exactly at 2048 characters in strict mode', async () => { + const { json, bin } = triangleAsset(); + const exactUri = 'a'.repeat(2044) + '.bin'; + json.buffers = [{ uri: exactUri, byteLength: bin.byteLength }]; + const buffer = jsonToBuffer(json); + const resolveUri = vi.fn().mockResolvedValue(bin); + await expect(loadGltf(buffer, { resolveUri, strict: true })).resolves.toMatchObject({ meshes: expect.any(Array) }); + expect(resolveUri).toHaveBeenCalledWith(exactUri); + }); + + it('does not apply URI length cap in non-strict mode', async () => { + const { json, bin } = triangleAsset(); + // URI longer than 2048 chars but with only safe relative characters + const longUri = 'a'.repeat(2045) + '.bin'; + json.buffers = [{ uri: longUri, byteLength: bin.byteLength }]; + const buffer = jsonToBuffer(json); + const resolveUri = vi.fn().mockResolvedValue(bin); + await expect(loadGltf(buffer, { resolveUri })).resolves.toMatchObject({ meshes: expect.any(Array) }); + expect(resolveUri).toHaveBeenCalledWith(longUri); + }); + it('rejects URL-encoded path traversal URI "%2e%2e%2fetc%2fpasswd"', async () => { const { json, bin } = triangleAsset(); json.buffers = [{ uri: '%2e%2e%2fetc%2fpasswd', byteLength: bin.byteLength }];