openvibers/openvibe.ai Commits

Scope public CORS to exact origins on /stats and /release.json (#14)

OP OpenVibers committed 4 Oct 2026 · fe76a3c615 · 5 files, +117 −0

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.AI/pull/14

Changes

ddiff --git a/.env.example b/.env.example
iindex 6fa84a9..404bd77 100644
---- a/.env.example
++++ b/.env.example
@@@ -10,6 +10,9 @@ PORT=4700
HOST=127.0.0.1
NODE_ENV=development
BASE_URL=http://localhost:4700
++# Exact browser origins allowed to read GET /stats and /release.json (and their GET preflight).
++# Defaults to https://ai.openvibe.services,https://openvibe.services; no credentials or token/console CORS.
++#AI_CORS_ORIGINS=https://ai.openvibe.services,https://openvibe.services
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh ai: the runtime role through PgBouncer,
# the owner on a direct connection (migrations), and Valkey (shared limit counters).
ddiff --git a/README.md b/README.md
iindex d3652e4..6a37874 100644
---- a/README.md
++++ b/README.md
@@@ -110,6 +110,11 @@ idempotent replay):
| `GET /robots.txt` | public: disallows `/console`, `/auth/`, `/api/` |
| `GET /stats` | public: p50/p95 latency, success rate and rate-card price per registered provider and model over the last 7 days (server-rendered, `openvibe-shared/cache-policy` HTML headers, nothing per caller) |
++Browser reads of `GET /stats` and `GET /release.json` allow exact origins from `AI_CORS_ORIGINS`
++(comma-separated; default `https://ai.openvibe.services,https://openvibe.services`). Their `OPTIONS`
++preflight allows GET. Responses never allow credentials; `/api/v1/*`, `/console` and `/auth/*`
++have no CORS access. Service callers do not need CORS.
++
`/api/ready` (openvibe-shared/ready) is 503 when the database, the active workflows or the Network
key fail; provider configuration (an active provider without its credentials, or with an open
circuit) degrades it. `/metrics` (openvibe-shared/metrics) carries golden signals by route template,
ddiff --git a/server/app.js b/server/app.js
iindex 43468e5..aacce5a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -25,6 +25,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
const metrics = instrument(app, { service: 'ai', release: release.release });
registerAiGauges(metrics.registry, { runs, registry, pool });
app.use(http.middleware());
++ // Browser reads of the two public GETs (/stats, /release.json) only: an exact origin from
++ // AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.
++ // Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.
++ const publicCorsPaths = new Set(['/stats', '/release.json']);
++ const corsOrigins = new Set((config.cors && config.cors.origins) || []);
++ app.use((req, res, next) => {
++ if (!publicCorsPaths.has(req.path) || (req.method !== 'GET' && req.method !== 'OPTIONS')) return next();
++ const origin = req.get('Origin');
++ const allowed = Boolean(origin) && corsOrigins.has(origin);
++ res.vary('Origin');
++ // openvibe-shared/release sends a wildcard ACAO itself: keep the exact-origin decision ours,
++ // and let nothing (a rejected origin included) send Access-Control-Allow-Origin.
++ const rawSetHeader = res.setHeader.bind(res);
++ res.setHeader = (name, value) => {
++ if (String(name).toLowerCase() !== 'access-control-allow-origin') return rawSetHeader(name, value);
++ return allowed ? rawSetHeader(name, value === '*' ? origin : value) : res;
++ };
++ if (req.method === 'OPTIONS') {
++ res.vary('Access-Control-Request-Method');
++ const method = req.get('Access-Control-Request-Method');
++ if (allowed && (!method || method === 'GET')) {
++ rawSetHeader('Access-Control-Allow-Origin', origin);
++ rawSetHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
++ rawSetHeader('Access-Control-Allow-Headers', 'Accept, Content-Type');
++ rawSetHeader('Access-Control-Max-Age', '600');
++ }
++ return res.sendStatus(204);
++ }
++ if (allowed) rawSetHeader('Access-Control-Allow-Origin', origin);
++ return next();
++ });
// One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.
require('openvibe-shared/trace').install(app);
app.use((req, res, next) => {
ddiff --git a/server/config.js b/server/config.js
iindex 320eb8f..9c6f5f8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -44,6 +44,9 @@ function load(env = process.env) {
nodeEnv,
isProduction,
baseUrl,
++ // Browser reads of the public stats and release endpoints only. Values are exact origins;
++ // CORS never allows credentials, and token/console routes do not use this list.
++ cors: { origins: list(env.AI_CORS_ORIGINS, ['https://ai.openvibe.services', 'https://openvibe.services']) },
// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
ddiff --git a/test/cors.test.js b/test/cors.test.js
nnew file mode 100644
iindex 0000000..0c20d23
---- /dev/null
++++ b/test/cors.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++const assert = require('assert');
++const { load } = require('../server/config');
++const { boot, request, suite } = require('./helpers');
++
++const t = suite('cors');
++const allowed = 'https://ai.openvibe.services';
++const otherAllowed = 'https://openvibe.services';
++const rejected = 'https://ai.openvibe.services.evil.example';
++let h;
++
++function assertNoCors(headers) {
++ for (const [name] of headers) assert.ok(!name.startsWith('access-control-'), `unexpected ${name}`);
++}
++
++t.test('config has exact public origins and accepts an override', () => {
++ assert.deepStrictEqual(load({ NODE_ENV: 'test' }).cors.origins, [allowed, otherAllowed]);
++ assert.deepStrictEqual(load({ NODE_ENV: 'test', AI_CORS_ORIGINS: ' https://reader.example, https://another.example ' }).cors.origins,
++ ['https://reader.example', 'https://another.example']);
++});
++
++t.test('boot', async () => { h = await boot(); });
++
++t.test('listed origins can read only the two public GETs without credentials', async () => {
++ for (const [path, origin] of [['/stats', allowed], ['/release.json', otherAllowed]]) {
++ const r = await request(h.base, 'GET', path, { headers: { Origin: origin } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), origin);
++ assert.match(r.headers.get('vary'), /\bOrigin\b/i);
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
++ }
++});
++
++t.test('unlisted and merely similar origins receive no CORS permission', async () => {
++ for (const origin of [rejected, `${allowed}:443`, 'null']) {
++ const r = await request(h.base, 'GET', '/stats', { headers: { Origin: origin } });
++ assert.strictEqual(r.status, 200, r.text);
++ assertNoCors(r.headers);
++ }
++ const r = await request(h.base, 'GET', '/release.json', { headers: { Origin: rejected } });
++ assert.strictEqual(r.status, 200, r.text);
++ assertNoCors(r.headers);
++});
++
++t.test('public GET preflight succeeds; rejected origin or method gets no CORS headers', async () => {
++ for (const path of ['/stats', '/release.json']) {
++ const r = await request(h.base, 'OPTIONS', path, { headers: {
++ Origin: allowed, 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'Content-Type',
++ } });
++ assert.strictEqual(r.status, 204, r.text);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), allowed);
++ assert.strictEqual(r.headers.get('access-control-allow-methods'), 'GET, OPTIONS');
++ assert.strictEqual(r.headers.get('access-control-allow-headers'), 'Accept, Content-Type');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
++ }
++ for (const [origin, method] of [[rejected, 'GET'], [allowed, 'POST']]) {
++ const r = await request(h.base, 'OPTIONS', '/stats', { headers: { Origin: origin, 'Access-Control-Request-Method': method } });
++ assert.strictEqual(r.status, 204, r.text);
++ assertNoCors(r.headers);
++ }
++});
++
++t.test('token and console routes never receive CORS headers', async () => {
++ for (const [method, path] of [['GET', '/api/v1/runs'], ['OPTIONS', '/api/v1/runs'], ['GET', '/console'], ['OPTIONS', '/console'], ['GET', '/auth/login']]) {
++ const r = await request(h.base, method, path, { headers: { Origin: allowed, 'Access-Control-Request-Method': 'GET' } });
++ assertNoCors(r.headers);
++ }
++ for (const path of ['/stats/extra', '/release.json/extra']) {
++ const r = await request(h.base, 'GET', path, { headers: { Origin: allowed } });
++ assertNoCors(r.headers);
++ }
++});
++
++t.test('stop', async () => { await h.stop(); });
++t.run();