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
20 changes: 15 additions & 5 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 @@ -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.`,
);
}
}
}

Expand Down
33 changes: 33 additions & 0 deletions tests/gltf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
Expand Down
Loading