openvibers/openvibe.mediahub Commits

A quiet sign-in on openvibe.video returns to the page it started on (an absolute next on our own origins is its path), the navbar gets the person's avatar, guest HTML is revalidated and sign-in/out clear the cache, so a signed-in owner sees their Manage and Delete (#109)

OP OpenVibers committed 22 days ago · b69bb6818a · 7 files, +83 −14

Changes

ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex e943c05..2f79afd 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -28,11 +28,31 @@ function pkcePair() {
return { verifier, challenge };
}
--/** Same-site relative paths only (never protocol-relative, never another origin). */
--function sanitizeNext(next) {
++/** This service's own origins: the drive (BASE_URL), OpenVibe.Video and OpenVibe.Pics. */
++function ownOrigins(config) {
++ const out = new Set();
++ for (const u of [config && config.baseUrl, config && config.video && config.video.url, config && config.pics && config.pics.url]) {
++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
++ }
++ return out;
++}
++
++/**
++ * Same-site relative paths only (never protocol-relative, never another origin). An absolute URL on one of this
++ * service's own origins counts as its path: the shared navbar's silent sign-in sends the whole page address
++ * (`next=https://openvibe.video/v/5902`), and until 2026-10-22 that landed on the home page, so a person signed in
++ * quietly on a video page found themselves somewhere else and went Back to a signed-out copy of it.
++ */
++function sanitizeNext(next, config) {
// Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
// leave the site. A next with any control character or backslash goes home.
if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
++ let u = null;
++ try { u = new URL(next); } catch { return '/'; }
++ if (!ownOrigins(config).has(u.origin)) return '/';
++ next = `${u.pathname}${u.search}${u.hash}`;
++ }
if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
return next;
}
@@@ -120,6 +140,9 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
role,
++ // The navbar's avatar and colour (Network's token claims; the avatar is an https image on openvibe.media).
++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
// The person's Network access token: used for server-side Network calls, never rendered.
token,
};
@@@ -187,7 +210,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
const silent = Boolean(req.query.silent) && req.query.silent !== '0';
const state = crypto.randomBytes(16).toString('hex');
const { verifier, challenge } = pkcePair();
-- res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.redirect(authorizeUrl(config, { state, challenge, silent, redirectUri: redirectUriFor(req) }));
});
@@@ -197,7 +220,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
let flow = null;
try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
-- const next = flow ? sanitizeNext(flow.next) : '/';
++ const next = flow ? sanitizeNext(flow.next, config) : '/';
const { code, state, error } = req.query;
if (error) {
if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
@@@ -211,6 +234,9 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
try {
const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUriFor(req), code_verifier: flow.verifier });
setSession(res, data);
++ // Whatever this browser kept of the site was rendered for a guest: drop it, so the page it returns to
++ // (and Back) is drawn for the person who just signed in.
++ res.set('Clear-Site-Data', '"cache"');
return res.redirect(next);
} catch (err) {
log.warn('[OpenVibe.MediaHub] sign-in exchange failed:', err.status || '', err.error || '');
@@@ -232,7 +258,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
r.get('/logout', (req, res, next) => {
if (sameOriginRequest(req)) return next();
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ `<p>Sign out of OpenVibe.MediaHub?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
@@@ -248,8 +274,9 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}).catch(() => {});
}
clearSession(res);
++ res.set('Clear-Site-Data', '"cache"');
res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
-- res.redirect(303, sanitizeNext(req.query.next));
++ res.redirect(303, sanitizeNext(req.query.next, config));
});
r.get('/me', (req, res) => {
@@@ -261,7 +288,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
const v = req.viewer;
if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
-- res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
});
return r;
}
@@@ -269,4 +296,4 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
return { middleware, routes, asViewer, tokenGrant, pkcePair };
}
--module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 345eb4e..8dcb4b1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -64,7 +64,9 @@ function createPageRoutes(ctx) {
// The pages' forms are web forms (no JavaScript): urlencoded text, and one multipart file upload whose body the
// upload route reads itself. The JSON parser is deliberately absent here.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
-- const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++ // Revalidated on every view, as Video's and Pics' guest pages are (server/video/layout.js GUEST_HTML): Cloudflare drops
++ // `Vary`, so a guest copy kept in the browser would hide a sign-in.
++ const PUBLIC_CACHE = 'public, max-age=0, must-revalidate';
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const staffViewer = (req) => isStaff(req.viewer);
ddiff --git a/server/pics/layout.js b/server/pics/layout.js
iindex e6d41c2..226e9e4 100644
---- a/server/pics/layout.js
++++ b/server/pics/layout.js
@@@ -91,9 +91,13 @@ ${render(o.body)}
// The site's own places in the navbar: upload, your pictures and your albums (those two ask you to sign in).
const NAV_LINKS = [{ label: 'Upload', href: '/' }, { label: 'Your pictures', href: '/mine' }, { label: 'Albums', href: '/albums' }];
--/** Send a Pics page. Signed-out pages are public; a session cookie makes a page private, no-store. */
++// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
++// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page
++// (no owner tools, "Sign in to save"; 2026-10-22). Cloudflare does not cache these pages (DYNAMIC), so nothing is lost.
++const GUEST_HTML = 'public, max-age=0, must-revalidate';
++/** Send a Pics page. Signed-out pages are public but always revalidated; a session cookie makes a page private, no-store. */
function send(res, status, o) {
-- if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ private: o.private, maxAge: 300 }));
++ if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || (o.private ? cache.htmlHeaders({ private: true }) : GUEST_HTML));
res.set('Vary', 'Host');
res.status(status).type('html').send(renderPage(o));
}
ddiff --git a/server/video/layout.js b/server/video/layout.js
iindex 05841a9..d783e35 100644
---- a/server/video/layout.js
++++ b/server/video/layout.js
@@@ -95,9 +95,13 @@ ${render(o.body)}
// The site's own places in the navbar: the videos, and your playlists (signed in; the page asks you to sign in).
const NAV_LINKS = [{ label: 'Videos', href: '/' }, { label: 'Upload', href: '/upload' }, { label: 'Record', href: '/record' }, { label: 'Your videos', href: '/me/videos' }, { label: 'Playlists', href: '/playlists' }];
--/** Send a video page. Signed-out pages are public; a session cookie makes a page private, no-store. */
++// A signed-out copy is never reused without asking: Cloudflare drops `Vary` (it keeps only Accept-Encoding), so a
++// browser keyed the guest copy by its URL alone and, for five minutes after a quiet sign-in, showed the signed-out page
++// (no owner tools, "Sign in to save"; 2026-10-22). Cloudflare does not cache these pages (DYNAMIC), so nothing is lost.
++const GUEST_HTML = 'public, max-age=0, must-revalidate';
++/** Send a video page. Signed-out pages are public but always revalidated; a session cookie makes a page private, no-store. */
function send(res, status, o) {
-- if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ private: o.private, maxAge: 300 }));
++ if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || (o.private ? cache.htmlHeaders({ private: true }) : GUEST_HTML));
res.set('Vary', 'Host');
res.status(status).type('html').send(renderPage(o));
}
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
iindex 175c4fe..ab76aae 100644
---- a/test/open-redirect.test.js
++++ b/test/open-redirect.test.js
@@@ -17,4 +17,16 @@ for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com'
assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
}
assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
++
++// The shared navbar's silent sign-in sends the whole page address: on one of this service's own origins it is that
++// page's path (before 2026-10-22 it went to the home page); anywhere else, or a lookalike host, goes home.
++const hub = { baseUrl: 'https://openvibe.download', video: { url: 'https://openvibe.video' }, pics: { url: 'https://openvibe.pics' } };
++assert.strictEqual(sanitizeNext('https://openvibe.video/v/5902', hub), '/v/5902');
++assert.strictEqual(sanitizeNext('https://openvibe.video/v/5890?t=6528#c', hub), '/v/5890?t=6528#c');
++assert.strictEqual(sanitizeNext('https://openvibe.pics/a/abc', hub), '/a/abc');
++assert.strictEqual(sanitizeNext('https://openvibe.download/files', hub), '/files');
++for (const v of ['https://evil.com/v/1', 'https://openvibe.video.evil.com/v/1', 'https://[email protected]/', 'https://openvibe.video//evil.com', 'http://openvibe.video/v/1', 'https://openvibe.video/\\evil.com']) {
++ assert.strictEqual(sanitizeNext(v, hub), '/', v);
++}
++assert.strictEqual(sanitizeNext('https://openvibe.video/v/1'), '/', 'with no origins known, an absolute URL goes home');
console.log('open redirect: all checks passed');
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex bba39f1..5a955ed 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -51,6 +51,26 @@ const { signJwt } = require('./helpers/mocks');
assert.strictEqual(JSON.parse(me.text).user.username, staff.username);
});
++ await check('/auth/me carries the avatar and colour from the token, so the navbar shows the person\'s picture', async () => {
++ const token = (extra) => signJwt({ iss: s.network.url, sub: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username, role: 'user', aud: ['openvibe.network'], iat: now(), exp: now() + 300, ...extra }, s.network.privatePem);
++ const me = JSON.parse((await s.get('/auth/me', { cookie: `media-hub_at=${token({ avatar_url: 'https://openvibe.media/o/med_01JABCDEFGHJKMNPQRSTVWXYZ0', profile_color: '#0af' })}` })).text).user;
++ assert.deepStrictEqual([me.avatar_url, me.profile_color], ['https://openvibe.media/o/med_01JABCDEFGHJKMNPQRSTVWXYZ0', '#0af']);
++ const odd = JSON.parse((await s.get('/auth/me', { cookie: `media-hub_at=${token({ avatar_url: 'javascript:alert(1)', profile_color: 'red;x' })}` })).text).user;
++ assert.deepStrictEqual([odd.avatar_url, odd.profile_color], [null, null], 'only an https image and a hex colour pass');
++ });
++
++ await check('a quiet sign-in from a video page comes back to that page, not the home page', async () => {
++ const r = await s.get(`/auth/login?silent=1&next=${encodeURIComponent('https://openvibe.video/v/5902')}`);
++ assert.strictEqual(r.status, 302);
++ const flow = decodeURIComponent((String(r.headers.get('set-cookie') || '').match(/media-hub_oauth=([^;]+)/) || [])[1] || '');
++ assert.strictEqual(JSON.parse(flow).next, '/v/5902');
++ });
++
++ await check('signing out clears what the browser kept of the site', async () => {
++ const out = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(out.headers.get('clear-site-data'), '"cache"');
++ });
++
await check('another site cannot sign a person out; this site can', async () => {
const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });
assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');
ddiff --git a/test/video-playlists.test.js b/test/video-playlists.test.js
iindex 79cfbc7..1167e36 100644
---- a/test/video-playlists.test.js
++++ b/test/video-playlists.test.js
@@@ -234,7 +234,7 @@ const videoEnv = (live) => ({
const signedIn = await vreq(t.base, '/v/101', { cookie: kimCookie });
assert.strictEqual(signedIn.headers['cache-control'], 'private, no-store');
const signedOut = await vreq(t.base, '/v/101');
-- assert.match(signedOut.headers['cache-control'], /public, max-age=300/);
++ assert.strictEqual(signedOut.headers['cache-control'], 'public, max-age=0, must-revalidate', 'a guest copy is revalidated, never reused after a sign-in');
const lists = await vreq(t.base, '/playlists', { cookie: kimCookie });
assert.strictEqual(lists.headers['cache-control'], 'private, no-store');
});