openvibers/openvibe.bench Commits

Render origin: the wrapper runs, the child loads, three is vendored (#20)

OP OpenVibers committed 14 days ago · cc25d1c656 · 10 files, +186 −22

The showcase's frames showed nothing: the wrapper's header policy had no
frame-src (default-src 'none' refused the child) and script-src 'self'
blocked its own clock; the frames pointed at the bench origin while the
policy named bench-render.openvibe.services, which had no DNS; and the
three.js the prompts point at was never served.

- The wrapper gets a fresh nonce for its one script and frame-src for the
  render origin; X-Frame-Options DENY is dropped where frame-ancestors decides.
- The gallery and the compare page frame RENDER_ORIGIN/render/w/:id.
- /vendor serves three 0.170.0 and its addons with CORS for the frames'
  opaque origin; every page gets an import map for three and three/addons/.
- The shader and glTF pages wrote __NONCE__ and never replaced it.
- deploy/nginx/bench-render.openvibe.services.conf: /render and /vendor only,
  GET only, cookies and authorization stripped.

Changes

ddiff --git a/deploy/nginx/bench-render.openvibe.services.conf b/deploy/nginx/bench-render.openvibe.services.conf
nnew file mode 100644
iindex 0000000..cb126f6
---- /dev/null
++++ b/deploy/nginx/bench-render.openvibe.services.conf
@@@ -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; }
++}
ddiff --git a/package-lock.json b/package-lock.json
iindex cdd27c7..016b0e8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 378e73c..b52b004 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex 10854be..13bdc40 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, {
ddiff --git a/server/bench/prompts.js b/server/bench/prompts.js
iindex cb85f59..86dccb3 100644
---- a/server/bench/prompts.js
++++ b/server/bench/prompts.js
@@@ -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: {
ddiff --git a/server/bench/render.js b/server/bench/render.js
iindex 9693e0a..a05cbcc 100644
---- a/server/bench/render.js
++++ b/server/bench/render.js
@@@ -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,
};
ddiff --git a/server/http/render.js b/server/http/render.js
iindex 73a0490..4444625 100644
---- a/server/http/render.js
++++ b/server/http/render.js
@@@ -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 it
res.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);
});
ddiff --git a/server/http/showcase.js b/server/http/showcase.js
iindex 94a87c6..528a5c6 100644
---- a/server/http/showcase.js
++++ b/server/http/showcase.js
@@@ -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,
};
ddiff --git a/server/http/vendor.js b/server/http/vendor.js
nnew file mode 100644
iindex 0000000..ce85a7c
---- /dev/null
++++ b/server/http/vendor.js
@@@ -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 };
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 80e9479..a6ea477 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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 () => {