openvibers/openvibe.bench Commits
Render origin: the wrapper runs, the child loads, three is vendored (#20)
Changes
nnew file mode 100644@@@ -0,0 +1,58 @@++## ════════════════════════════════════════════════════════════════════════════════════════════════════════════════++## bench-render.openvibe.services — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/bench-render.openvibe.services.conf (ov-vhost-install) — keep them in sync and++## reload nginx after changing it. The render origin (server/bench/render.js): the trusted wrapper (/render/w/:id), the++## model's own document (/render/e/:id) and the vendored libraries (/vendor/), from the same Node app (port 5230) on a++## separate origin that never sees a cookie or a session. Everything else answers 404. GET and HEAD only.++## The certificate is the *.openvibe.services wildcard.++## ════════════════════════════════════════════════════════════════════════════════════════════════════════════════++server {++ listen 80;++ listen [::]:80;++ server_name bench-render.openvibe.services;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://bench-render.openvibe.services$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name bench-render.openvibe.services;++++ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/bench-render.openvibe.services.access.log;++ error_log /var/log/nginx/bench-render.openvibe.services.error.log;++++ client_max_body_size 1k;++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript image/svg+xml text/plain;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ # No credentials in either direction: this origin never carries a session.++ proxy_set_header Cookie "";++ proxy_set_header Authorization "";++ proxy_hide_header Set-Cookie;++++ location ~ ^/(render/[we]/|vendor/) {++ limit_except GET { deny all; }++ proxy_pass http://127.0.0.1:5230;++ }++ location / { return 404; }++}@@@ -18,7 +18,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "three": "0.170.0"},"devDependencies": {"@electric-sql/pglite": "^0.5.8"@@@ -1621,6 +1622,12 @@"node": ">= 0.8"}},++ "node_modules/three": {++ "version": "0.170.0",++ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",++ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",++ "license": "MIT"++ },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -25,7 +25,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",-- "pg": "^8.23.0"++ "pg": "^8.23.0",++ "three": "0.170.0"},"engines": {"node": ">=22.0.0"@@@ -102,7 +102,7 @@ async function createApp(opts = {}) {fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],-- frameSrc: ["'self'", 'https://openvibe.network'],++ frameSrc: ["'self'", 'https://openvibe.network', require('./bench/render').RENDER_ORIGIN],frameAncestors: ["'none'"],objectSrc: ["'none'"],baseUri: ["'self'"],@@@ -166,6 +166,7 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));app.use('/render', createRenderRoutes(ctx));++ app.use('/vendor', require('./http/vendor').createVendorRoutes());app.use('/showcase', createShowcasePages(ctx));app.use(createPageRoutes(ctx));app.use((req, res) => send(res, 404, {@@@ -29,7 +29,7 @@ const PROMPTS = {system: 'You write one self-contained three.js scene that runs in a sandboxed iframe.\n'+ 'Return one HTML file and nothing else: no markdown fence, no explanation.\n'+ SANDBOX_RULES + '\n'-- + 'Import three.js from ' + RENDER_ORIGIN + '/vendor/three.module.js (or use the import map already in the page).\n'++ + 'Import three.js as "three" and its addons as "three/addons/…" (for example "three/addons/controls/OrbitControls.js"): the page has an import map to ' + RENDER_ORIGIN + '/vendor/ (three 0.170.0).\n'+ 'Set up a camera, lights and geometry, animate on its own, and show the scene within three seconds.',},svg_art: {@@@ -20,13 +20,20 @@ const { truncateBytes, MAX_BYTES } = require('./sanitize');const RENDER_ORIGIN = String(process.env.OV_RENDER_ORIGIN || 'https://bench-render.openvibe.services').replace(/\/+$/, '');/** The only origins a model output may load code, styles, images or fonts from. */const PINNED_ORIGINS = Object.freeze([RENDER_ORIGIN]);--/** Version-pinned libraries the render origin vendors. A model imports these by absolute URL. */++/** Version-pinned libraries the render origin vendors (three 0.170.0, server/http/vendor.js). A model imports these by++ * absolute URL, or by the import map's bare names ("three", "three/addons/…"). */const VENDOR = Object.freeze({three: RENDER_ORIGIN + '/vendor/three.module.js',-- threeLegacy: RENDER_ORIGIN + '/vendor/three.min.js',++ addons: RENDER_ORIGIN + '/vendor/three/addons/',});++/** The pages that may frame the wrapper and the model documents. */++const FRAME_ANCESTORS = 'https://bench.openvibe.services https://openvibe.report';++/** The wrapper's own policy: its one inline script under a fresh nonce, its child frame only from the render origin. */++function wrapperCsp(nonce) {++ return "default-src 'none'; script-src 'nonce-" + nonce + "'; style-src 'unsafe-inline'; frame-src " + RENDER_ORIGIN++ + "; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors " + FRAME_ANCESTORS;++}/** The sandbox attributes for every model frame: scripts yes, everything else no. */--const WRAPPER_CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; frame-src " + RENDER_ORIGIN + "; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'";const SANDBOX = 'allow-scripts';/** The default wall-clock a kind may run before the wrapper stops it. */const DEFAULT_LIMIT_MS = 20000;@@@ -48,13 +55,16 @@ function cspFor(nonce) {"object-src 'none'","form-action 'none'","base-uri 'none'",-- "frame-ancestors https://bench.openvibe.services https://openvibe.report",++ "frame-ancestors " + FRAME_ANCESTORS,].join('; ');}--/** Give every script tag in the model output our nonce, so only our own script-src nonce runs it. */++/** Give every script tag in the model output our nonce, so only our own script-src nonce runs it. A tag that already++ * names a nonce (the model guessing one, or our __NONCE__ placeholder) gets ours in its place: one nonce per tag. */function applyNonce(html, nonce) {-- return String(html == null ? '' : html).replace(/<script\b/gi, '<script nonce="' + nonce + '"');++ return String(html == null ? '' : html)++ .replace(/(<script\b[^>]*?)\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '$1')++ .replace(/<script\b/gi, '<script nonce="' + nonce + '"');}/** SVG-specific pass (spec 2.3): no script, no foreignObject, no external reference, no event handler, no @import. */@@@ -93,7 +103,7 @@ function head(nonce, extra) {/** The import map the wrapper injects: three.js is vendored on the render origin, never fetched from a CDN. */function importMap() {-- return '<script type="importmap" nonce="__NONCE__">' + JSON.stringify({ imports: { three: VENDOR.three } }) + '</script>';++ return '<script type="importmap" nonce="__NONCE__">' + JSON.stringify({ imports: { three: VENDOR.three, 'three/addons/': VENDOR.addons } }) + '</script>';}/** The model document for a script kind: the model's own HTML, under our nonce and our CSP. */@@@ -106,10 +116,16 @@ function modelDocument(kind, payload, opts) {else if (typeof p.gltf_base64 === 'string' && p.gltf_base64.trim()) body = gltfPage(p.gltf_base64);else body = '<p>Nothing was generated for this entry.</p>';body = truncateBytes(body, MAX_ENTRY_BYTES);-- const doc = /<html\b/i.test(body)-- ? body++ // A whole page of the model's own gets the import map too (an addon imports the bare "three"), unless it has one.++ const own = /<html\b/i.test(body);++ const withMap = own && !/type\s*=\s*["']?importmap/i.test(body)++ ? (/<head\b[^>]*>/i.test(body) ? body.replace(/<head\b[^>]*>/i, (m) => m + importMap()) : body.replace(/<html\b[^>]*>/i, (m) => m + '<head>' + importMap() + '</head>'))++ : body;++ const doc = own++ ? withMap: '<!doctype html><html lang="en"><head>' + head(nonce, importMap()) + '</head><body>' + body + '</body></html>';-- return truncateBytes(applyNonce(doc, nonce), MAX_ENTRY_BYTES + 2048);++ // The built-in pages (glsl, gltf) write __NONCE__ in their CSP meta and tags; it becomes this response's nonce.++ return truncateBytes(applyNonce(doc.split('__NONCE__').join(nonce), nonce), MAX_ENTRY_BYTES + 2048);}/** A minimal WebGL page: the model's fragment shader on a full-frame quad. */@@@ -167,9 +183,10 @@ function wrapperDocument(opts) {const kindId = (opts && opts.kindId) || '';const limitMs = (opts && opts.limitMs) || DEFAULT_LIMIT_MS;const title = (opts && opts.title) || 'Generated output';++ const nonce = (opts && opts.nonce) || crypto.randomBytes(16).toString('base64');const secs = Math.max(1, Math.round(limitMs / 1000));return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'-- + '<meta http-equiv="Content-Security-Policy" content="' + WRAPPER_CSP + '">'++ + '<meta http-equiv="Content-Security-Policy" content="' + esc(wrapperCsp(nonce)) + '">'+ '<style>html,body{margin:0;height:100%;font:14px/1.4 system-ui,sans-serif;background:#0f1115;color:#e9edf2}'+ '.bar{display:flex;gap:.5rem;align-items:center;justify-content:space-between;padding:.35rem .6rem;background:#171a21;border-bottom:1px solid #262b36}'+ '.bar b{font-weight:600}.clock{font-variant-numeric:tabular-nums;color:#9fb0c8}'@@@ -179,9 +196,9 @@ function wrapperDocument(opts) {+ '<body data-entry-id="' + esc(entryId) + '" data-kind-id="' + esc(kindId) + '" data-limit-ms="' + limitMs + '">'+ '<div class="bar"><b>' + esc(title) + '</b><span><span class="clock" id="clock">' + secs + 's</span> '+ '<button type="button" id="stop">Stop</button></span></div>'-- + '<iframe id="frame" title="' + esc(title) + '" sandbox="' + SANDBOX + '" referrerpolicy="no-referrer" loading="lazy" src="/render/e/' + encodeURIComponent(entryId) + '"></iframe>'++ + '<iframe id="frame" title="' + esc(title) + '" sandbox="' + SANDBOX + '" referrerpolicy="no-referrer" loading="lazy" src="' + esc(RENDER_ORIGIN + '/render/e/' + encodeURIComponent(entryId)) + '"></iframe>'+ '<div class="stopped" id="stopped">The entry stopped after ' + secs + ' seconds.</div>'-- + '<script>(function(){var frame=document.getElementById("frame");var clock=document.getElementById("clock");'++ + '<script nonce="' + esc(nonce) + '">(function(){var frame=document.getElementById("frame");var clock=document.getElementById("clock");'+ 'var stopped=document.getElementById("stopped");var left=' + secs + ';'+ 'function stop(){frame.src="about:blank";frame.style.display="none";stopped.style.display="block";clock.textContent="0s";}'+ 'document.getElementById("stop").addEventListener("click",stop);'@@@ -191,5 +208,5 @@ function wrapperDocument(opts) {module.exports = {RENDER_ORIGIN, PINNED_ORIGINS, VENDOR, SANDBOX, DEFAULT_LIMIT_MS, MAX_ENTRY_BYTES,-- cspFor, applyNonce, sanitizeSvg, limitMsOf, modeOf, modelDocument, wrapperDocument,++ FRAME_ANCESTORS, wrapperCsp, cspFor, applyNonce, sanitizeSvg, limitMsOf, modeOf, modelDocument, wrapperDocument,};@@@ -27,6 +27,7 @@ function createRenderRoutes(ctx) {/** No cookies on this origin, ever. */function bare(res) {res.removeHeader('Set-Cookie');++ res.removeHeader('X-Frame-Options'); // helmet's DENY; frame-ancestors in each response's CSP names who may frame itres.set('Cache-Control', 'no-store');res.set('X-Content-Type-Options', 'nosniff');res.set('Referrer-Policy', 'no-referrer');@@@ -37,14 +38,16 @@ function createRenderRoutes(ctx) {const entry = await entryOf(req.params.entryId);if (!entry) return bare(res).status(404).type('text/plain').send('No such entry.');const kind = entry.kind;++ const nonce = crypto.randomBytes(16).toString('base64');const doc = renderLib.wrapperDocument({++ nonce,entryId: entry.id,kindId: kind ? kind.id : '',limitMs: renderLib.limitMsOf(kind),title: kind ? kind.name : 'Generated output',});return bare(res)-- .set('Content-Security-Policy', renderLib.cspFor(crypto.randomBytes(16).toString('base64')).replace(/script-src [^;]*/, "script-src 'self'"))++ .set('Content-Security-Policy', renderLib.wrapperCsp(nonce)).type('html').send(doc);});@@@ -23,8 +23,8 @@ function slotsOf(entries, revealed, kind) {const base = {slot: i === 0 ? 'A' : 'B',entry_id: e.id,-- render_url: '/render/e/' + encodeURIComponent(e.id),-- wrapper_url: '/render/w/' + encodeURIComponent(e.id),++ render_url: renderLib.RENDER_ORIGIN + '/render/e/' + encodeURIComponent(e.id),++ wrapper_url: renderLib.RENDER_ORIGIN + '/render/w/' + encodeURIComponent(e.id),limit_ms: kind ? renderLib.limitMsOf(kind) : renderLib.DEFAULT_LIMIT_MS,error: e.error || null,};nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++++/**++ * The libraries the render origin vendors (server/bench/render.js VENDOR): three 0.170.0, pinned in package.json, served++ * from node_modules so a model output never fetches code from a CDN. A model document runs in an opaque origin (its++ * frame has no allow-same-origin), so a module script it loads is a cross-origin CORS request: these answer with++ * Access-Control-Allow-Origin * and Cross-Origin-Resource-Policy cross-origin (helmet's same-site would refuse them).++ *++ * GET /vendor/three.module.js three's module build++ * GET /vendor/three/addons/<path>.js examples/jsm (loaders, controls, …), the import map's "three/addons/"++ */++++const express = require('express');++const fs = require('fs');++const path = require('path');++++// three's exports map hides package.json; its main entry (build/three.cjs) is one level under the package root.++const ROOT = path.resolve(path.dirname(require.resolve('three')), '..');++const BUILD = path.join(ROOT, 'build');++const ADDONS = path.join(ROOT, 'examples', 'jsm');++++function createVendorRoutes() {++ const r = express.Router();++ const send = (res, file) => {++ res.removeHeader('Set-Cookie');++ res.set('Access-Control-Allow-Origin', '*');++ res.set('Cross-Origin-Resource-Policy', 'cross-origin');++ res.set('Cache-Control', 'public, max-age=31536000, immutable');++ res.set('X-Content-Type-Options', 'nosniff');++ res.type('text/javascript');++ return res.sendFile(file);++ };++ r.get('/three.module.js', (_req, res) => send(res, path.join(BUILD, 'three.module.js')));++ r.get(/^\/three\/addons\/([\w./-]+\.js)$/, (req, res) => {++ const rel = req.params[0];++ const file = path.resolve(ADDONS, rel);++ if (!file.startsWith(ADDONS + path.sep) || rel.includes('..') || !fs.existsSync(file)) return res.status(404).type('text/plain').send('Not vendored.');++ return send(res, file);++ });++ return r;++}++++module.exports = { createVendorRoutes };@@@ -1,4 +1,5 @@'use strict';++const renderLib = require('../server/bench/render');const assert = require('assert');const { boot, check, done } = require('./helpers/boot');@@@ -87,6 +88,39 @@ const HOSTILE = [assert.ok(w.text.includes('data-limit-ms="120000"'), 'the wrapper knows the kind clock');assert.ok(w.text.includes('id="stop"'), 'the wrapper has a stop button');assert.ok(!w.headers.get('set-cookie'), 'the wrapper sets no cookie');++ // Its policy lets its own clock run and its child load (10-30: 'script-src self' blocked the clock, and the++ // header had no frame-src, so default-src 'none' refused the child frame).++ const wcsp = w.headers.get('content-security-policy') || '';++ const nonce = (/script-src 'nonce-([^']+)'/.exec(wcsp) || [])[1];++ assert.ok(nonce, 'the wrapper header names a nonce: ' + wcsp);++ assert.ok(w.text.includes('<script nonce="' + nonce + '">'), 'the clock script carries the header\'s nonce');++ assert.ok(wcsp.includes('frame-src ' + renderLib.RENDER_ORIGIN), 'the child frame may load from the render origin: ' + wcsp);++ assert.ok(w.text.includes('src="' + renderLib.RENDER_ORIGIN + '/render/e/'), 'the child loads from the render origin');++ assert.ok(!w.headers.get('x-frame-options'), 'no X-Frame-Options DENY: frame-ancestors decides');++ assert.ok(wcsp.includes('frame-ancestors https://bench.openvibe.services'), 'the showcase page may frame it');++ });++++ await check('built-in pages and whole pages: one nonce per script, the import map for three and its addons', async () => {++ const glsl = renderLib.modelDocument({ id: 'games.shader' }, { glsl: 'void main(){gl_FragColor=vec4(1.0);}' }, { nonce: 'N1' });++ assert.ok(!glsl.includes('__NONCE__'), 'the placeholder becomes the nonce');++ assert.ok(!/nonce="[^"]*"\s+nonce=/.test(glsl), 'no tag carries two nonces');++ assert.ok(/http-equiv="Content-Security-Policy" content="[^"]*nonce-N1/.test(glsl), 'the page\'s own CSP names the same nonce');++ const own = renderLib.modelDocument({ id: 'games.three' }, { html: '<!doctype html><html><head><title>x</title></head><body><script type="module" nonce="guess">import * as THREE from "three";</script></body></html>' }, { nonce: 'N2' });++ assert.ok(own.includes('"three/addons/":"' + renderLib.VENDOR.addons + '"'), 'a whole page gets the import map');++ assert.ok(own.includes('<script nonce="N2" type="module">') && !own.includes('guess'), 'a guessed nonce is replaced by ours');++ });++++ await check('the render origin vendors three 0.170.0 for opaque-origin frames', async () => {++ const v = await t.get('/vendor/three.module.js');++ assert.strictEqual(v.status, 200);++ assert.ok(/javascript/.test(v.headers.get('content-type') || ''), v.headers.get('content-type'));++ assert.strictEqual(v.headers.get('access-control-allow-origin'), '*', 'a module script from an opaque origin is a CORS request');++ assert.strictEqual(v.headers.get('cross-origin-resource-policy'), 'cross-origin');++ assert.ok(v.text.includes("REVISION = '170'"), 'three r170');++ const a = await t.get('/vendor/three/addons/controls/OrbitControls.js');++ assert.strictEqual(a.status, 200, 'an addon');++ const bad = await t.get('/vendor/three/addons/../../package.json');++ assert.notStrictEqual(bad.status, 200, 'nothing outside examples/jsm');});await check('an entry is capped at 200 KB', async () => {@@@ -113,7 +147,7 @@ const HOSTILE = [assert.strictEqual(pair.slots[0].slot, 'A');assert.strictEqual(pair.slots[1].slot, 'B');assert.strictEqual(pair.slots[0].model, undefined, 'no model before the vote');-- assert.ok(pair.slots[0].wrapper_url.startsWith('/render/w/'));++ assert.ok(pair.slots[0].wrapper_url.startsWith(renderLib.RENDER_ORIGIN + '/render/w/'), 'the wrapper is on the render origin: ' + pair.slots[0].wrapper_url);});await check('the compare page is blind, and the vote reveals', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+## ════════════════════════════════════════════════════════════════════════════════════════════════════════════════
+## bench-render.openvibe.services — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/bench-render.openvibe.services.conf (ov-vhost-install) — keep them in sync and
+## reload nginx after changing it. The render origin (server/bench/render.js): the trusted wrapper (/render/w/:id), the
+## model's own document (/render/e/:id) and the vendored libraries (/vendor/), from the same Node app (port 5230) on a
+## separate origin that never sees a cookie or a session. Everything else answers 404. GET and HEAD only.
+## The certificate is the *.openvibe.services wildcard.
+## ════════════════════════════════════════════════════════════════════════════════════════════════════════════════
+server {
+ listen 80;
+ listen [::]:80;
+ server_name bench-render.openvibe.services;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://bench-render.openvibe.services$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name bench-render.openvibe.services;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/bench-render.openvibe.services.access.log;
+ error_log /var/log/nginx/bench-render.openvibe.services.error.log;
+
+ client_max_body_size 1k;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript image/svg+xml text/plain;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # No credentials in either direction: this origin never carries a session.
+ proxy_set_header Cookie "";
+ proxy_set_header Authorization "";
+ proxy_hide_header Set-Cookie;
+
+ location ~ ^/(render/[we]/|vendor/) {
+ limit_except GET { deny all; }
+ proxy_pass http://127.0.0.1:5230;
+ }
+ location / { return 404; }
+}
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "three": "0.170.0"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8"
"@electric-sql/pglite": "^0.5.8"
@@ -1621,6 +1622,12 @@
@@ -1621,6 +1622,12 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/three": {
+ "version": "0.170.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
+ "license": "MIT"
+ },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -25,7 +25,8 @@
@@ -25,7 +25,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
- "pg": "^8.23.0"
+ "pg": "^8.23.0",
+ "three": "0.170.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -102,7 +102,7 @@ async function createApp(opts = {}) {
@@ -102,7 +102,7 @@ async function createApp(opts = {}) {
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
- frameSrc: ["'self'", 'https://openvibe.network'],
+ frameSrc: ["'self'", 'https://openvibe.network', require('./bench/render').RENDER_ORIGIN],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
baseUri: ["'self'"],
@@ -166,6 +166,7 @@ async function createApp(opts = {}) {
@@ -166,6 +166,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use('/render', createRenderRoutes(ctx));
app.use('/render', createRenderRoutes(ctx));
+ app.use('/vendor', require('./http/vendor').createVendorRoutes());
app.use('/showcase', createShowcasePages(ctx));
app.use('/showcase', createShowcasePages(ctx));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, {
app.use((req, res) => send(res, 404, {
@@ -29,7 +29,7 @@ const PROMPTS = {
@@ -29,7 +29,7 @@ const PROMPTS = {
system: 'You write one self-contained three.js scene that runs in a sandboxed iframe.\n'
system: 'You write one self-contained three.js scene that runs in a sandboxed iframe.\n'
+ 'Return one HTML file and nothing else: no markdown fence, no explanation.\n'
+ 'Return one HTML file and nothing else: no markdown fence, no explanation.\n'
+ SANDBOX_RULES + '\n'
+ SANDBOX_RULES + '\n'
- + 'Import three.js from ' + RENDER_ORIGIN + '/vendor/three.module.js (or use the import map already in the page).\n'
+ + 'Import three.js as "three" and its addons as "three/addons/…" (for example "three/addons/controls/OrbitControls.js"): the page has an import map to ' + RENDER_ORIGIN + '/vendor/ (three 0.170.0).\n'
+ 'Set up a camera, lights and geometry, animate on its own, and show the scene within three seconds.',
+ 'Set up a camera, lights and geometry, animate on its own, and show the scene within three seconds.',
},
},
svg_art: {
svg_art: {
@@ -20,13 +20,20 @@ const { truncateBytes, MAX_BYTES } = require('./sanitize');
@@ -20,13 +20,20 @@ const { truncateBytes, MAX_BYTES } = require('./sanitize');
const RENDER_ORIGIN = String(process.env.OV_RENDER_ORIGIN || 'https://bench-render.openvibe.services').replace(/\/+$/, '');
const RENDER_ORIGIN = String(process.env.OV_RENDER_ORIGIN || 'https://bench-render.openvibe.services').replace(/\/+$/, '');
/** The only origins a model output may load code, styles, images or fonts from. */
/** The only origins a model output may load code, styles, images or fonts from. */
const PINNED_ORIGINS = Object.freeze([RENDER_ORIGIN]);
const PINNED_ORIGINS = Object.freeze([RENDER_ORIGIN]);
-/** Version-pinned libraries the render origin vendors. A model imports these by absolute URL. */
+/** Version-pinned libraries the render origin vendors (three 0.170.0, server/http/vendor.js). A model imports these by
+ * absolute URL, or by the import map's bare names ("three", "three/addons/…"). */
const VENDOR = Object.freeze({
const VENDOR = Object.freeze({
three: RENDER_ORIGIN + '/vendor/three.module.js',
three: RENDER_ORIGIN + '/vendor/three.module.js',
- threeLegacy: RENDER_ORIGIN + '/vendor/three.min.js',
+ addons: RENDER_ORIGIN + '/vendor/three/addons/',
});
});
+/** The pages that may frame the wrapper and the model documents. */
+const FRAME_ANCESTORS = 'https://bench.openvibe.services https://openvibe.report';
+/** The wrapper's own policy: its one inline script under a fresh nonce, its child frame only from the render origin. */
+function wrapperCsp(nonce) {
+ return "default-src 'none'; script-src 'nonce-" + nonce + "'; style-src 'unsafe-inline'; frame-src " + RENDER_ORIGIN
+ + "; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors " + FRAME_ANCESTORS;
+}
/** The sandbox attributes for every model frame: scripts yes, everything else no. */
/** The sandbox attributes for every model frame: scripts yes, everything else no. */
-const WRAPPER_CSP = "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; frame-src " + RENDER_ORIGIN + "; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'";
const SANDBOX = 'allow-scripts';
const SANDBOX = 'allow-scripts';
/** The default wall-clock a kind may run before the wrapper stops it. */
/** The default wall-clock a kind may run before the wrapper stops it. */
const DEFAULT_LIMIT_MS = 20000;
const DEFAULT_LIMIT_MS = 20000;
@@ -48,13 +55,16 @@ function cspFor(nonce) {
@@ -48,13 +55,16 @@ function cspFor(nonce) {
"object-src 'none'",
"object-src 'none'",
"form-action 'none'",
"form-action 'none'",
"base-uri 'none'",
"base-uri 'none'",
- "frame-ancestors https://bench.openvibe.services https://openvibe.report",
+ "frame-ancestors " + FRAME_ANCESTORS,
].join('; ');
].join('; ');
}
}
-/** Give every script tag in the model output our nonce, so only our own script-src nonce runs it. */
+/** Give every script tag in the model output our nonce, so only our own script-src nonce runs it. A tag that already
+ * names a nonce (the model guessing one, or our __NONCE__ placeholder) gets ours in its place: one nonce per tag. */
function applyNonce(html, nonce) {
function applyNonce(html, nonce) {
- return String(html == null ? '' : html).replace(/<script\b/gi, '<script nonce="' + nonce + '"');
+ return String(html == null ? '' : html)
+ .replace(/(<script\b[^>]*?)\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '$1')
+ .replace(/<script\b/gi, '<script nonce="' + nonce + '"');
}
}
/** SVG-specific pass (spec 2.3): no script, no foreignObject, no external reference, no event handler, no @import. */
/** SVG-specific pass (spec 2.3): no script, no foreignObject, no external reference, no event handler, no @import. */
@@ -93,7 +103,7 @@ function head(nonce, extra) {
@@ -93,7 +103,7 @@ function head(nonce, extra) {
/** The import map the wrapper injects: three.js is vendored on the render origin, never fetched from a CDN. */
/** The import map the wrapper injects: three.js is vendored on the render origin, never fetched from a CDN. */
function importMap() {
function importMap() {
- return '<script type="importmap" nonce="__NONCE__">' + JSON.stringify({ imports: { three: VENDOR.three } }) + '</script>';
+ return '<script type="importmap" nonce="__NONCE__">' + JSON.stringify({ imports: { three: VENDOR.three, 'three/addons/': VENDOR.addons } }) + '</script>';
}
}
/** The model document for a script kind: the model's own HTML, under our nonce and our CSP. */
/** The model document for a script kind: the model's own HTML, under our nonce and our CSP. */
@@ -106,10 +116,16 @@ function modelDocument(kind, payload, opts) {
@@ -106,10 +116,16 @@ function modelDocument(kind, payload, opts) {
else if (typeof p.gltf_base64 === 'string' && p.gltf_base64.trim()) body = gltfPage(p.gltf_base64);
else if (typeof p.gltf_base64 === 'string' && p.gltf_base64.trim()) body = gltfPage(p.gltf_base64);
else body = '<p>Nothing was generated for this entry.</p>';
else body = '<p>Nothing was generated for this entry.</p>';
body = truncateBytes(body, MAX_ENTRY_BYTES);
body = truncateBytes(body, MAX_ENTRY_BYTES);
- const doc = /<html\b/i.test(body)
- ? body
+ // A whole page of the model's own gets the import map too (an addon imports the bare "three"), unless it has one.
+ const own = /<html\b/i.test(body);
+ const withMap = own && !/type\s*=\s*["']?importmap/i.test(body)
+ ? (/<head\b[^>]*>/i.test(body) ? body.replace(/<head\b[^>]*>/i, (m) => m + importMap()) : body.replace(/<html\b[^>]*>/i, (m) => m + '<head>' + importMap() + '</head>'))
+ : body;
+ const doc = own
+ ? withMap
: '<!doctype html><html lang="en"><head>' + head(nonce, importMap()) + '</head><body>' + body + '</body></html>';
: '<!doctype html><html lang="en"><head>' + head(nonce, importMap()) + '</head><body>' + body + '</body></html>';
- return truncateBytes(applyNonce(doc, nonce), MAX_ENTRY_BYTES + 2048);
+ // The built-in pages (glsl, gltf) write __NONCE__ in their CSP meta and tags; it becomes this response's nonce.
+ return truncateBytes(applyNonce(doc.split('__NONCE__').join(nonce), nonce), MAX_ENTRY_BYTES + 2048);
}
}
/** A minimal WebGL page: the model's fragment shader on a full-frame quad. */
/** A minimal WebGL page: the model's fragment shader on a full-frame quad. */
@@ -167,9 +183,10 @@ function wrapperDocument(opts) {
@@ -167,9 +183,10 @@ function wrapperDocument(opts) {
const kindId = (opts && opts.kindId) || '';
const kindId = (opts && opts.kindId) || '';
const limitMs = (opts && opts.limitMs) || DEFAULT_LIMIT_MS;
const limitMs = (opts && opts.limitMs) || DEFAULT_LIMIT_MS;
const title = (opts && opts.title) || 'Generated output';
const title = (opts && opts.title) || 'Generated output';
+ const nonce = (opts && opts.nonce) || crypto.randomBytes(16).toString('base64');
const secs = Math.max(1, Math.round(limitMs / 1000));
const secs = Math.max(1, Math.round(limitMs / 1000));
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
- + '<meta http-equiv="Content-Security-Policy" content="' + WRAPPER_CSP + '">'
+ + '<meta http-equiv="Content-Security-Policy" content="' + esc(wrapperCsp(nonce)) + '">'
+ '<style>html,body{margin:0;height:100%;font:14px/1.4 system-ui,sans-serif;background:#0f1115;color:#e9edf2}'
+ '<style>html,body{margin:0;height:100%;font:14px/1.4 system-ui,sans-serif;background:#0f1115;color:#e9edf2}'
+ '.bar{display:flex;gap:.5rem;align-items:center;justify-content:space-between;padding:.35rem .6rem;background:#171a21;border-bottom:1px solid #262b36}'
+ '.bar{display:flex;gap:.5rem;align-items:center;justify-content:space-between;padding:.35rem .6rem;background:#171a21;border-bottom:1px solid #262b36}'
+ '.bar b{font-weight:600}.clock{font-variant-numeric:tabular-nums;color:#9fb0c8}'
+ '.bar b{font-weight:600}.clock{font-variant-numeric:tabular-nums;color:#9fb0c8}'
@@ -179,9 +196,9 @@ function wrapperDocument(opts) {
@@ -179,9 +196,9 @@ function wrapperDocument(opts) {
+ '<body data-entry-id="' + esc(entryId) + '" data-kind-id="' + esc(kindId) + '" data-limit-ms="' + limitMs + '">'
+ '<body data-entry-id="' + esc(entryId) + '" data-kind-id="' + esc(kindId) + '" data-limit-ms="' + limitMs + '">'
+ '<div class="bar"><b>' + esc(title) + '</b><span><span class="clock" id="clock">' + secs + 's</span> '
+ '<div class="bar"><b>' + esc(title) + '</b><span><span class="clock" id="clock">' + secs + 's</span> '
+ '<button type="button" id="stop">Stop</button></span></div>'
+ '<button type="button" id="stop">Stop</button></span></div>'
- + '<iframe id="frame" title="' + esc(title) + '" sandbox="' + SANDBOX + '" referrerpolicy="no-referrer" loading="lazy" src="/render/e/' + encodeURIComponent(entryId) + '"></iframe>'
+ + '<iframe id="frame" title="' + esc(title) + '" sandbox="' + SANDBOX + '" referrerpolicy="no-referrer" loading="lazy" src="' + esc(RENDER_ORIGIN + '/render/e/' + encodeURIComponent(entryId)) + '"></iframe>'
+ '<div class="stopped" id="stopped">The entry stopped after ' + secs + ' seconds.</div>'
+ '<div class="stopped" id="stopped">The entry stopped after ' + secs + ' seconds.</div>'
- + '<script>(function(){var frame=document.getElementById("frame");var clock=document.getElementById("clock");'
+ + '<script nonce="' + esc(nonce) + '">(function(){var frame=document.getElementById("frame");var clock=document.getElementById("clock");'
+ 'var stopped=document.getElementById("stopped");var left=' + secs + ';'
+ 'var stopped=document.getElementById("stopped");var left=' + secs + ';'
+ 'function stop(){frame.src="about:blank";frame.style.display="none";stopped.style.display="block";clock.textContent="0s";}'
+ 'function stop(){frame.src="about:blank";frame.style.display="none";stopped.style.display="block";clock.textContent="0s";}'
+ 'document.getElementById("stop").addEventListener("click",stop);'
+ 'document.getElementById("stop").addEventListener("click",stop);'
@@ -191,5 +208,5 @@ function wrapperDocument(opts) {
@@ -191,5 +208,5 @@ function wrapperDocument(opts) {
module.exports = {
module.exports = {
RENDER_ORIGIN, PINNED_ORIGINS, VENDOR, SANDBOX, DEFAULT_LIMIT_MS, MAX_ENTRY_BYTES,
RENDER_ORIGIN, PINNED_ORIGINS, VENDOR, SANDBOX, DEFAULT_LIMIT_MS, MAX_ENTRY_BYTES,
- cspFor, applyNonce, sanitizeSvg, limitMsOf, modeOf, modelDocument, wrapperDocument,
+ FRAME_ANCESTORS, wrapperCsp, cspFor, applyNonce, sanitizeSvg, limitMsOf, modeOf, modelDocument, wrapperDocument,
};
};
@@ -27,6 +27,7 @@ function createRenderRoutes(ctx) {
@@ -27,6 +27,7 @@ function createRenderRoutes(ctx) {
/** No cookies on this origin, ever. */
/** No cookies on this origin, ever. */
function bare(res) {
function bare(res) {
res.removeHeader('Set-Cookie');
res.removeHeader('Set-Cookie');
+ res.removeHeader('X-Frame-Options'); // helmet's DENY; frame-ancestors in each response's CSP names who may frame it
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'no-referrer');
res.set('Referrer-Policy', 'no-referrer');
@@ -37,14 +38,16 @@ function createRenderRoutes(ctx) {
@@ -37,14 +38,16 @@ function createRenderRoutes(ctx) {
const entry = await entryOf(req.params.entryId);
const entry = await entryOf(req.params.entryId);
if (!entry) return bare(res).status(404).type('text/plain').send('No such entry.');
if (!entry) return bare(res).status(404).type('text/plain').send('No such entry.');
const kind = entry.kind;
const kind = entry.kind;
+ const nonce = crypto.randomBytes(16).toString('base64');
const doc = renderLib.wrapperDocument({
const doc = renderLib.wrapperDocument({
+ nonce,
entryId: entry.id,
entryId: entry.id,
kindId: kind ? kind.id : '',
kindId: kind ? kind.id : '',
limitMs: renderLib.limitMsOf(kind),
limitMs: renderLib.limitMsOf(kind),
title: kind ? kind.name : 'Generated output',
title: kind ? kind.name : 'Generated output',
});
});
return bare(res)
return bare(res)
- .set('Content-Security-Policy', renderLib.cspFor(crypto.randomBytes(16).toString('base64')).replace(/script-src [^;]*/, "script-src 'self'"))
+ .set('Content-Security-Policy', renderLib.wrapperCsp(nonce))
.type('html')
.type('html')
.send(doc);
.send(doc);
});
});
@@ -23,8 +23,8 @@ function slotsOf(entries, revealed, kind) {
@@ -23,8 +23,8 @@ function slotsOf(entries, revealed, kind) {
const base = {
const base = {
slot: i === 0 ? 'A' : 'B',
slot: i === 0 ? 'A' : 'B',
entry_id: e.id,
entry_id: e.id,
- render_url: '/render/e/' + encodeURIComponent(e.id),
- wrapper_url: '/render/w/' + encodeURIComponent(e.id),
+ render_url: renderLib.RENDER_ORIGIN + '/render/e/' + encodeURIComponent(e.id),
+ wrapper_url: renderLib.RENDER_ORIGIN + '/render/w/' + encodeURIComponent(e.id),
limit_ms: kind ? renderLib.limitMsOf(kind) : renderLib.DEFAULT_LIMIT_MS,
limit_ms: kind ? renderLib.limitMsOf(kind) : renderLib.DEFAULT_LIMIT_MS,
error: e.error || null,
error: e.error || null,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+
+/**
+ * The libraries the render origin vendors (server/bench/render.js VENDOR): three 0.170.0, pinned in package.json, served
+ * from node_modules so a model output never fetches code from a CDN. A model document runs in an opaque origin (its
+ * frame has no allow-same-origin), so a module script it loads is a cross-origin CORS request: these answer with
+ * Access-Control-Allow-Origin * and Cross-Origin-Resource-Policy cross-origin (helmet's same-site would refuse them).
+ *
+ * GET /vendor/three.module.js three's module build
+ * GET /vendor/three/addons/<path>.js examples/jsm (loaders, controls, …), the import map's "three/addons/"
+ */
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+
+// three's exports map hides package.json; its main entry (build/three.cjs) is one level under the package root.
+const ROOT = path.resolve(path.dirname(require.resolve('three')), '..');
+const BUILD = path.join(ROOT, 'build');
+const ADDONS = path.join(ROOT, 'examples', 'jsm');
+
+function createVendorRoutes() {
+ const r = express.Router();
+ const send = (res, file) => {
+ res.removeHeader('Set-Cookie');
+ res.set('Access-Control-Allow-Origin', '*');
+ res.set('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.set('Cache-Control', 'public, max-age=31536000, immutable');
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.type('text/javascript');
+ return res.sendFile(file);
+ };
+ r.get('/three.module.js', (_req, res) => send(res, path.join(BUILD, 'three.module.js')));
+ r.get(/^\/three\/addons\/([\w./-]+\.js)$/, (req, res) => {
+ const rel = req.params[0];
+ const file = path.resolve(ADDONS, rel);
+ if (!file.startsWith(ADDONS + path.sep) || rel.includes('..') || !fs.existsSync(file)) return res.status(404).type('text/plain').send('Not vendored.');
+ return send(res, file);
+ });
+ return r;
+}
+
+module.exports = { createVendorRoutes };
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const renderLib = require('../server/bench/render');
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -87,6 +88,39 @@ const HOSTILE = [
@@ -87,6 +88,39 @@ const HOSTILE = [
assert.ok(w.text.includes('data-limit-ms="120000"'), 'the wrapper knows the kind clock');
assert.ok(w.text.includes('data-limit-ms="120000"'), 'the wrapper knows the kind clock');
assert.ok(w.text.includes('id="stop"'), 'the wrapper has a stop button');
assert.ok(w.text.includes('id="stop"'), 'the wrapper has a stop button');
assert.ok(!w.headers.get('set-cookie'), 'the wrapper sets no cookie');
assert.ok(!w.headers.get('set-cookie'), 'the wrapper sets no cookie');
+ // Its policy lets its own clock run and its child load (10-30: 'script-src self' blocked the clock, and the
+ // header had no frame-src, so default-src 'none' refused the child frame).
+ const wcsp = w.headers.get('content-security-policy') || '';
+ const nonce = (/script-src 'nonce-([^']+)'/.exec(wcsp) || [])[1];
+ assert.ok(nonce, 'the wrapper header names a nonce: ' + wcsp);
+ assert.ok(w.text.includes('<script nonce="' + nonce + '">'), 'the clock script carries the header\'s nonce');
+ assert.ok(wcsp.includes('frame-src ' + renderLib.RENDER_ORIGIN), 'the child frame may load from the render origin: ' + wcsp);
+ assert.ok(w.text.includes('src="' + renderLib.RENDER_ORIGIN + '/render/e/'), 'the child loads from the render origin');
+ assert.ok(!w.headers.get('x-frame-options'), 'no X-Frame-Options DENY: frame-ancestors decides');
+ assert.ok(wcsp.includes('frame-ancestors https://bench.openvibe.services'), 'the showcase page may frame it');
+ });
+
+ await check('built-in pages and whole pages: one nonce per script, the import map for three and its addons', async () => {
+ const glsl = renderLib.modelDocument({ id: 'games.shader' }, { glsl: 'void main(){gl_FragColor=vec4(1.0);}' }, { nonce: 'N1' });
+ assert.ok(!glsl.includes('__NONCE__'), 'the placeholder becomes the nonce');
+ assert.ok(!/nonce="[^"]*"\s+nonce=/.test(glsl), 'no tag carries two nonces');
+ assert.ok(/http-equiv="Content-Security-Policy" content="[^"]*nonce-N1/.test(glsl), 'the page\'s own CSP names the same nonce');
+ const own = renderLib.modelDocument({ id: 'games.three' }, { html: '<!doctype html><html><head><title>x</title></head><body><script type="module" nonce="guess">import * as THREE from "three";</script></body></html>' }, { nonce: 'N2' });
+ assert.ok(own.includes('"three/addons/":"' + renderLib.VENDOR.addons + '"'), 'a whole page gets the import map');
+ assert.ok(own.includes('<script nonce="N2" type="module">') && !own.includes('guess'), 'a guessed nonce is replaced by ours');
+ });
+
+ await check('the render origin vendors three 0.170.0 for opaque-origin frames', async () => {
+ const v = await t.get('/vendor/three.module.js');
+ assert.strictEqual(v.status, 200);
+ assert.ok(/javascript/.test(v.headers.get('content-type') || ''), v.headers.get('content-type'));
+ assert.strictEqual(v.headers.get('access-control-allow-origin'), '*', 'a module script from an opaque origin is a CORS request');
+ assert.strictEqual(v.headers.get('cross-origin-resource-policy'), 'cross-origin');
+ assert.ok(v.text.includes("REVISION = '170'"), 'three r170');
+ const a = await t.get('/vendor/three/addons/controls/OrbitControls.js');
+ assert.strictEqual(a.status, 200, 'an addon');
+ const bad = await t.get('/vendor/three/addons/../../package.json');
+ assert.notStrictEqual(bad.status, 200, 'nothing outside examples/jsm');
});
});
await check('an entry is capped at 200 KB', async () => {
await check('an entry is capped at 200 KB', async () => {
@@ -113,7 +147,7 @@ const HOSTILE = [
@@ -113,7 +147,7 @@ const HOSTILE = [
assert.strictEqual(pair.slots[0].slot, 'A');
assert.strictEqual(pair.slots[0].slot, 'A');
assert.strictEqual(pair.slots[1].slot, 'B');
assert.strictEqual(pair.slots[1].slot, 'B');
assert.strictEqual(pair.slots[0].model, undefined, 'no model before the vote');
assert.strictEqual(pair.slots[0].model, undefined, 'no model before the vote');
- assert.ok(pair.slots[0].wrapper_url.startsWith('/render/w/'));
+ assert.ok(pair.slots[0].wrapper_url.startsWith(renderLib.RENDER_ORIGIN + '/render/w/'), 'the wrapper is on the render origin: ' + pair.slots[0].wrapper_url);
});
});
await check('the compare page is blind, and the vote reveals', async () => {
await check('the compare page is blind, and the vote reveals', async () => {