openvibers/openvibe.host Commits
Trust only Host's own Host- session cookie on the dashboard (#16)
Changes
@@@ -284,7 +284,7 @@ Requests resolve their site once from the `Host` header, read the site's active**Why a separate registrable domain.** Tenant pages are arbitrary HTML and JavaScript. They live under `openvibe.host`, never under `openvibe.network` or `openvibe.live`, so no OpenVibe session cookie can ever reach them (`ovhost nginx tenants` refuses a sites domain under a first-party domain). The dashboard shares `openvibe.host` with the tenants, which makes tenant pages *same-site* with it. So:--- the dashboard trusts only `__Host-` prefixed cookies (a subdomain cannot set them); the plain `ov_token` cookie the shared navbar reads is display-only and is never trusted;++- the dashboard session is only Host's own `__Host-ov_host_session` cookie (HttpOnly, Secure, `Path=/`, no `Domain`; a subdomain cannot set it), written and cleared alongside the SDK's `ov_token` at sign-in, refresh and sign-out (`server/auth/sso.js`, read at `server/auth/viewer.js:99-103`). The plain `ov_token` cookie the shared navbar reads is display-only and is never trusted, a sign-in callback must match Host's own `__Host-ov_host_flow` state, and a refresh never changes the signed-in account. With `COOKIE_SECURE=false` (plain-http development) the names drop the `__Host-` prefix and are not Secure;- every dashboard form needs the dashboard's own `Origin` **and** a form token (an HMAC of the signed-in subject); `SameSite` alone would not stop a tenant page;- `/api/v1` ignores cookies entirely: `Authorization: Bearer` only;- nginx strips `Cookie` and `Authorization` from tenant requests, hides `Set-Cookie` from tenant responses and allows only `GET`/`HEAD` with a 1 KB body limit.@@@ -20,7 +20,7 @@ Status column says which risks are **fixed**, **mitigated**, **accepted** (with|---|---|| Other tenants' files, deploy history, logs and domains | `host_*` tables in `/var/lib/openvibe-host-api/host.db`; objects in `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>` || OpenVibe sessions (Network SSO tokens, other products' cookies) | browsers; Network |--| The dashboard session and form tokens | `__Host-ov_host_session` cookie; `HOST_FORM_SECRET` |++| The dashboard session and form tokens | `__Host-ov_host_session` cookie (`server/auth/sso.js:59-115`); `HOST_FORM_SECRET` || Host's own credentials (`OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`) | `/etc/openvibe/host.env` (0600) || Certificate private keys, DNS provider credentials | `/etc/letsencrypt`, `/root/.secrets` (root only; never read by Host) || The host itself: disk, memory and CPU shared with Live, Media, Chat and every database | the one production machine |@@@ -94,7 +94,8 @@ Tenant pages on `<site>.openvibe.host` are *same-site* with the dashboard on `op`host-isolation` "the API ignores cookies");- every dashboard POST needs the dashboard's own `Origin` (or `Referer`) **and** an HMAC form token(`server/auth/forms.js:21-35`);--- the dashboard trusts only `__Host-` cookies (`server/auth/sso.js:33`).++- the dashboard session is only Host's own `__Host-ov_host_session` cookie (`server/auth/viewer.js:99-103`++ reads it through `server/auth/sso.js:39-41`); the SDK's `ov_token` is display-only for the shared navbar.**Platform cookies never reach tenants.** Status: fixed by design.@@@ -168,10 +169,10 @@ escape a directory: there is no directory.| Threat | Handling | Status ||---|---|---|--| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, host-only; tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |--| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | the server trusts only `__Host-` cookies, which a subdomain cannot set (`sso.js:33`); a planted `ov_token` is ignored (`host-secrets-dashboard` "a planted ov_token cookie … is not a session") | fixed |--| **Login CSRF / forged dashboard actions from a same-site tenant page** | state cookie `__Host-` + OAuth `state`; every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |--| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard) | nuisance only | accepted |++| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, `Path=/`, no `Domain`, SameSite=Lax (`server/auth/sso.js:34-37,61`); tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |++| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | `ov_token` (openvibe-sdk/sso) is host-only but not HttpOnly, and a tenant page can plant its owner's own valid one with `Domain=openvibe.host; Path=/<longer path>`, which the browser sends first. The dashboard therefore reads only `__Host-ov_host_session` (`server/auth/viewer.js:99-103`), which a subdomain cannot set. Host writes it whenever the SDK writes `ov_token` after a sign-in or refresh, and clears it whenever the SDK clears `ov_token` (`server/auth/sso.js:85-99`). Inside `/auth` the SDK sees the trusted session as `ov_token` (`sso.js:65-69`), so `/auth/me` and the silent sign-in check ignore a planted one too. A planted `ov_refresh` (`Path=/auth/refresh`) renews only a session that already holds the same subject and never creates one: anything else answers 401 and sets no cookie (`sso.js:91,100-103`). Tests: `host-secrets-dashboard` "cookie tossing: a planted, validly signed ov_token …" (alone and next to a real session; forms land in the victim's own account) and "refresh renews the session of the same account only …" | fixed |++| **Login CSRF / forged dashboard actions from a same-site tenant page** | `/auth/login` stores the OAuth `state` in `__Host-ov_host_flow` and `/auth/callback` requires it to match (`server/auth/sso.js:71-82`), so a planted `ov_oauth_state`/`ov_oauth_verifier` cannot finish the attacker's own sign-in in the victim's browser (`host-secrets-dashboard` "sign-in issues the HttpOnly session cookie …"); every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |++| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard); likewise a planted *invalid* `ov_refresh` makes the next refresh fail and clear the session | nuisance only | accepted || **Cookie bomb** (a tenant sets many large `Domain=openvibe.host` cookies, so the visitor's browser sends oversized headers and nginx answers 400 for `openvibe.host` and every tenant site until the cookies expire or are cleared) | only the Public Suffix List stops a subdomain from setting parent-domain cookies (§5) | **accepted** for alpha; see §5 || `ov_token` (the Network access JWT) is JS-readable on `openvibe.host` for the shared navbar | host-only cookie: tenants cannot read it. A dashboard XSS could, and the dashboard CSP allows inline scripts (for the shared chrome). Tenant-controlled values (project names, file paths, log lines, host names) go through `esc()` in `server/render/pages.js`; ids are server-generated and site names match `[a-z0-9-]`. | mitigated; revisit when the shared chrome no longer needs inline script || Clickjacking the dashboard | `frame-ancestors 'none'`, `X-Frame-Options: DENY` (`server/app.js:47-60`) | fixed |@@@ -35,6 +35,7 @@ const { createDomains } = require('./domain/domains');const { createSiteConfig } = require('./domain/site-config');const { createSsoClient } = require('openvibe-sdk/sso');const { createViewerResolver } = require('./auth/viewer');++const { createHostSession } = require('./auth/sso');const { createTenantServer } = require('./http/tenant');const { createUploadGate } = require('./http/upload');const { createApi } = require('./http/api');@@@ -210,7 +211,7 @@ async function createApp(opts = {}) {// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));-- app.use('/auth', auth.router(express));++ app.use('/auth', createHostSession({ auth, config }).router(express));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }// ── Static assets (content-hashed ?v= → immutable) ──────nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++++/**++ * The dashboard's own session cookie, around the openvibe-sdk/sso /auth router.++ *++ * openvibe-sdk/sso keeps the session in `ov_token`: host-only, not HttpOnly (the shared navbar reads++ * it). Tenant sites are served at <site>.openvibe.host, the same site as the dashboard, so a tenant++ * page can plant `ov_token=<its owner's own valid token>; Domain=openvibe.host; Path=/<longer path>`++ * and the browser sends it ahead of the real one (cookie tossing). Host therefore trusts only its own++ * cookie, which no subdomain can set or overwrite:++ *++ * __Host-ov_host_session HttpOnly, Secure, Path=/, no Domain, SameSite=Lax; the verified access++ * token, written whenever the SDK writes ov_token after a sign-in++ * (callback, FedCM) or a refresh, and cleared whenever it clears ov_token++ * (sign-out, a refresh the Network refuses).++ * __Host-ov_host_flow the OAuth state of a sign-in started here; /auth/callback requires it,++ * so a planted ov_oauth_state/ov_oauth_verifier cannot complete the++ * attacker's own sign-in in the victim's browser.++ *++ * ov_refresh (Path=/auth) can be planted too: a refresh renews the session only for the subject the++ * session already holds, and never creates one. Inside /auth the SDK sees the trusted session as++ * ov_token (/auth/me and the silent sign-in check never read a planted one). ov_token stays++ * display-only for the shared navbar.++ *++ * Development (COOKIE_SECURE=false, plain http): browsers refuse __Host- cookies without Secure, so++ * the names drop the prefix (ov_host_session, ov_host_flow) and are not Secure.++ */++const crypto = require('crypto');++const { COOKIES, decodeJwtPayload, parseCookies } = require('openvibe-sdk/sso');++++const SESSION_MAX_AGE = 30 * 24 * 3600e3; // the SDK's ov_refresh lifetime: a refresh always finds the session it renews++const FLOW_MAX_AGE = 10 * 60e3;++++function cookieNames(config) {++ const prefix = config.cookies.secure ? '__Host-' : '';++ return { session: `${prefix}ov_host_session`, flow: `${prefix}ov_host_flow` };++}++++/** The session token this browser holds on the dashboard, or null (never ov_token). */++function sessionToken(config, req) {++ return parseCookies(req)[cookieNames(config).session] || null;++}++++const subjectOf = (token) => { const c = token ? decodeJwtPayload(token) : null; return c && typeof c.subject_id === 'string' ? c.subject_id : null; };++++function sameString(a, b) {++ const x = Buffer.from(String(a || '')), y = Buffer.from(String(b || ''));++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);++}++++/** The state of a redirect to the Network's authorize endpoint, or null. */++function authorizeState(url) {++ try {++ const u = new URL(String(url));++ return u.pathname.endsWith('/oauth/authorize') ? u.searchParams.get('state') : null;++ } catch { return null; }++}++++function createHostSession({ auth, config }) {++ const names = cookieNames(config);++ const opts = (maxAge) => ({ httpOnly: true, secure: config.cookies.secure, sameSite: 'lax', path: '/', ...(maxAge ? { maxAge } : {}) });++++ function guard(req, res, next) {++ const cookies = parseCookies(req);++ const current = cookies[names.session] || null;++ // The SDK handlers read ov_token from req.cookies: give them the trusted session instead.++ if (req.cookies && typeof req.cookies === 'object') {++ if (current) req.cookies[COOKIES.access] = current; else delete req.cookies[COOKIES.access];++ }++++ if (req.path === '/callback' && !(req.query && req.query.error)) {++ res.clearCookie(names.flow, opts());++ if (!sameString(req.query && req.query.state, cookies[names.flow])) return res.status(400).send('OAuth state mismatch. Please try signing in again.');++ }++ if (req.path === '/login') {++ const redirect = res.redirect.bind(res);++ res.redirect = (...args) => {++ const state = authorizeState(args[args.length - 1]);++ if (state) res.cookie(names.flow, state, opts(FLOW_MAX_AGE));++ return redirect(...args);++ };++ }++++ const setCookie = res.cookie.bind(res);++ const clearCookie = res.clearCookie.bind(res);++ let refused = false;++ res.cookie = (name, value, o) => {++ if (refused && name === COOKIES.refresh) return res;++ if (name === COOKIES.access) {++ // A refresh renews this browser's session; a planted ov_refresh of another account is refused.++ if (req.path === '/refresh' && (!subjectOf(current) || subjectOf(current) !== subjectOf(value))) { refused = true; return res; }++ setCookie(names.session, value, opts(SESSION_MAX_AGE));++ }++ return setCookie(name, value, o);++ };++ res.clearCookie = (name, o) => {++ if (name === COOKIES.access) clearCookie(names.session, opts());++ return clearCookie(name, o);++ };++ if (req.path === '/refresh') {++ const json = res.json.bind(res);++ res.json = (body) => (refused ? (res.status(401), json({ error: 'The session ended. Please sign in again.' })) : json(body));++ }++ next();++ }++++ /** The /auth router: Host's guard, then the SDK's handlers. */++ function router(express) {++ const r = express.Router();++ r.use(guard);++ r.use(auth.router(express));++ return r;++ }++++ return { names, router };++}++++module.exports = { createHostSession, cookieNames, sessionToken };@@@ -6,7 +6,8 @@* { kind: 'anonymous' }* { kind: 'user', subject: 'usr_…', staff, user }* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).-- * Dashboard: the ov_token session cookie (openvibe-sdk/sso; the shared navbar reads the same cookie).++ * Dashboard: only Host's own __Host-ov_host_session cookie (./sso.js). ov_token (openvibe-sdk/sso)++ * is display-only for the shared navbar: a tenant page on <site>.openvibe.host can plant one.* staff = the contracts staff map's staff.site.configure (ADR-022):* quotas, takedowns, and maintaining or deleting any project (never deploying to it).* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }@@@ -19,9 +20,10 @@* never downgraded to anonymous.*/const contracts = require('openvibe-contracts');--const { claimsToUser, decodeJwtPayload, parseCookies, COOKIES } = require('openvibe-sdk/sso');++const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');const { verifyServiceToken } = require('openvibe-sdk/auth');const { checkCapability } = require('./capabilities');++const { sessionToken } = require('./sso');const { ids, http, staff: staffMap } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod):/;@@@ -95,7 +97,7 @@ function createViewerResolver({ auth, config, log = console }) {}async function resolveDashboard(req) {-- const token = parseCookies(req)[COOKIES.access] || null;++ const token = sessionToken(config, req);if (!token) return ANONYMOUS;return (await fromUserToken(token)) || ANONYMOUS;}@@@ -8,6 +8,7 @@ const assert = require('assert');const { boot, check, done, SECRET } = require('./stageb/boot');const { tarball } = require('./stageb/tar');const { csrfToken } = require('../server/auth/forms');++const { cookieNames } = require('../server/auth/sso');const ORIGIN = 'https://openvibe.host';@@@ -81,8 +82,8 @@ const ORIGIN = 'https://openvibe.host';for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);});-- await check('the dashboard session is the verified ov_token cookie; a forged ov_token is not a session', async () => {-- // openvibe-sdk/sso keeps the session in the ov_token cookie (the shared navbar reads the same one).++ await check('the dashboard session is Host\'s own cookie; a forged ov_token is not a session', async () => {++ // openvibe-sdk/sso keeps its session in ov_token (the shared navbar reads it); Host trusts only its own cookie.const forged = 'not.a.jwt';const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });assert.match(r.text, /Sign in with OpenVibe/);@@@ -93,6 +94,101 @@ const ORIGIN = 'https://openvibe.host';assert.strictEqual(api.status, 401);});++ const SESSION = cookieNames(t.config).session; // ov_host_session here (COOKIE_SECURE=false); __Host- in production++ const mallory = t.user('mallory');++ const malloryProjects = async () => (await t.api('GET', '/api/v1/projects', { as: mallory })).json().projects.length;++ const setCookies = (r) => [].concat(r.headers['set-cookie'] || []);++ const cookieValue = (r, name) => { const c = setCookies(r).find((x) => x.startsWith(`${name}=`)); return c === undefined ? undefined : decodeURIComponent(c.slice(name.length + 1).split(';')[0]); };++++ await check('cookie tossing: a planted, validly signed ov_token of another user is not a session, alone or next to a real one', async () => {++ // A tenant page on <site>.openvibe.host can set ov_token=<its owner's token>; Domain=openvibe.host; Path=/<longer path>:++ // the browser sends it first. It verifies, and it is still ignored.++ const planted = `ov_token=${t.network.userToken(mallory)}`;++ const alone = await t.api('GET', '/', { headers: { cookie: planted } });++ assert.match(alone.text, /Sign in with OpenVibe/);++ assert.strictEqual((await t.api('GET', '/auth/me', { headers: { cookie: planted } })).status, 401);++ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: mallory.subject });++ const post = await t.api('POST', '/projects', { form: { csrf, name: 'Tossed', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });++ assert.ok(!/\/projects\/prj_/.test(post.headers.location || ''), post.text);++ assert.strictEqual(await malloryProjects(), 0);++++ const both = await t.api('GET', '/', { session: alice, headers: { cookie: planted } });++ assert.match(both.text, /Your projects/);++ assert.ok(!/mallory/.test(both.text), 'the page is alice\'s, not mallory\'s');++ const me = await t.api('GET', '/auth/me', { session: alice, headers: { cookie: planted } });++ assert.strictEqual(me.json().user.subject_id, alice.subject);++ const csrfA = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });++ const asAlice = await t.api('POST', '/projects', { session: alice, form: { csrf: csrfA, name: 'Tossed but mine', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });++ assert.strictEqual(asAlice.status, 303, asAlice.text);++ assert.strictEqual(await malloryProjects(), 0, 'nothing lands in mallory\'s account');++ const mine = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects;++ assert.ok(mine.some((p) => p.name === 'Tossed but mine'));++ for (const p of mine.filter((x) => x.name === 'Tossed but mine')) await t.api('DELETE', `/api/v1/projects/${p.id}`, { as: alice });++ });++++ await check('sign-in issues the HttpOnly session cookie; a planted OAuth state cannot finish another account\'s sign-in', async () => {++ const login = await t.api('GET', '/auth/login');++ assert.strictEqual(login.status, 302);++ const state = new URL(login.headers.location).searchParams.get('state');++ assert.strictEqual(cookieValue(login, 'ov_host_flow'), state);++ const flow = `ov_host_flow=${state}; ov_oauth_state=${state}; ov_oauth_verifier=${cookieValue(login, 'ov_oauth_verifier')}`;++ const cb = await t.api('GET', `/auth/callback?code=${t.network.issueCode(alice)}&state=${state}`, { headers: { cookie: flow } });++ assert.strictEqual(cb.status, 302, cb.text);++ const set = setCookies(cb).find((c) => c.startsWith(`${SESSION}=`));++ assert.match(set, /; Path=\/;/);++ assert.match(set, /; HttpOnly/);++ assert.match(set, /; SameSite=Lax/);++ assert.ok(!/Domain=/i.test(set), set);++ const dash = await t.api('GET', '/', { headers: { cookie: `${SESSION}=${cookieValue(cb, SESSION)}` } });++ assert.match(dash.text, /Your projects/);++++ // Mallory starts a sign-in of her own, plants her state and verifier (Path=/auth/callback) and sends alice to the callback.++ const theirs = await t.api('GET', '/auth/login');++ const s2 = new URL(theirs.headers.location).searchParams.get('state');++ const tossed = `ov_oauth_state=${s2}; ov_oauth_verifier=${cookieValue(theirs, 'ov_oauth_verifier')}`;++ for (const cookie of [tossed, `${tossed}; ov_host_flow=${state}`]) {++ const r = await t.api('GET', `/auth/callback?code=${t.network.issueCode(mallory)}&state=${s2}`, { headers: { cookie } });++ assert.strictEqual(r.status, 400, r.text);++ assert.strictEqual(cookieValue(r, SESSION), undefined);++ }++ });++++ await check('refresh renews the session of the same account only; sign-out clears it', async () => {++ const session = `${SESSION}=${t.network.userToken(alice)}`;++ const ok = await t.api('POST', '/auth/refresh', { headers: { cookie: `${session}; ov_refresh=${t.network.refreshToken(alice)}` } });++ assert.strictEqual(ok.status, 200, ok.text);++ assert.strictEqual(require('openvibe-sdk/sso').decodeJwtPayload(cookieValue(ok, SESSION)).subject_id, alice.subject);++ // A planted ov_refresh of mallory's (Path=/auth/refresh), next to alice's session or alone, never becomes a session.++ for (const cookie of [`${session}; ov_refresh=${t.network.refreshToken(mallory)}`, `ov_refresh=${t.network.refreshToken(mallory)}`]) {++ const r = await t.api('POST', '/auth/refresh', { headers: { cookie } });++ assert.strictEqual(r.status, 401, r.text);++ assert.strictEqual(cookieValue(r, SESSION), undefined);++ assert.strictEqual(cookieValue(r, 'ov_token'), undefined);++ assert.strictEqual(cookieValue(r, 'ov_refresh'), undefined);++ }++ const out = await t.api('GET', '/auth/logout', { headers: { cookie: session } });++ assert.strictEqual(out.status, 302);++ assert.strictEqual(cookieValue(out, SESSION), '');++ assert.match(setCookies(out).find((c) => c.startsWith(`${SESSION}=`)), /Expires=Thu, 01 Jan 1970/);++ });++++ await check('in production the session cookie is __Host- prefixed and Secure', async () => {++ assert.deepStrictEqual(cookieNames({ cookies: { secure: true } }), { session: '__Host-ov_host_session', flow: '__Host-ov_host_flow' });++ // The guard around a stand-in SDK router whose callback writes ov_token, as setSession does.++ const express = require('express');++ const app = express();++ app.use(require('cookie-parser')());++ const auth = { router: (ex) => ex.Router().get('/callback', (_req, res) => { res.cookie('ov_token', 'tok', {}); res.send('ok'); }) };++ app.use('/auth', require('../server/auth/sso').createHostSession({ auth, config: { cookies: { secure: true } } }).router(express));++ const srv = await new Promise((resolve) => { const x = app.listen(0, '127.0.0.1', () => resolve(x)); });++ try {++ const r = await fetch(`http://127.0.0.1:${srv.address().port}/auth/callback?state=s1`, { headers: { cookie: '__Host-ov_host_flow=s1' } });++ assert.strictEqual(r.status, 200);++ const set = r.headers.getSetCookie().find((c) => c.startsWith('__Host-ov_host_session='));++ assert.match(set, /^__Host-ov_host_session=tok; Max-Age=\d+; Path=\/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax$/);++ } finally { await new Promise((resolve) => srv.close(resolve)); }++ });++let projectId, siteId;await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });@@@ -15,6 +15,7 @@ const path = require('path');const http = require('http');const { startNetwork } = require('./mocks');const { site: siteTar } = require('./tar');++const { cookieNames } = require('../../server/auth/sso');// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own// (test/actor-limits.test.js), so they are off here unless a test turns them on.@@@ -84,7 +85,8 @@ async function boot(opts = {}) {if (o.host !== undefined) headers.host = o.host;if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;-- if (o.session) headers.cookie = [headers.cookie, `ov_token=${network.userToken(o.session)}`].filter(Boolean).join('; ');++ // A dashboard session is Host's own cookie (server/auth/sso.js); ov_token is display-only.++ if (o.session) headers.cookie = [headers.cookie, `${cookieNames(t.config).session}=${network.userToken(o.session)}`].filter(Boolean).join('; ');let body = o.body;if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }@@@ -1,7 +1,8 @@'use strict';/*** In-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS and /oauth/token-- * (client_credentials → service tokens with the requested scope as capabilities).++ * (client_credentials → service tokens with the requested scope as capabilities; authorization_code and++ * refresh_token → user tokens for issueCode()/refreshToken()).* userToken()/serviceToken()/appToken() mint what browsers, services and apps present to Host.*/const http = require('http');@@@ -42,6 +43,13 @@ async function startNetwork() {const cap = String(scope || '').split(/\s+/).filter(Boolean);return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });}++ // Browser sign-in: a code from issueCode(), a refresh token from refreshToken() (single use, rotated).++ const grant = body.grant_type === 'authorization_code' ? grants.get(`code:${body.code}`) : body.grant_type === 'refresh_token' ? grants.get(`rt:${body.refresh_token}`) : undefined;++ if (grant === undefined && ['authorization_code', 'refresh_token'].includes(body.grant_type)) return json(400, { error: 'invalid_grant' });++ if (grant) {++ grants.delete(body.grant_type === 'authorization_code' ? `code:${body.code}` : `rt:${body.refresh_token}`);++ return json(200, { access_token: userToken(grant), refresh_token: refreshToken(grant), token_type: 'Bearer', expires_in: 3600 });++ }return json(400, { error: 'unsupported_grant_type' });}return json(404, { error: 'not found' });@@@ -50,6 +58,9 @@ async function startNetwork() {function sign({ sub, actor_type = 'service', aud, cap, ...extra }) {return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);}++ const grants = new Map();++ const issueCode = (u) => { const c = crypto.randomBytes(12).toString('hex'); grants.set(`code:${c}`, u); return c; };++ const refreshToken = (u) => { const r = crypto.randomBytes(12).toString('hex'); grants.set(`rt:${r}`, u); return r; };function addUser(username, extra = {}) {return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };}@@@ -67,7 +78,7 @@ async function startNetwork() {function appToken(appId, cap, extra = {}) {return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', env: 'production', ...extra });}-- return { ...srv, publicPem, addUser, userToken, fedcmAssertion, serviceToken, appToken, sign };++ return { ...srv, publicPem, addUser, userToken, issueCode, refreshToken, fedcmAssertion, serviceToken, appToken, sign };}module.exports = { startNetwork, listen };
@@ -284,7 +284,7 @@ Requests resolve their site once from the `Host` header, read the site's active
@@ -284,7 +284,7 @@ Requests resolve their site once from the `Host` header, read the site's active
**Why a separate registrable domain.** Tenant pages are arbitrary HTML and JavaScript. They live under `openvibe.host`, never under `openvibe.network` or `openvibe.live`, so no OpenVibe session cookie can ever reach them (`ovhost nginx tenants` refuses a sites domain under a first-party domain). The dashboard shares `openvibe.host` with the tenants, which makes tenant pages *same-site* with it. So:
**Why a separate registrable domain.** Tenant pages are arbitrary HTML and JavaScript. They live under `openvibe.host`, never under `openvibe.network` or `openvibe.live`, so no OpenVibe session cookie can ever reach them (`ovhost nginx tenants` refuses a sites domain under a first-party domain). The dashboard shares `openvibe.host` with the tenants, which makes tenant pages *same-site* with it. So:
-- the dashboard trusts only `__Host-` prefixed cookies (a subdomain cannot set them); the plain `ov_token` cookie the shared navbar reads is display-only and is never trusted;
+- the dashboard session is only Host's own `__Host-ov_host_session` cookie (HttpOnly, Secure, `Path=/`, no `Domain`; a subdomain cannot set it), written and cleared alongside the SDK's `ov_token` at sign-in, refresh and sign-out (`server/auth/sso.js`, read at `server/auth/viewer.js:99-103`). The plain `ov_token` cookie the shared navbar reads is display-only and is never trusted, a sign-in callback must match Host's own `__Host-ov_host_flow` state, and a refresh never changes the signed-in account. With `COOKIE_SECURE=false` (plain-http development) the names drop the `__Host-` prefix and are not Secure;
- every dashboard form needs the dashboard's own `Origin` **and** a form token (an HMAC of the signed-in subject); `SameSite` alone would not stop a tenant page;
- every dashboard form needs the dashboard's own `Origin` **and** a form token (an HMAC of the signed-in subject); `SameSite` alone would not stop a tenant page;
- `/api/v1` ignores cookies entirely: `Authorization: Bearer` only;
- `/api/v1` ignores cookies entirely: `Authorization: Bearer` only;
- nginx strips `Cookie` and `Authorization` from tenant requests, hides `Set-Cookie` from tenant responses and allows only `GET`/`HEAD` with a 1 KB body limit.
- nginx strips `Cookie` and `Authorization` from tenant requests, hides `Set-Cookie` from tenant responses and allows only `GET`/`HEAD` with a 1 KB body limit.
@@ -20,7 +20,7 @@ Status column says which risks are **fixed**, **mitigated**, **accepted** (with
@@ -20,7 +20,7 @@ Status column says which risks are **fixed**, **mitigated**, **accepted** (with
|---|---|
|---|---|
| Other tenants' files, deploy history, logs and domains | `host_*` tables in `/var/lib/openvibe-host-api/host.db`; objects in `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>` |
| Other tenants' files, deploy history, logs and domains | `host_*` tables in `/var/lib/openvibe-host-api/host.db`; objects in `<HOST_STORAGE_DIR>/projects/<prj_…>/<aa>/<sha256>` |
| OpenVibe sessions (Network SSO tokens, other products' cookies) | browsers; Network |
| OpenVibe sessions (Network SSO tokens, other products' cookies) | browsers; Network |
-| The dashboard session and form tokens | `__Host-ov_host_session` cookie; `HOST_FORM_SECRET` |
+| The dashboard session and form tokens | `__Host-ov_host_session` cookie (`server/auth/sso.js:59-115`); `HOST_FORM_SECRET` |
| Host's own credentials (`OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`) | `/etc/openvibe/host.env` (0600) |
| Host's own credentials (`OV_OAUTH_CLIENT_SECRET`, `HOST_FORM_SECRET`) | `/etc/openvibe/host.env` (0600) |
| Certificate private keys, DNS provider credentials | `/etc/letsencrypt`, `/root/.secrets` (root only; never read by Host) |
| Certificate private keys, DNS provider credentials | `/etc/letsencrypt`, `/root/.secrets` (root only; never read by Host) |
| The host itself: disk, memory and CPU shared with Live, Media, Chat and every database | the one production machine |
| The host itself: disk, memory and CPU shared with Live, Media, Chat and every database | the one production machine |
@@ -94,7 +94,8 @@ Tenant pages on `<site>.openvibe.host` are *same-site* with the dashboard on `op
@@ -94,7 +94,8 @@ Tenant pages on `<site>.openvibe.host` are *same-site* with the dashboard on `op
`host-isolation` "the API ignores cookies");
`host-isolation` "the API ignores cookies");
- every dashboard POST needs the dashboard's own `Origin` (or `Referer`) **and** an HMAC form token
- every dashboard POST needs the dashboard's own `Origin` (or `Referer`) **and** an HMAC form token
(`server/auth/forms.js:21-35`);
(`server/auth/forms.js:21-35`);
-- the dashboard trusts only `__Host-` cookies (`server/auth/sso.js:33`).
+- the dashboard session is only Host's own `__Host-ov_host_session` cookie (`server/auth/viewer.js:99-103`
+ reads it through `server/auth/sso.js:39-41`); the SDK's `ov_token` is display-only for the shared navbar.
**Platform cookies never reach tenants.** Status: fixed by design.
**Platform cookies never reach tenants.** Status: fixed by design.
@@ -168,10 +169,10 @@ escape a directory: there is no directory.
@@ -168,10 +169,10 @@ escape a directory: there is no directory.
| Threat | Handling | Status |
| Threat | Handling | Status |
|---|---|---|
|---|---|---|
-| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, host-only; tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |
-| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | the server trusts only `__Host-` cookies, which a subdomain cannot set (`sso.js:33`); a planted `ov_token` is ignored (`host-secrets-dashboard` "a planted ov_token cookie … is not a session") | fixed |
-| **Login CSRF / forged dashboard actions from a same-site tenant page** | state cookie `__Host-` + OAuth `state`; every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |
-| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard) | nuisance only | accepted |
+| A tenant page reads the dashboard session | the session is `__Host-ov_host_session`: HttpOnly, Secure, `Path=/`, no `Domain`, SameSite=Lax (`server/auth/sso.js:34-37,61`); tenant origins are different hosts; nginx strips `Cookie` from tenant requests | fixed |
+| **Cookie tossing** (a tenant sets `Domain=openvibe.host` cookies to fix a session or fake a sign-in) | `ov_token` (openvibe-sdk/sso) is host-only but not HttpOnly, and a tenant page can plant its owner's own valid one with `Domain=openvibe.host; Path=/<longer path>`, which the browser sends first. The dashboard therefore reads only `__Host-ov_host_session` (`server/auth/viewer.js:99-103`), which a subdomain cannot set. Host writes it whenever the SDK writes `ov_token` after a sign-in or refresh, and clears it whenever the SDK clears `ov_token` (`server/auth/sso.js:85-99`). Inside `/auth` the SDK sees the trusted session as `ov_token` (`sso.js:65-69`), so `/auth/me` and the silent sign-in check ignore a planted one too. A planted `ov_refresh` (`Path=/auth/refresh`) renews only a session that already holds the same subject and never creates one: anything else answers 401 and sets no cookie (`sso.js:91,100-103`). Tests: `host-secrets-dashboard` "cookie tossing: a planted, validly signed ov_token …" (alone and next to a real session; forms land in the victim's own account) and "refresh renews the session of the same account only …" | fixed |
+| **Login CSRF / forged dashboard actions from a same-site tenant page** | `/auth/login` stores the OAuth `state` in `__Host-ov_host_flow` and `/auth/callback` requires it to match (`server/auth/sso.js:71-82`), so a planted `ov_oauth_state`/`ov_oauth_verifier` cannot finish the attacker's own sign-in in the victim's browser (`host-secrets-dashboard` "sign-in issues the HttpOnly session cookie …"); every POST needs the dashboard `Origin` and the HMAC form token (`forms.js`); tested with same-site tenant origins | fixed |
+| **Logout CSRF** (`GET /auth/logout` from a tenant page signs the visitor out of the dashboard); likewise a planted *invalid* `ov_refresh` makes the next refresh fail and clear the session | nuisance only | accepted |
| **Cookie bomb** (a tenant sets many large `Domain=openvibe.host` cookies, so the visitor's browser sends oversized headers and nginx answers 400 for `openvibe.host` and every tenant site until the cookies expire or are cleared) | only the Public Suffix List stops a subdomain from setting parent-domain cookies (§5) | **accepted** for alpha; see §5 |
| **Cookie bomb** (a tenant sets many large `Domain=openvibe.host` cookies, so the visitor's browser sends oversized headers and nginx answers 400 for `openvibe.host` and every tenant site until the cookies expire or are cleared) | only the Public Suffix List stops a subdomain from setting parent-domain cookies (§5) | **accepted** for alpha; see §5 |
| `ov_token` (the Network access JWT) is JS-readable on `openvibe.host` for the shared navbar | host-only cookie: tenants cannot read it. A dashboard XSS could, and the dashboard CSP allows inline scripts (for the shared chrome). Tenant-controlled values (project names, file paths, log lines, host names) go through `esc()` in `server/render/pages.js`; ids are server-generated and site names match `[a-z0-9-]`. | mitigated; revisit when the shared chrome no longer needs inline script |
| `ov_token` (the Network access JWT) is JS-readable on `openvibe.host` for the shared navbar | host-only cookie: tenants cannot read it. A dashboard XSS could, and the dashboard CSP allows inline scripts (for the shared chrome). Tenant-controlled values (project names, file paths, log lines, host names) go through `esc()` in `server/render/pages.js`; ids are server-generated and site names match `[a-z0-9-]`. | mitigated; revisit when the shared chrome no longer needs inline script |
| Clickjacking the dashboard | `frame-ancestors 'none'`, `X-Frame-Options: DENY` (`server/app.js:47-60`) | fixed |
| Clickjacking the dashboard | `frame-ancestors 'none'`, `X-Frame-Options: DENY` (`server/app.js:47-60`) | fixed |
@@ -35,6 +35,7 @@ const { createDomains } = require('./domain/domains');
@@ -35,6 +35,7 @@ const { createDomains } = require('./domain/domains');
const { createSiteConfig } = require('./domain/site-config');
const { createSiteConfig } = require('./domain/site-config');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
+const { createHostSession } = require('./auth/sso');
const { createTenantServer } = require('./http/tenant');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
const { createUploadGate } = require('./http/upload');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -210,7 +211,7 @@ async function createApp(opts = {}) {
@@ -210,7 +211,7 @@ async function createApp(opts = {}) {
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
- app.use('/auth', auth.router(express));
+ app.use('/auth', createHostSession({ auth, config }).router(express));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+
+/**
+ * The dashboard's own session cookie, around the openvibe-sdk/sso /auth router.
+ *
+ * openvibe-sdk/sso keeps the session in `ov_token`: host-only, not HttpOnly (the shared navbar reads
+ * it). Tenant sites are served at <site>.openvibe.host, the same site as the dashboard, so a tenant
+ * page can plant `ov_token=<its owner's own valid token>; Domain=openvibe.host; Path=/<longer path>`
+ * and the browser sends it ahead of the real one (cookie tossing). Host therefore trusts only its own
+ * cookie, which no subdomain can set or overwrite:
+ *
+ * __Host-ov_host_session HttpOnly, Secure, Path=/, no Domain, SameSite=Lax; the verified access
+ * token, written whenever the SDK writes ov_token after a sign-in
+ * (callback, FedCM) or a refresh, and cleared whenever it clears ov_token
+ * (sign-out, a refresh the Network refuses).
+ * __Host-ov_host_flow the OAuth state of a sign-in started here; /auth/callback requires it,
+ * so a planted ov_oauth_state/ov_oauth_verifier cannot complete the
+ * attacker's own sign-in in the victim's browser.
+ *
+ * ov_refresh (Path=/auth) can be planted too: a refresh renews the session only for the subject the
+ * session already holds, and never creates one. Inside /auth the SDK sees the trusted session as
+ * ov_token (/auth/me and the silent sign-in check never read a planted one). ov_token stays
+ * display-only for the shared navbar.
+ *
+ * Development (COOKIE_SECURE=false, plain http): browsers refuse __Host- cookies without Secure, so
+ * the names drop the prefix (ov_host_session, ov_host_flow) and are not Secure.
+ */
+const crypto = require('crypto');
+const { COOKIES, decodeJwtPayload, parseCookies } = require('openvibe-sdk/sso');
+
+const SESSION_MAX_AGE = 30 * 24 * 3600e3; // the SDK's ov_refresh lifetime: a refresh always finds the session it renews
+const FLOW_MAX_AGE = 10 * 60e3;
+
+function cookieNames(config) {
+ const prefix = config.cookies.secure ? '__Host-' : '';
+ return { session: `${prefix}ov_host_session`, flow: `${prefix}ov_host_flow` };
+}
+
+/** The session token this browser holds on the dashboard, or null (never ov_token). */
+function sessionToken(config, req) {
+ return parseCookies(req)[cookieNames(config).session] || null;
+}
+
+const subjectOf = (token) => { const c = token ? decodeJwtPayload(token) : null; return c && typeof c.subject_id === 'string' ? c.subject_id : null; };
+
+function sameString(a, b) {
+ const x = Buffer.from(String(a || '')), y = Buffer.from(String(b || ''));
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
+}
+
+/** The state of a redirect to the Network's authorize endpoint, or null. */
+function authorizeState(url) {
+ try {
+ const u = new URL(String(url));
+ return u.pathname.endsWith('/oauth/authorize') ? u.searchParams.get('state') : null;
+ } catch { return null; }
+}
+
+function createHostSession({ auth, config }) {
+ const names = cookieNames(config);
+ const opts = (maxAge) => ({ httpOnly: true, secure: config.cookies.secure, sameSite: 'lax', path: '/', ...(maxAge ? { maxAge } : {}) });
+
+ function guard(req, res, next) {
+ const cookies = parseCookies(req);
+ const current = cookies[names.session] || null;
+ // The SDK handlers read ov_token from req.cookies: give them the trusted session instead.
+ if (req.cookies && typeof req.cookies === 'object') {
+ if (current) req.cookies[COOKIES.access] = current; else delete req.cookies[COOKIES.access];
+ }
+
+ if (req.path === '/callback' && !(req.query && req.query.error)) {
+ res.clearCookie(names.flow, opts());
+ if (!sameString(req.query && req.query.state, cookies[names.flow])) return res.status(400).send('OAuth state mismatch. Please try signing in again.');
+ }
+ if (req.path === '/login') {
+ const redirect = res.redirect.bind(res);
+ res.redirect = (...args) => {
+ const state = authorizeState(args[args.length - 1]);
+ if (state) res.cookie(names.flow, state, opts(FLOW_MAX_AGE));
+ return redirect(...args);
+ };
+ }
+
+ const setCookie = res.cookie.bind(res);
+ const clearCookie = res.clearCookie.bind(res);
+ let refused = false;
+ res.cookie = (name, value, o) => {
+ if (refused && name === COOKIES.refresh) return res;
+ if (name === COOKIES.access) {
+ // A refresh renews this browser's session; a planted ov_refresh of another account is refused.
+ if (req.path === '/refresh' && (!subjectOf(current) || subjectOf(current) !== subjectOf(value))) { refused = true; return res; }
+ setCookie(names.session, value, opts(SESSION_MAX_AGE));
+ }
+ return setCookie(name, value, o);
+ };
+ res.clearCookie = (name, o) => {
+ if (name === COOKIES.access) clearCookie(names.session, opts());
+ return clearCookie(name, o);
+ };
+ if (req.path === '/refresh') {
+ const json = res.json.bind(res);
+ res.json = (body) => (refused ? (res.status(401), json({ error: 'The session ended. Please sign in again.' })) : json(body));
+ }
+ next();
+ }
+
+ /** The /auth router: Host's guard, then the SDK's handlers. */
+ function router(express) {
+ const r = express.Router();
+ r.use(guard);
+ r.use(auth.router(express));
+ return r;
+ }
+
+ return { names, router };
+}
+
+module.exports = { createHostSession, cookieNames, sessionToken };
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* { kind: 'anonymous' }
* { kind: 'anonymous' }
* { kind: 'user', subject: 'usr_…', staff, user }
* { kind: 'user', subject: 'usr_…', staff, user }
* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).
* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).
- * Dashboard: the ov_token session cookie (openvibe-sdk/sso; the shared navbar reads the same cookie).
+ * Dashboard: only Host's own __Host-ov_host_session cookie (./sso.js). ov_token (openvibe-sdk/sso)
+ * is display-only for the shared navbar: a tenant page on <site>.openvibe.host can plant one.
* staff = the contracts staff map's staff.site.configure (ADR-022):
* staff = the contracts staff map's staff.site.configure (ADR-022):
* quotas, takedowns, and maintaining or deleting any project (never deploying to it).
* quotas, takedowns, and maintaining or deleting any project (never deploying to it).
* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
@@ -19,9 +20,10 @@
@@ -19,9 +20,10 @@
* never downgraded to anonymous.
* never downgraded to anonymous.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { claimsToUser, decodeJwtPayload, parseCookies, COOKIES } = require('openvibe-sdk/sso');
+const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
const { verifyServiceToken } = require('openvibe-sdk/auth');
const { verifyServiceToken } = require('openvibe-sdk/auth');
const { checkCapability } = require('./capabilities');
const { checkCapability } = require('./capabilities');
+const { sessionToken } = require('./sso');
const { ids, http, staff: staffMap } = contracts;
const { ids, http, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
@@ -95,7 +97,7 @@ function createViewerResolver({ auth, config, log = console }) {
@@ -95,7 +97,7 @@ function createViewerResolver({ auth, config, log = console }) {
}
}
async function resolveDashboard(req) {
async function resolveDashboard(req) {
- const token = parseCookies(req)[COOKIES.access] || null;
+ const token = sessionToken(config, req);
if (!token) return ANONYMOUS;
if (!token) return ANONYMOUS;
return (await fromUserToken(token)) || ANONYMOUS;
return (await fromUserToken(token)) || ANONYMOUS;
}
}
@@ -8,6 +8,7 @@ const assert = require('assert');
@@ -8,6 +8,7 @@ const assert = require('assert');
const { boot, check, done, SECRET } = require('./stageb/boot');
const { boot, check, done, SECRET } = require('./stageb/boot');
const { tarball } = require('./stageb/tar');
const { tarball } = require('./stageb/tar');
const { csrfToken } = require('../server/auth/forms');
const { csrfToken } = require('../server/auth/forms');
+const { cookieNames } = require('../server/auth/sso');
const ORIGIN = 'https://openvibe.host';
const ORIGIN = 'https://openvibe.host';
@@ -81,8 +82,8 @@ const ORIGIN = 'https://openvibe.host';
@@ -81,8 +82,8 @@ const ORIGIN = 'https://openvibe.host';
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
});
- await check('the dashboard session is the verified ov_token cookie; a forged ov_token is not a session', async () => {
- // openvibe-sdk/sso keeps the session in the ov_token cookie (the shared navbar reads the same one).
+ await check('the dashboard session is Host\'s own cookie; a forged ov_token is not a session', async () => {
+ // openvibe-sdk/sso keeps its session in ov_token (the shared navbar reads it); Host trusts only its own cookie.
const forged = 'not.a.jwt';
const forged = 'not.a.jwt';
const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });
const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });
assert.match(r.text, /Sign in with OpenVibe/);
assert.match(r.text, /Sign in with OpenVibe/);
@@ -93,6 +94,101 @@ const ORIGIN = 'https://openvibe.host';
@@ -93,6 +94,101 @@ const ORIGIN = 'https://openvibe.host';
assert.strictEqual(api.status, 401);
assert.strictEqual(api.status, 401);
});
});
+ const SESSION = cookieNames(t.config).session; // ov_host_session here (COOKIE_SECURE=false); __Host- in production
+ const mallory = t.user('mallory');
+ const malloryProjects = async () => (await t.api('GET', '/api/v1/projects', { as: mallory })).json().projects.length;
+ const setCookies = (r) => [].concat(r.headers['set-cookie'] || []);
+ const cookieValue = (r, name) => { const c = setCookies(r).find((x) => x.startsWith(`${name}=`)); return c === undefined ? undefined : decodeURIComponent(c.slice(name.length + 1).split(';')[0]); };
+
+ await check('cookie tossing: a planted, validly signed ov_token of another user is not a session, alone or next to a real one', async () => {
+ // A tenant page on <site>.openvibe.host can set ov_token=<its owner's token>; Domain=openvibe.host; Path=/<longer path>:
+ // the browser sends it first. It verifies, and it is still ignored.
+ const planted = `ov_token=${t.network.userToken(mallory)}`;
+ const alone = await t.api('GET', '/', { headers: { cookie: planted } });
+ assert.match(alone.text, /Sign in with OpenVibe/);
+ assert.strictEqual((await t.api('GET', '/auth/me', { headers: { cookie: planted } })).status, 401);
+ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: mallory.subject });
+ const post = await t.api('POST', '/projects', { form: { csrf, name: 'Tossed', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });
+ assert.ok(!/\/projects\/prj_/.test(post.headers.location || ''), post.text);
+ assert.strictEqual(await malloryProjects(), 0);
+
+ const both = await t.api('GET', '/', { session: alice, headers: { cookie: planted } });
+ assert.match(both.text, /Your projects/);
+ assert.ok(!/mallory/.test(both.text), 'the page is alice\'s, not mallory\'s');
+ const me = await t.api('GET', '/auth/me', { session: alice, headers: { cookie: planted } });
+ assert.strictEqual(me.json().user.subject_id, alice.subject);
+ const csrfA = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
+ const asAlice = await t.api('POST', '/projects', { session: alice, form: { csrf: csrfA, name: 'Tossed but mine', environment: 'production' }, headers: { cookie: planted, origin: ORIGIN } });
+ assert.strictEqual(asAlice.status, 303, asAlice.text);
+ assert.strictEqual(await malloryProjects(), 0, 'nothing lands in mallory\'s account');
+ const mine = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects;
+ assert.ok(mine.some((p) => p.name === 'Tossed but mine'));
+ for (const p of mine.filter((x) => x.name === 'Tossed but mine')) await t.api('DELETE', `/api/v1/projects/${p.id}`, { as: alice });
+ });
+
+ await check('sign-in issues the HttpOnly session cookie; a planted OAuth state cannot finish another account\'s sign-in', async () => {
+ const login = await t.api('GET', '/auth/login');
+ assert.strictEqual(login.status, 302);
+ const state = new URL(login.headers.location).searchParams.get('state');
+ assert.strictEqual(cookieValue(login, 'ov_host_flow'), state);
+ const flow = `ov_host_flow=${state}; ov_oauth_state=${state}; ov_oauth_verifier=${cookieValue(login, 'ov_oauth_verifier')}`;
+ const cb = await t.api('GET', `/auth/callback?code=${t.network.issueCode(alice)}&state=${state}`, { headers: { cookie: flow } });
+ assert.strictEqual(cb.status, 302, cb.text);
+ const set = setCookies(cb).find((c) => c.startsWith(`${SESSION}=`));
+ assert.match(set, /; Path=\/;/);
+ assert.match(set, /; HttpOnly/);
+ assert.match(set, /; SameSite=Lax/);
+ assert.ok(!/Domain=/i.test(set), set);
+ const dash = await t.api('GET', '/', { headers: { cookie: `${SESSION}=${cookieValue(cb, SESSION)}` } });
+ assert.match(dash.text, /Your projects/);
+
+ // Mallory starts a sign-in of her own, plants her state and verifier (Path=/auth/callback) and sends alice to the callback.
+ const theirs = await t.api('GET', '/auth/login');
+ const s2 = new URL(theirs.headers.location).searchParams.get('state');
+ const tossed = `ov_oauth_state=${s2}; ov_oauth_verifier=${cookieValue(theirs, 'ov_oauth_verifier')}`;
+ for (const cookie of [tossed, `${tossed}; ov_host_flow=${state}`]) {
+ const r = await t.api('GET', `/auth/callback?code=${t.network.issueCode(mallory)}&state=${s2}`, { headers: { cookie } });
+ assert.strictEqual(r.status, 400, r.text);
+ assert.strictEqual(cookieValue(r, SESSION), undefined);
+ }
+ });
+
+ await check('refresh renews the session of the same account only; sign-out clears it', async () => {
+ const session = `${SESSION}=${t.network.userToken(alice)}`;
+ const ok = await t.api('POST', '/auth/refresh', { headers: { cookie: `${session}; ov_refresh=${t.network.refreshToken(alice)}` } });
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.strictEqual(require('openvibe-sdk/sso').decodeJwtPayload(cookieValue(ok, SESSION)).subject_id, alice.subject);
+ // A planted ov_refresh of mallory's (Path=/auth/refresh), next to alice's session or alone, never becomes a session.
+ for (const cookie of [`${session}; ov_refresh=${t.network.refreshToken(mallory)}`, `ov_refresh=${t.network.refreshToken(mallory)}`]) {
+ const r = await t.api('POST', '/auth/refresh', { headers: { cookie } });
+ assert.strictEqual(r.status, 401, r.text);
+ assert.strictEqual(cookieValue(r, SESSION), undefined);
+ assert.strictEqual(cookieValue(r, 'ov_token'), undefined);
+ assert.strictEqual(cookieValue(r, 'ov_refresh'), undefined);
+ }
+ const out = await t.api('GET', '/auth/logout', { headers: { cookie: session } });
+ assert.strictEqual(out.status, 302);
+ assert.strictEqual(cookieValue(out, SESSION), '');
+ assert.match(setCookies(out).find((c) => c.startsWith(`${SESSION}=`)), /Expires=Thu, 01 Jan 1970/);
+ });
+
+ await check('in production the session cookie is __Host- prefixed and Secure', async () => {
+ assert.deepStrictEqual(cookieNames({ cookies: { secure: true } }), { session: '__Host-ov_host_session', flow: '__Host-ov_host_flow' });
+ // The guard around a stand-in SDK router whose callback writes ov_token, as setSession does.
+ const express = require('express');
+ const app = express();
+ app.use(require('cookie-parser')());
+ const auth = { router: (ex) => ex.Router().get('/callback', (_req, res) => { res.cookie('ov_token', 'tok', {}); res.send('ok'); }) };
+ app.use('/auth', require('../server/auth/sso').createHostSession({ auth, config: { cookies: { secure: true } } }).router(express));
+ const srv = await new Promise((resolve) => { const x = app.listen(0, '127.0.0.1', () => resolve(x)); });
+ try {
+ const r = await fetch(`http://127.0.0.1:${srv.address().port}/auth/callback?state=s1`, { headers: { cookie: '__Host-ov_host_flow=s1' } });
+ assert.strictEqual(r.status, 200);
+ const set = r.headers.getSetCookie().find((c) => c.startsWith('__Host-ov_host_session='));
+ assert.match(set, /^__Host-ov_host_session=tok; Max-Age=\d+; Path=\/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax$/);
+ } finally { await new Promise((resolve) => srv.close(resolve)); }
+ });
+
let projectId, siteId;
let projectId, siteId;
await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {
await check('forms: create a project and a site (Origin + form token), then upload a folder and see the log', async () => {
const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
@@ -15,6 +15,7 @@ const path = require('path');
@@ -15,6 +15,7 @@ const path = require('path');
const http = require('http');
const http = require('http');
const { startNetwork } = require('./mocks');
const { startNetwork } = require('./mocks');
const { site: siteTar } = require('./tar');
const { site: siteTar } = require('./tar');
+const { cookieNames } = require('../../server/auth/sso');
// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own
// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own
// (test/actor-limits.test.js), so they are off here unless a test turns them on.
// (test/actor-limits.test.js), so they are off here unless a test turns them on.
@@ -84,7 +85,8 @@ async function boot(opts = {}) {
@@ -84,7 +85,8 @@ async function boot(opts = {}) {
if (o.host !== undefined) headers.host = o.host;
if (o.host !== undefined) headers.host = o.host;
if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
- if (o.session) headers.cookie = [headers.cookie, `ov_token=${network.userToken(o.session)}`].filter(Boolean).join('; ');
+ // A dashboard session is Host's own cookie (server/auth/sso.js); ov_token is display-only.
+ if (o.session) headers.cookie = [headers.cookie, `${cookieNames(t.config).session}=${network.userToken(o.session)}`].filter(Boolean).join('; ');
let body = o.body;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
/**
/**
* In-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS and /oauth/token
* In-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS and /oauth/token
- * (client_credentials → service tokens with the requested scope as capabilities).
+ * (client_credentials → service tokens with the requested scope as capabilities; authorization_code and
+ * refresh_token → user tokens for issueCode()/refreshToken()).
* userToken()/serviceToken()/appToken() mint what browsers, services and apps present to Host.
* userToken()/serviceToken()/appToken() mint what browsers, services and apps present to Host.
*/
*/
const http = require('http');
const http = require('http');
@@ -42,6 +43,13 @@ async function startNetwork() {
@@ -42,6 +43,13 @@ async function startNetwork() {
const cap = String(scope || '').split(/\s+/).filter(Boolean);
const cap = String(scope || '').split(/\s+/).filter(Boolean);
return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
return json(200, { access_token: sign({ sub: `svc:${body.client_id}`, actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
+ // Browser sign-in: a code from issueCode(), a refresh token from refreshToken() (single use, rotated).
+ const grant = body.grant_type === 'authorization_code' ? grants.get(`code:${body.code}`) : body.grant_type === 'refresh_token' ? grants.get(`rt:${body.refresh_token}`) : undefined;
+ if (grant === undefined && ['authorization_code', 'refresh_token'].includes(body.grant_type)) return json(400, { error: 'invalid_grant' });
+ if (grant) {
+ grants.delete(body.grant_type === 'authorization_code' ? `code:${body.code}` : `rt:${body.refresh_token}`);
+ return json(200, { access_token: userToken(grant), refresh_token: refreshToken(grant), token_type: 'Bearer', expires_in: 3600 });
+ }
return json(400, { error: 'unsupported_grant_type' });
return json(400, { error: 'unsupported_grant_type' });
}
}
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
@@ -50,6 +58,9 @@ async function startNetwork() {
@@ -50,6 +58,9 @@ async function startNetwork() {
function sign({ sub, actor_type = 'service', aud, cap, ...extra }) {
function sign({ sub, actor_type = 'service', aud, cap, ...extra }) {
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);
return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type, aud, cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomUUID(), ...extra }, privatePem);
}
}
+ const grants = new Map();
+ const issueCode = (u) => { const c = crypto.randomBytes(12).toString('hex'); grants.set(`code:${c}`, u); return c; };
+ const refreshToken = (u) => { const r = crypto.randomBytes(12).toString('hex'); grants.set(`rt:${r}`, u); return r; };
function addUser(username, extra = {}) {
function addUser(username, extra = {}) {
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
}
}
@@ -67,7 +78,7 @@ async function startNetwork() {
@@ -67,7 +78,7 @@ async function startNetwork() {
function appToken(appId, cap, extra = {}) {
function appToken(appId, cap, extra = {}) {
return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', env: 'production', ...extra });
return sign({ sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.host'], cap, project_id: 'prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', env: 'production', ...extra });
}
}
- return { ...srv, publicPem, addUser, userToken, fedcmAssertion, serviceToken, appToken, sign };
+ return { ...srv, publicPem, addUser, userToken, issueCode, refreshToken, fedcmAssertion, serviceToken, appToken, sign };
}
}
module.exports = { startNetwork, listen };
module.exports = { startNetwork, listen };