fix(examples): resolve CSP 'unsafe-eval' violation in threejs-server#432
fix(examples): resolve CSP 'unsafe-eval' violation in threejs-server#432Robloncz wants to merge 1 commit intomodelcontextprotocol:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a Content Security Policy (CSP) violation in the threejs-server example by replacing new Function() with dynamic script injection to avoid unsafe-eval restrictions.
Changes:
- Modified
executeThreeCodefunction to use a temporary global context object and dynamic<script>tag injection instead ofnew Function() - Added error handling with try-catch-finally blocks around the injected code execution
- Implemented automatic cleanup of temporary global context after code execution
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Use a unique ID to avoid conflicts | ||
| const scriptId = `threejs_ctx_${Math.random().toString(36).slice(2, 11)}`; | ||
|
|
||
| // Expose context to window temporarily so the script tag can access it | ||
| (window as any)[scriptId] = { | ||
| threeContext, | ||
| canvas, | ||
| width, | ||
| height, | ||
| visibilityAwareRAF, | ||
| }; | ||
|
|
||
| const wrappedCode = ` | ||
| (async () => { | ||
| const { threeContext, canvas, width, height, visibilityAwareRAF } = window['${scriptId}']; | ||
| const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = threeContext; | ||
| const requestAnimationFrame = visibilityAwareRAF; | ||
|
|
||
| try { | ||
| ${code} | ||
| } catch (e) { | ||
| console.error('Three.js execution error:', e); | ||
| } finally { | ||
| delete window['${scriptId}']; | ||
| } | ||
| })(); | ||
| `; | ||
|
|
||
| const script = document.createElement("script"); | ||
| script.textContent = wrappedCode; | ||
| document.head.appendChild(script); | ||
| script.remove(); |
There was a problem hiding this comment.
The async execution is no longer awaited. The original code used await fn(...) which ensured the async function completed before executeThreeCode returned. With the new script injection approach, the function returns immediately after appending the script, not after the async IIFE inside completes. This breaks the promise chain and means errors from the Three.js code execution won't be caught by the .catch() handler at line 278. Consider using a Promise-based callback mechanism where the injected script resolves/rejects a promise that executeThreeCode returns.
| // Use a unique ID to avoid conflicts | |
| const scriptId = `threejs_ctx_${Math.random().toString(36).slice(2, 11)}`; | |
| // Expose context to window temporarily so the script tag can access it | |
| (window as any)[scriptId] = { | |
| threeContext, | |
| canvas, | |
| width, | |
| height, | |
| visibilityAwareRAF, | |
| }; | |
| const wrappedCode = ` | |
| (async () => { | |
| const { threeContext, canvas, width, height, visibilityAwareRAF } = window['${scriptId}']; | |
| const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = threeContext; | |
| const requestAnimationFrame = visibilityAwareRAF; | |
| try { | |
| ${code} | |
| } catch (e) { | |
| console.error('Three.js execution error:', e); | |
| } finally { | |
| delete window['${scriptId}']; | |
| } | |
| })(); | |
| `; | |
| const script = document.createElement("script"); | |
| script.textContent = wrappedCode; | |
| document.head.appendChild(script); | |
| script.remove(); | |
| return new Promise<void>((resolve, reject) => { | |
| // Use a unique ID to avoid conflicts | |
| const scriptId = `threejs_ctx_${Math.random().toString(36).slice(2, 11)}`; | |
| // Expose context to window temporarily so the script tag can access it | |
| (window as any)[scriptId] = { | |
| threeContext, | |
| canvas, | |
| width, | |
| height, | |
| visibilityAwareRAF, | |
| resolve, | |
| reject, | |
| }; | |
| const wrappedCode = ` | |
| (async () => { | |
| const { threeContext, canvas, width, height, visibilityAwareRAF, resolve, reject } = window['${scriptId}']; | |
| const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = threeContext; | |
| const requestAnimationFrame = visibilityAwareRAF; | |
| try { | |
| ${code} | |
| resolve(); | |
| } catch (e) { | |
| console.error('Three.js execution error:', e); | |
| reject(e); | |
| } finally { | |
| delete window['${scriptId}']; | |
| } | |
| })(); | |
| `; | |
| const script = document.createElement("script"); | |
| script.textContent = wrappedCode; | |
| document.head.appendChild(script); | |
| script.remove(); | |
| }); |
| const wrappedCode = ` | ||
| (async () => { | ||
| const { threeContext, canvas, width, height, visibilityAwareRAF } = window['${scriptId}']; | ||
| const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = threeContext; | ||
| const requestAnimationFrame = visibilityAwareRAF; | ||
|
|
||
| try { | ||
| ${code} | ||
| } catch (e) { | ||
| console.error('Three.js execution error:', e); | ||
| } finally { | ||
| delete window['${scriptId}']; | ||
| } | ||
| })(); | ||
| `; |
There was a problem hiding this comment.
Direct string interpolation of user code creates a potential code injection vulnerability. If the code parameter contains backticks or template literal expressions like ${...}, it could break out of the template string context or interfere with the wrapper logic. Since this code is meant to execute LLM-generated content, there's a real risk. Consider using string concatenation instead of template literals for the wrapper, or properly escape the code before interpolation.
| const wrappedCode = ` | |
| (async () => { | |
| const { threeContext, canvas, width, height, visibilityAwareRAF } = window['${scriptId}']; | |
| const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = threeContext; | |
| const requestAnimationFrame = visibilityAwareRAF; | |
| try { | |
| ${code} | |
| } catch (e) { | |
| console.error('Three.js execution error:', e); | |
| } finally { | |
| delete window['${scriptId}']; | |
| } | |
| })(); | |
| `; | |
| const wrappedCode = | |
| "(async () => {\n" + | |
| " const { threeContext, canvas, width, height, visibilityAwareRAF } = window['" + scriptId + "'];\n" + | |
| " const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = threeContext;\n" + | |
| " const requestAnimationFrame = visibilityAwareRAF;\n" + | |
| " try {\n" + | |
| code + | |
| "\n" + | |
| " } catch (e) {\n" + | |
| " console.error('Three.js execution error:', e);\n" + | |
| " } finally {\n" + | |
| " delete window['" + scriptId + "'];\n" + | |
| " }\n" + | |
| "})();\n"; |
Summary of changes
Replaced
new Function()with a dynamic<script>tag execution pattern in thethreejs-serverexample'sexecuteThreeCodefunction.Motivation and Context
Strict Content Security Policies (CSP) often block
unsafe-eval, which prevents the use ofnew Function(). This caused the Three.js example to fail in environments like the VS Code MCP App host with the error:"Evaluating a string as JavaScript violates the following Content Security Policy directive...".By switching to dynamic script injection and a temporary global context object, the app can execute LLM-generated 3D code while adhering to
unsafe-inlinepolicies, significantly improving compatibility with secure hosts.How Has This Been Tested?
unsafe-evalerror is no longer triggered.npm run buildinexamples/threejs-server.npm run prettier:fix.Breaking Changes
None. This is an internal implementation change for the example renderer.
Types of changes
Checklist
Additional context
The implementation uses a unique window-level ID (
threejs_ctx_...) to pass the execution context (THREE, canvas, etc.) to the dynamic script without collisions. The global reference is deleted immediately after execution to keep the environment clean.