openvibers/openvibe.chat Commits

The navbar's Messages count and the Dock's DMs work on every OpenVibe site (#389)

OP OpenVibers committed 6 days ago · cad997c122 · 2 files, +42 −1

The shared navbar and Dock call /api/dm/* with a Bearer and credentials 'omit' from every site, but Chat's CORS
answered 403 to any origin outside its five-site credentialed list (openvibe.video, .email, .my, …: the Messages
count and the Dock's chats failed there). On the DM paths an origin a Contracts service manifest names now gets the
non-credentialed grant: the origin echoed, no Allow-Credentials, so the Bearer is the whole key and a credentialed
read or preflight is still refused. Foreign web origins stay refused; the rest of the API is unchanged.
Test: dock-signin (preflight and read from openvibe.video, no credentials; search still refused; evil.example 403;
openvibe.network keeps its credentialed grant).

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 56c450a..750b1e6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -113,6 +113,24 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
return allowedOrigins.has(origin);
}
++ // Every OpenVibe site, by the exact https origins the pinned Contracts service manifests name. The navbar's Messages
++ // count and the Dock's DMs call Chat from each of them with a Bearer and credentials 'omit' (openvibe-shared
++ // navbar.js, dock.js); only five sites were in the credentialed list, so the count was refused everywhere else.
++ const FIRST_PARTY = (() => {
++ const out = new Set();
++ try {
++ const m = require('openvibe-contracts').services.manifests;
++ for (const svc of Array.isArray(m) ? m : Object.values(m || {})) {
++ for (const host of (svc && Array.isArray(svc.domains) ? svc.domains : [])) {
++ if (typeof host === 'string' && /^[a-z0-9.-]+$/.test(host)) out.add(`https://${host}`);
++ }
++ }
++ } catch { /* no manifests: the credentialed list alone */ }
++ return out;
++ })();
++ /** The Bearer-only DM reads and writes the shared navbar and Dock make from any OpenVibe site. */
++ const bearerDmPath = (p) => p === '/api/dm/unread' || p === '/api/dm/conversations' || p.startsWith('/api/dm/conversations/');
++
// ── Internal (loopback; never routed by nginx) ─────────────
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
// Live's reads of the chat tables Chat owns, in place of its mirrored copy (plan T3 J4b, server/chat/internal-reads.js).
@@@ -251,7 +269,13 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
};
app.use((req, res, next) => {
if (isExtensionOrigin(req.headers.origin)) return extensionCors(req, res, next);
-- return (openPath(req.path) ? openCors : allowlistedCors)(req, res, next);
++ if (openPath(req.path)) return openCors(req, res, next);
++ // An OpenVibe site outside the credentialed list gets the non-credentialed grant on the DM paths: the origin is
++ // echoed with no Allow-Credentials, so the Bearer is the whole key, a browser refuses a credentialed read or
++ // preflight, and a foreign web origin is still refused.
++ const origin = req.headers.origin;
++ if (origin && bearerDmPath(req.path) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);
++ return allowlistedCors(req, res, next);
});
// A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
app.post('/api/chat/hooks/:id', express.raw({ type: () => true, limit: '64kb' }), require('./rooms/hooks').ingress);
ddiff --git a/test/dock-signin.test.js b/test/dock-signin.test.js
iindex 543c15e..b8e5592 100644
---- a/test/dock-signin.test.js
++++ b/test/dock-signin.test.js
@@@ -70,4 +70,21 @@ t('an extension origin gets no CORS grant and never authenticates with a cookie'
assert.strictEqual(moz.headers.get('access-control-allow-origin'), null);
});
++t('an OpenVibe site outside the credentialed list reads DMs with its Bearer, and gets no credentials', async () => {
++ const tok = dock(alice);
++ const origin = 'https://openvibe.video';
++ const pre = await h.http('OPTIONS', '/api/dm/unread', { headers: { origin, 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' } });
++ assert.ok(pre.status === 204 || pre.status === 200, `preflight ${pre.status}`);
++ assert.strictEqual(pre.headers.get('access-control-allow-origin'), origin);
++ assert.strictEqual(pre.headers.get('access-control-allow-credentials'), null, 'never a credentialed grant');
++ const r = await h.http('GET', '/api/dm/unread', { token: tok, headers: { origin } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), origin);
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
++ assert.strictEqual((await h.http('GET', '/api/chat/search?q=hello', { token: normal(alice), headers: { origin } })).status, 403, 'only the DM paths, not the rest of the API');
++ assert.strictEqual((await h.http('GET', '/api/dm/unread', { token: tok, headers: { origin: 'https://evil.example' } })).status, 403, 'a foreign web origin is still refused');
++ const live = await h.http('GET', '/api/dm/unread', { token: tok, headers: { origin: 'https://openvibe.network' } });
++ assert.strictEqual(live.headers.get('access-control-allow-credentials'), 'true', 'the credentialed list keeps its grant');
++});
++
t.run(async () => { await h.shutdown(); });