openvibers/openvibe.media Commits
A missing page answers the site's 404 page in the Frame (#268)
Changes
@@@ -1,38 +1,67 @@'use strict';/**-- * The answer for a path nothing matched: Express's own 404 page ("Cannot GET /x", default-src 'none'),-- * plus Cloudflare Web Analytics. Cloudflare injects its beacon into every HTML answer at the edge, and-- * the network's privacy text says Cloudflare may measure performance; under Express's default policy the-- * beacon was blocked and logged a CSP error on every not-found page (browser check, OpenVibe.Host-- * scripts/browser-check.js). Media's pages send no policy of their own, so this is Media's one CSP.++ * The answer for a path nothing matched. A browser that asks for a page gets Media's own 404 page in the++ * OpenVibe Frame (server/public/page-frame.js); an API client, a player, or a client that does not accept++ * HTML keeps the JSON answer. The page never names the path or method that was asked for.++ *++ * Cloudflare Web Analytics is allowed by this page's CSP: Cloudflare injects its beacon into every HTML++ * answer at the edge, and the network's privacy text says it may measure performance. Media's other pages++ * send no policy of their own, so this is the one place the beacon's sources are listed.*/++const pageFrame = require('./public/page-frame');++const config = require('./config');++const CSP = [-- "default-src 'none'",-- // Cloudflare Web Analytics: script-src loads the beacon, connect-src is where it reports.-- 'script-src https://static.cloudflareinsights.com',-- 'connect-src https://cloudflareinsights.com',++ "default-src 'self'",++ // The Frame's theme-loader, navbar and footer are served from /shared; the inline boot and page style are the shell's.++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com",++ "font-src 'self' data: https://fonts.gstatic.com",++ "img-src 'self' data: https:",++ "connect-src 'self' https://openvibe.network https://cloudflareinsights.com",++ "frame-src 'self' https://openvibe.network",++ "frame-ancestors 'none'",++ "object-src 'none'",++ "base-uri 'self'",++ "form-action 'self' https://openvibe.network",].join('; ');--const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };--const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ESC[c]);++let cached = null;++function page() {++ if (cached) return cached;++ const host = String(config.publicUrl).replace(/^https?:\/\//, '').replace(/\/.*$/, '');++ const home = pageFrame.esc(config.publicUrl);++ cached = pageFrame.page({++ seo: {++ title: 'Page not found · OpenVibe.Media',++ description: `There is no page at this address on ${host}.`,++ robots: 'noindex, follow',++ ogType: 'website',++ },++ body: `<h1>Page not found</h1>++<p class="note">There is no page at this address on ${pageFrame.esc(host)}. The link may be old or mistyped.</p>++<div class="actions">++<a class="btn" href="${home}/">OpenVibe.Media home</a>++<a class="btn ghost" href="${home}/?tab=videos">Videos</a>++<a class="btn ghost" href="${home}/?tab=clips">Clips</a>++</div>`,++ footer: { variant: 'compact' },++ });++ return cached;++}function notFound(req, res) {res.status(404);res.set('X-Content-Type-Options', 'nosniff');-- // An API caller gets JSON, as from Media's own API routes (it got this HTML page).-- if (req.path === '/api' || req.path.startsWith('/api/')) return res.json({ error: 'Not found' });++ // An API caller gets JSON, as from Media's own API routes; so does a player or a client that does not want a page.++ if (req.path === '/api' || req.path.startsWith('/api/')++ || req.method !== 'GET' || req.accepts(['application/json', 'text/html']) !== 'text/html') {++ return res.json({ error: 'Not found' });++ }res.set('Content-Security-Policy', CSP);-- res.type('html').send(`<!DOCTYPE html>--<html lang="en">--<head>--<meta charset="utf-8">--<title>Error</title>--</head>--<body>--<pre>Cannot ${esc(req.method)} ${esc(req.originalUrl)}</pre>--</body>--</html>--`);++ res.set('Cache-Control', 'no-store');++ res.set('X-Robots-Tag', 'noindex');++ return res.type('html').send(page());}module.exports = { notFound, CSP };@@@ -1,8 +1,8 @@'use strict';--// server/not-found.js: an unknown path gets Express's own 404 page ("Cannot GET /x") under Media's CSP,--// which is default-src 'none' plus Cloudflare Web Analytics (script-src static.cloudflareinsights.com,--// connect-src cloudflareinsights.com). Cloudflare injects the beacon at the edge; Express's default--// policy blocked it and logged a CSP error on every not-found page (browser check, OpenVibe.Host).++// server/not-found.js: an unknown path asks for Media's own 404 page in the OpenVibe Frame when the client++// accepts HTML; an API caller, a player, or any client that does not want a page keeps the JSON answer.++// The page never echoes the path or method that was asked for, and its CSP still lets Cloudflare's beacon++// load and report (Cloudflare injects it at the edge; Media's pages send no policy of their own).const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -11,9 +11,9 @@ const express = require('express');(async () => {const { notFound, CSP } = require('../server/not-found');-- function send(base, p, method = 'GET') {++ function send(base, p, method = 'GET', accept = 'text/html') {return new Promise((resolve, reject) => {-- const req = http.request(base + p, { method, headers: { accept: 'text/html' } }, (res) => {++ const req = http.request(base + p, { method, headers: { accept } }, (res) => {let b = ''; res.on('data', (d) => { b += d; });res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));});@@@ -28,22 +28,36 @@ const express = require('express');const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;try {-- assert.strictEqual(CSP, "default-src 'none'; script-src https://static.cloudflareinsights.com; connect-src https://cloudflareinsights.com");++ assert.match(CSP, /^default-src 'self'/);++ assert.match(CSP, /script-src [^;]*https:\/\/static\.cloudflareinsights\.com/);++ assert.match(CSP, /connect-src [^;]*https:\/\/cloudflareinsights\.com/);assert.strictEqual((await send(base, '/known')).status, 200);const r = await send(base, '/__ovcheck-404');assert.strictEqual(r.status, 404);assert.strictEqual(r.headers['content-security-policy'], CSP);assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex');assert.match(r.headers['content-type'], /^text\/html/);-- assert.ok(r.body.includes('<pre>Cannot GET /__ovcheck-404</pre>'), r.body);++ assert.ok(r.body.includes('<h1>Page not found</h1>'), r.body.slice(0, 300));++ assert.match(r.body, /There is no page at this address on [^<]+\. The link may be old or mistyped\./);++ assert.ok(r.body.includes('id="navbar-mount"'), 'the page is in the site Frame');++ assert.ok(r.body.includes('<meta name="robots" content="noindex, follow">'));++ assert.ok(!r.body.includes('__ovcheck-404'), 'the page never names the path that was asked for');++ // The path and the query are not echoed; every missing path gets the same page.const x = await send(base, '/x%3Cb%3E?q=<script>');assert.strictEqual(x.status, 404);-- assert.ok(!x.body.includes('<script>'), 'the path is escaped');++ assert.strictEqual(x.body, r.body, 'one 404 page, whatever was asked for');++ assert.ok(!x.body.includes('<pre>Cannot'));-- const post = await send(base, '/nope', 'POST');-- assert.ok(post.body.includes('<pre>Cannot POST /nope</pre>'));++ // An API path, a POST or a client that does not accept HTML keeps the JSON answer.++ for (const [p, method, accept] of [['/api/nope', 'GET', 'text/html'], ['/nope', 'GET', 'application/json'], ['/nope', 'POST', 'text/html']]) {++ const j = await send(base, p, method, accept);++ assert.strictEqual(j.status, 404, `${method} ${p}`);++ assert.match(j.headers['content-type'], /^application\/json/);++ assert.deepStrictEqual(JSON.parse(j.body), { error: 'Not found' });++ }const head = await send(base, '/nope', 'HEAD');assert.deepStrictEqual([head.status, head.body], [404, '']);@@@ -52,7 +66,7 @@ const express = require('express');const at = idx.indexOf("app.use(require('./not-found').notFound)");assert.ok(at > idx.indexOf("app.use('/', require('./public/routes'))"), 'after the last routes');assert.ok(at < idx.indexOf('app.use((err, req, res, next) =>'), 'before the error handler');-- console.log('not-found: 404 under Media\'s CSP, Cloudflare Web Analytics allowed');++ console.log('not-found: 404 in Media\'s Frame for a browser, JSON for an API caller');} finally { server.close(); }})().catch((e) => { console.error(e); process.exit(1); });})().catch((err) => { console.error(err); process.exit(1); });@@@ -154,12 +154,13 @@ const { spawn } = require('child_process');assert.ok(m.body.includes('http_requests_total{method="GET",route="/api/ready",status_class="2xx"} 1\n'), m.body.slice(0, 400));const h = await request(b2, '/healthz');assert.strictEqual(h.status, 200, '/healthz still answers');-- // An unknown path is index.js's last handler (server/not-found.js): a 404 whose CSP lets Cloudflare's beacon run.++ // An unknown path is index.js's last handler (server/not-found.js): Media's own 404 page in the++ // Frame, under a CSP that still lets Cloudflare's beacon run.const nf = await new Promise((resolve, reject) => http.get(`${b2}/__ovcheck-404`, { headers: { accept: 'text/html' } }, (res) => {let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, csp: res.headers['content-security-policy'], body: b }));}).on('error', reject));assert.deepStrictEqual([nf.status, nf.csp], [404, require('../server/not-found').CSP]);-- assert.ok(nf.body.includes('<pre>Cannot GET /__ovcheck-404</pre>'));++ assert.ok(nf.body.includes('<h1>Page not found</h1>') && nf.body.includes('id="navbar-mount"'), nf.body.slice(0, 300));// ...and an unknown /api path is JSON, as from Media's API routes.const api404 = await request(b2, '/api/nope');assert.strictEqual(api404.status, 404);
@@ -1,38 +1,67 @@
@@ -1,38 +1,67 @@
'use strict';
'use strict';
/**
/**
- * The answer for a path nothing matched: Express's own 404 page ("Cannot GET /x", default-src 'none'),
- * plus Cloudflare Web Analytics. Cloudflare injects its beacon into every HTML answer at the edge, and
- * the network's privacy text says Cloudflare may measure performance; under Express's default policy the
- * beacon was blocked and logged a CSP error on every not-found page (browser check, OpenVibe.Host
- * scripts/browser-check.js). Media's pages send no policy of their own, so this is Media's one CSP.
+ * The answer for a path nothing matched. A browser that asks for a page gets Media's own 404 page in the
+ * OpenVibe Frame (server/public/page-frame.js); an API client, a player, or a client that does not accept
+ * HTML keeps the JSON answer. The page never names the path or method that was asked for.
+ *
+ * Cloudflare Web Analytics is allowed by this page's CSP: Cloudflare injects its beacon into every HTML
+ * answer at the edge, and the network's privacy text says it may measure performance. Media's other pages
+ * send no policy of their own, so this is the one place the beacon's sources are listed.
*/
*/
+const pageFrame = require('./public/page-frame');
+const config = require('./config');
+
const CSP = [
const CSP = [
- "default-src 'none'",
- // Cloudflare Web Analytics: script-src loads the beacon, connect-src is where it reports.
- 'script-src https://static.cloudflareinsights.com',
- 'connect-src https://cloudflareinsights.com',
+ "default-src 'self'",
+ // The Frame's theme-loader, navbar and footer are served from /shared; the inline boot and page style are the shell's.
+ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",
+ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com",
+ "font-src 'self' data: https://fonts.gstatic.com",
+ "img-src 'self' data: https:",
+ "connect-src 'self' https://openvibe.network https://cloudflareinsights.com",
+ "frame-src 'self' https://openvibe.network",
+ "frame-ancestors 'none'",
+ "object-src 'none'",
+ "base-uri 'self'",
+ "form-action 'self' https://openvibe.network",
].join('; ');
].join('; ');
-const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
-const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ESC[c]);
+let cached = null;
+function page() {
+ if (cached) return cached;
+ const host = String(config.publicUrl).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
+ const home = pageFrame.esc(config.publicUrl);
+ cached = pageFrame.page({
+ seo: {
+ title: 'Page not found · OpenVibe.Media',
+ description: `There is no page at this address on ${host}.`,
+ robots: 'noindex, follow',
+ ogType: 'website',
+ },
+ body: `<h1>Page not found</h1>
+<p class="note">There is no page at this address on ${pageFrame.esc(host)}. The link may be old or mistyped.</p>
+<div class="actions">
+<a class="btn" href="${home}/">OpenVibe.Media home</a>
+<a class="btn ghost" href="${home}/?tab=videos">Videos</a>
+<a class="btn ghost" href="${home}/?tab=clips">Clips</a>
+</div>`,
+ footer: { variant: 'compact' },
+ });
+ return cached;
+}
function notFound(req, res) {
function notFound(req, res) {
res.status(404);
res.status(404);
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Content-Type-Options', 'nosniff');
- // An API caller gets JSON, as from Media's own API routes (it got this HTML page).
- if (req.path === '/api' || req.path.startsWith('/api/')) return res.json({ error: 'Not found' });
+ // An API caller gets JSON, as from Media's own API routes; so does a player or a client that does not want a page.
+ if (req.path === '/api' || req.path.startsWith('/api/')
+ || req.method !== 'GET' || req.accepts(['application/json', 'text/html']) !== 'text/html') {
+ return res.json({ error: 'Not found' });
+ }
res.set('Content-Security-Policy', CSP);
res.set('Content-Security-Policy', CSP);
- res.type('html').send(`<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<title>Error</title>
-</head>
-<body>
-<pre>Cannot ${esc(req.method)} ${esc(req.originalUrl)}</pre>
-</body>
-</html>
-`);
+ res.set('Cache-Control', 'no-store');
+ res.set('X-Robots-Tag', 'noindex');
+ return res.type('html').send(page());
}
}
module.exports = { notFound, CSP };
module.exports = { notFound, CSP };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
-// server/not-found.js: an unknown path gets Express's own 404 page ("Cannot GET /x") under Media's CSP,
-// which is default-src 'none' plus Cloudflare Web Analytics (script-src static.cloudflareinsights.com,
-// connect-src cloudflareinsights.com). Cloudflare injects the beacon at the edge; Express's default
-// policy blocked it and logged a CSP error on every not-found page (browser check, OpenVibe.Host).
+// server/not-found.js: an unknown path asks for Media's own 404 page in the OpenVibe Frame when the client
+// accepts HTML; an API caller, a player, or any client that does not want a page keeps the JSON answer.
+// The page never echoes the path or method that was asked for, and its CSP still lets Cloudflare's beacon
+// load and report (Cloudflare injects it at the edge; Media's pages send no policy of their own).
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -11,9 +11,9 @@ const express = require('express');
@@ -11,9 +11,9 @@ const express = require('express');
(async () => {
(async () => {
const { notFound, CSP } = require('../server/not-found');
const { notFound, CSP } = require('../server/not-found');
- function send(base, p, method = 'GET') {
+ function send(base, p, method = 'GET', accept = 'text/html') {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
- const req = http.request(base + p, { method, headers: { accept: 'text/html' } }, (res) => {
+ const req = http.request(base + p, { method, headers: { accept } }, (res) => {
let b = ''; res.on('data', (d) => { b += d; });
let b = ''; res.on('data', (d) => { b += d; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
});
});
@@ -28,22 +28,36 @@ const express = require('express');
@@ -28,22 +28,36 @@ const express = require('express');
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
try {
try {
- assert.strictEqual(CSP, "default-src 'none'; script-src https://static.cloudflareinsights.com; connect-src https://cloudflareinsights.com");
+ assert.match(CSP, /^default-src 'self'/);
+ assert.match(CSP, /script-src [^;]*https:\/\/static\.cloudflareinsights\.com/);
+ assert.match(CSP, /connect-src [^;]*https:\/\/cloudflareinsights\.com/);
assert.strictEqual((await send(base, '/known')).status, 200);
assert.strictEqual((await send(base, '/known')).status, 200);
const r = await send(base, '/__ovcheck-404');
const r = await send(base, '/__ovcheck-404');
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);
assert.strictEqual(r.headers['content-security-policy'], CSP);
assert.strictEqual(r.headers['content-security-policy'], CSP);
assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');
assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');
+ assert.strictEqual(r.headers['x-robots-tag'], 'noindex');
assert.match(r.headers['content-type'], /^text\/html/);
assert.match(r.headers['content-type'], /^text\/html/);
- assert.ok(r.body.includes('<pre>Cannot GET /__ovcheck-404</pre>'), r.body);
+ assert.ok(r.body.includes('<h1>Page not found</h1>'), r.body.slice(0, 300));
+ assert.match(r.body, /There is no page at this address on [^<]+\. The link may be old or mistyped\./);
+ assert.ok(r.body.includes('id="navbar-mount"'), 'the page is in the site Frame');
+ assert.ok(r.body.includes('<meta name="robots" content="noindex, follow">'));
+ assert.ok(!r.body.includes('__ovcheck-404'), 'the page never names the path that was asked for');
+ // The path and the query are not echoed; every missing path gets the same page.
const x = await send(base, '/x%3Cb%3E?q=<script>');
const x = await send(base, '/x%3Cb%3E?q=<script>');
assert.strictEqual(x.status, 404);
assert.strictEqual(x.status, 404);
- assert.ok(!x.body.includes('<script>'), 'the path is escaped');
+ assert.strictEqual(x.body, r.body, 'one 404 page, whatever was asked for');
+ assert.ok(!x.body.includes('<pre>Cannot'));
- const post = await send(base, '/nope', 'POST');
- assert.ok(post.body.includes('<pre>Cannot POST /nope</pre>'));
+ // An API path, a POST or a client that does not accept HTML keeps the JSON answer.
+ for (const [p, method, accept] of [['/api/nope', 'GET', 'text/html'], ['/nope', 'GET', 'application/json'], ['/nope', 'POST', 'text/html']]) {
+ const j = await send(base, p, method, accept);
+ assert.strictEqual(j.status, 404, `${method} ${p}`);
+ assert.match(j.headers['content-type'], /^application\/json/);
+ assert.deepStrictEqual(JSON.parse(j.body), { error: 'Not found' });
+ }
const head = await send(base, '/nope', 'HEAD');
const head = await send(base, '/nope', 'HEAD');
assert.deepStrictEqual([head.status, head.body], [404, '']);
assert.deepStrictEqual([head.status, head.body], [404, '']);
@@ -52,7 +66,7 @@ const express = require('express');
@@ -52,7 +66,7 @@ const express = require('express');
const at = idx.indexOf("app.use(require('./not-found').notFound)");
const at = idx.indexOf("app.use(require('./not-found').notFound)");
assert.ok(at > idx.indexOf("app.use('/', require('./public/routes'))"), 'after the last routes');
assert.ok(at > idx.indexOf("app.use('/', require('./public/routes'))"), 'after the last routes');
assert.ok(at < idx.indexOf('app.use((err, req, res, next) =>'), 'before the error handler');
assert.ok(at < idx.indexOf('app.use((err, req, res, next) =>'), 'before the error handler');
- console.log('not-found: 404 under Media\'s CSP, Cloudflare Web Analytics allowed');
+ console.log('not-found: 404 in Media\'s Frame for a browser, JSON for an API caller');
} finally { server.close(); }
} finally { server.close(); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -154,12 +154,13 @@ const { spawn } = require('child_process');
@@ -154,12 +154,13 @@ const { spawn } = require('child_process');
assert.ok(m.body.includes('http_requests_total{method="GET",route="/api/ready",status_class="2xx"} 1\n'), m.body.slice(0, 400));
assert.ok(m.body.includes('http_requests_total{method="GET",route="/api/ready",status_class="2xx"} 1\n'), m.body.slice(0, 400));
const h = await request(b2, '/healthz');
const h = await request(b2, '/healthz');
assert.strictEqual(h.status, 200, '/healthz still answers');
assert.strictEqual(h.status, 200, '/healthz still answers');
- // An unknown path is index.js's last handler (server/not-found.js): a 404 whose CSP lets Cloudflare's beacon run.
+ // An unknown path is index.js's last handler (server/not-found.js): Media's own 404 page in the
+ // Frame, under a CSP that still lets Cloudflare's beacon run.
const nf = await new Promise((resolve, reject) => http.get(`${b2}/__ovcheck-404`, { headers: { accept: 'text/html' } }, (res) => {
const nf = await new Promise((resolve, reject) => http.get(`${b2}/__ovcheck-404`, { headers: { accept: 'text/html' } }, (res) => {
let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, csp: res.headers['content-security-policy'], body: b }));
let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, csp: res.headers['content-security-policy'], body: b }));
}).on('error', reject));
}).on('error', reject));
assert.deepStrictEqual([nf.status, nf.csp], [404, require('../server/not-found').CSP]);
assert.deepStrictEqual([nf.status, nf.csp], [404, require('../server/not-found').CSP]);
- assert.ok(nf.body.includes('<pre>Cannot GET /__ovcheck-404</pre>'));
+ assert.ok(nf.body.includes('<h1>Page not found</h1>') && nf.body.includes('id="navbar-mount"'), nf.body.slice(0, 300));
// ...and an unknown /api path is JSON, as from Media's API routes.
// ...and an unknown /api path is JSON, as from Media's API routes.
const api404 = await request(b2, '/api/nope');
const api404 = await request(b2, '/api/nope');
assert.strictEqual(api404.status, 404);
assert.strictEqual(api404.status, 404);