A texture's pixels can be read back (readPixels(), toDataURL()), so WebGL refuses cross-origin images unless the server sends Access-Control-Allow-Origin, which is why loaders set crossOrigin = 'anonymous' (Textures and CORS). A glTF file's buffers and images are URLs too, so an untrusted model can fetch from any server. LoadingManager.setURLModifier() sees every URL a loader resolves, which makes it a gate:
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
await new THREE.TextureLoader().loadAsync('https://www.google.com/favicon.ico')
.then(() => console.log('texture loaded'), () => console.log('texture refused'));
const manager = new THREE.LoadingManager();
manager.setURLModifier((url) => { // every URL a loader resolves
if (/^(data|blob):/.test(url) || new URL(url, location.href).origin === location.origin) {
return url;
}
console.log('blocked', url);
return 'data:application/octet-stream;base64,'; // an empty file instead
});
const untrusted = JSON.stringify({ asset: { version: '2.0' }, scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
bufferViews: [{ buffer: 0, byteLength: 36 }],
buffers: [{ uri: 'https://tracker.example.com/beacon.bin', byteLength: 36 }] });
await new GLTFLoader(manager).parseAsync(untrusted, '')
.then(() => console.log('parsed'), (error) => console.log('parse failed:', error.message));
window.__done = true;Access to image at 'https://www.google.com/favicon.ico' from origin 'file://' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Failed to load resource: net::ERR_FAILED texture refused blocked https://tracker.example.com/beacon.bin THREE.GLTFLoader: Missing min/max properties for accessor POSITION. parse failed: Invalid typed array length: 9
The beacon was never requested. For user uploads, also cap the file size, run the Khronos glTF Validator (npm 2,036 install gltf-validator, Apache 2.0), limit triangle and texture counts (one 16384 x 16384 RGBA texture with mipmaps takes about 1.4 GB), and serve the Draco 7,495 and Basis decoders from your own origin under a Content Security Policy that allows 'wasm-unsafe-eval'.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
pre { margin: 4px 8px; white-space: pre-wrap; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<pre id="log"></pre>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
const log = (line) => { document.getElementById('log').textContent += line + '\n'; };
// setURLModifier sees every URL a loader resolves, which makes it a gate
const manager = new THREE.LoadingManager();
manager.setURLModifier((url) => {
if (/^(data|blob):/.test(url) || new URL(url, location.href).origin === location.origin) return url;
log(`blocked ${url}`);
throw new Error('URL not allowed'); // the request is never made
});
const loader = new GLTFLoader(manager);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 240);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 240, 0.1, 50);
camera.position.set(0, 0.4, 3);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
// 1. A trusted model: everything embedded in one .glb, nothing to fetch
const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.9, 0.12), new THREE.MeshStandardMaterial({ color: '#2a9d8f' }));
const glb = await new GLTFExporter().parseAsync(book, { binary: true });
try {
const gltf = await loader.parseAsync(glb, '');
gltf.scene.position.x = -0.7;
scene.add(gltf.scene);
log(`trusted .glb (${glb.byteLength} bytes): parsed and added`);
} catch (error) { log(`trusted model failed: ${error.message}`); }
// 2. An untrusted file whose buffer points at a tracking server
const untrusted = JSON.stringify({ asset: { version: '2.0' }, scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
bufferViews: [{ buffer: 0, byteLength: 36 }],
buffers: [{ uri: 'https://tracker.example.com/beacon.bin', byteLength: 36 }] });
try {
await loader.parseAsync(untrusted, '');
log('untrusted: parsed');
} catch (error) {
log(`untrusted: parse failed (${error.message}); the beacon was never requested`);
// Show a red placeholder where the untrusted model would have gone
const stub = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.9, 0.12), new THREE.MeshBasicMaterial({ color: '#b5452f', wireframe: true }));
stub.position.x = 0.7;
scene.add(stub);
}
renderer.setAnimationLoop((t) => {
scene.children.forEach((o) => { if (o.isMesh || o.isGroup) o.rotation.y = t / 1500; });
renderer.render(scene, camera);
});
</script>