openvibers/openvibe.ai Commits
Scope public CORS to exact origins on /stats and /release.json (#14)
Changes
@@@ -10,6 +10,9 @@ PORT=4700HOST=127.0.0.1NODE_ENV=developmentBASE_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).@@@ -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 Networkkey fail; provider configuration (an active provider without its credentials, or with an opencircuit) degrades it. `/metrics` (openvibe-shared/metrics) carries golden signals by route template,@@@ -25,6 +25,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysconst 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) => {@@@ -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'),nnew file mode 100644@@@ -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();
@@ -10,6 +10,9 @@ PORT=4700
@@ -10,6 +10,9 @@ PORT=4700
HOST=127.0.0.1
HOST=127.0.0.1
NODE_ENV=development
NODE_ENV=development
BASE_URL=http://localhost:4700
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,
# 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).
# the owner on a direct connection (migrations), and Valkey (shared limit counters).
@@ -110,6 +110,11 @@ idempotent replay):
@@ -110,6 +110,11 @@ idempotent replay):
| `GET /robots.txt` | public: disallows `/console`, `/auth/`, `/api/` |
| `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) |
| `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
`/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
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,
circuit) degrades it. `/metrics` (openvibe-shared/metrics) carries golden signals by route template,
@@ -25,6 +25,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -25,6 +25,37 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
const metrics = instrument(app, { service: 'ai', release: release.release });
const metrics = instrument(app, { service: 'ai', release: release.release });
registerAiGauges(metrics.registry, { runs, registry, pool });
registerAiGauges(metrics.registry, { runs, registry, pool });
app.use(http.middleware());
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.
// One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.
require('openvibe-shared/trace').install(app);
require('openvibe-shared/trace').install(app);
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -44,6 +44,9 @@ function load(env = process.env) {
@@ -44,6 +44,9 @@ function load(env = process.env) {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
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).
// Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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();