openvibers/openvibe.tools Commits
Food → maps: the visitor's address goes along (X-Forwarded-For and X-Real-IP from req.ip), so maps' 30/min limit is per person again instead of one 127.0.0.1 bucket for every food visitor. Maps and food believe X-Forwarded-For only from loopback hops (trust proxy 'loopback' instead of 2: maps listens on 0.0.0.0 by default, and a direct caller's own header must not count). apps/food/test/maps-forwarding.test.js runs both and shows one visitor limited while another is not; the shared spawn helper can poll /api/ready for apps without /api/health
Changes
@@@ -17,9 +17,10 @@ function freePort() {/*** @param {string} app 'img' | 'audio' | 'docs' | …* @param {object} env extra environment (DATA_DIR, TOOLS_JOBS_CONCURRENCY_…)++ * @param {object} [opts.readyPath] what to poll until it answers 200 (maps and food have no /api/health)* @returns {Promise<{ base, port, kill(signal), exited: Promise, output: () => string }>}*/--async function startApp(app, env = {}, port) {++async function startApp(app, env = {}, port, { readyPath = '/api/health' } = {}) {port = port || await freePort();const dir = path.join(__dirname, '..', '..', app);let out = '';@@@ -45,7 +46,7 @@ async function startApp(app, env = {}, port) {const t0 = Date.now();for (;;) {if (child.exitCode != null) throw new Error(`${app} exited during boot:\n${out}`);-- try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not up yet */ }++ try { const r = await fetch(`${base}${readyPath}`); if (r.ok) break; } catch { /* not up yet */ }if (Date.now() - t0 > 15000) { child.kill('SIGKILL'); throw new Error(`${app} did not start:\n${out}`); }await new Promise(r => setTimeout(r, 100));}@@@ -46,7 +46,9 @@ app.get('/release.json', release.handler);// Legal documents live on the apex; every tool host points there instead of answering 404.app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));--app.set('trust proxy', 2); // Cloudflare → Nginx → Node++// Only loopback hops (the host's nginx, the gateway) are proxies: req.ip is the first address before++// them, which is what maps is told below. A client's own X-Forwarded-For is never believed.++app.set('trust proxy', 'loopback');app.use(cors({origin(origin, callback) {if (!origin) return callback(null, true);@@@ -87,13 +89,15 @@ app.get(['/', '/index.html'], sendIndex);// Static filesapp.use(express.static(path.join(__dirname, '..', 'public'), { maxAge: '1d', index: false }));--// Proxy food-related API calls to openvibe-maps backend++// Proxy food-related API calls to openvibe-maps backend. The visitor's address goes along, so maps++// rate-limits each person instead of putting every food visitor in one 127.0.0.1 bucket.function proxyToMaps(apiPath) {return async (req, res) => {const qs = new URL(req.url, `http://localhost`).search;const url = `${MAPS_API}${apiPath}${qs}`;try {-- const response = await fetch(url, { signal: AbortSignal.timeout(15000) });++ const headers = req.ip ? { 'X-Forwarded-For': req.ip, 'X-Real-IP': req.ip } : {};++ const response = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });const data = await response.json();res.status(response.status).json(data);} catch (e) {nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++// Food proxies every API call to maps. It used to call maps without the visitor's address, so maps'++// per-IP limit (30/min) was one bucket shared by every food visitor (127.0.0.1). Now food forwards++// X-Forwarded-For / X-Real-IP from req.ip, and maps believes X-Forwarded-For only from loopback hops.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { startApp } = require('../../_shared/test/spawn');++++(async () => {++ const maps = await startApp('maps', {}, undefined, { readyPath: '/api/ready' });++ const food = await startApp('food', { MAPS_API: maps.base }, undefined, { readyPath: '/api/ready' });++ try {++ const ask = (ip) => fetch(`${food.base}/api/foods?search=rice`, { headers: { 'X-Forwarded-For': ip } });++ // One visitor uses up maps' budget…++ let limited = null;++ for (let i = 0; i < 40 && !limited; i++) { const r = await ask('203.0.113.1'); if (r.status === 429) limited = i; else assert.strictEqual(r.status, 200); }++ assert.strictEqual(limited, 30, 'maps limits that visitor after 30 requests a minute');++ // …and another visitor is not affected.++ const other = await ask('203.0.113.2');++ assert.strictEqual(other.status, 200, 'a second visitor has their own bucket');++++ const src = (app) => fs.readFileSync(path.join(__dirname, '..', '..', app, 'server', 'index.js'), 'utf8');++ assert.match(src('maps'), /app\.set\('trust proxy', 'loopback'\)/, 'maps trusts forwarded addresses from loopback only');++ assert.match(src('food'), /'X-Forwarded-For': req\.ip/);++ } finally {++ await food.kill();++ await maps.kill();++ }++ console.log('food → maps forwards the visitor address; maps limits per person: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -49,7 +49,10 @@ app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301,const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });// ── Middleware ──────────────────────────────────────────────--app.set('trust proxy', 2); // Cloudflare → Nginx → Node++// X-Forwarded-For is believed only from loopback hops: the host's nginx, the gateway and the food++// app (which forwards its visitor's address). Maps listens on 0.0.0.0 by default, so a direct++// caller's own header must not count.++app.set('trust proxy', 'loopback');app.use(helmet({contentSecurityPolicy: {directives: {
@@ -17,9 +17,10 @@ function freePort() {
@@ -17,9 +17,10 @@ function freePort() {
/**
/**
* @param {string} app 'img' | 'audio' | 'docs' | …
* @param {string} app 'img' | 'audio' | 'docs' | …
* @param {object} env extra environment (DATA_DIR, TOOLS_JOBS_CONCURRENCY_…)
* @param {object} env extra environment (DATA_DIR, TOOLS_JOBS_CONCURRENCY_…)
+ * @param {object} [opts.readyPath] what to poll until it answers 200 (maps and food have no /api/health)
* @returns {Promise<{ base, port, kill(signal), exited: Promise, output: () => string }>}
* @returns {Promise<{ base, port, kill(signal), exited: Promise, output: () => string }>}
*/
*/
-async function startApp(app, env = {}, port) {
+async function startApp(app, env = {}, port, { readyPath = '/api/health' } = {}) {
port = port || await freePort();
port = port || await freePort();
const dir = path.join(__dirname, '..', '..', app);
const dir = path.join(__dirname, '..', '..', app);
let out = '';
let out = '';
@@ -45,7 +46,7 @@ async function startApp(app, env = {}, port) {
@@ -45,7 +46,7 @@ async function startApp(app, env = {}, port) {
const t0 = Date.now();
const t0 = Date.now();
for (;;) {
for (;;) {
if (child.exitCode != null) throw new Error(`${app} exited during boot:\n${out}`);
if (child.exitCode != null) throw new Error(`${app} exited during boot:\n${out}`);
- try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not up yet */ }
+ try { const r = await fetch(`${base}${readyPath}`); if (r.ok) break; } catch { /* not up yet */ }
if (Date.now() - t0 > 15000) { child.kill('SIGKILL'); throw new Error(`${app} did not start:\n${out}`); }
if (Date.now() - t0 > 15000) { child.kill('SIGKILL'); throw new Error(`${app} did not start:\n${out}`); }
await new Promise(r => setTimeout(r, 100));
await new Promise(r => setTimeout(r, 100));
}
}
@@ -46,7 +46,9 @@ app.get('/release.json', release.handler);
@@ -46,7 +46,9 @@ app.get('/release.json', release.handler);
// Legal documents live on the apex; every tool host points there instead of answering 404.
// Legal documents live on the apex; every tool host points there instead of answering 404.
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
-app.set('trust proxy', 2); // Cloudflare → Nginx → Node
+// Only loopback hops (the host's nginx, the gateway) are proxies: req.ip is the first address before
+// them, which is what maps is told below. A client's own X-Forwarded-For is never believed.
+app.set('trust proxy', 'loopback');
app.use(cors({
app.use(cors({
origin(origin, callback) {
origin(origin, callback) {
if (!origin) return callback(null, true);
if (!origin) return callback(null, true);
@@ -87,13 +89,15 @@ app.get(['/', '/index.html'], sendIndex);
@@ -87,13 +89,15 @@ app.get(['/', '/index.html'], sendIndex);
// Static files
// Static files
app.use(express.static(path.join(__dirname, '..', 'public'), { maxAge: '1d', index: false }));
app.use(express.static(path.join(__dirname, '..', 'public'), { maxAge: '1d', index: false }));
-// Proxy food-related API calls to openvibe-maps backend
+// Proxy food-related API calls to openvibe-maps backend. The visitor's address goes along, so maps
+// rate-limits each person instead of putting every food visitor in one 127.0.0.1 bucket.
function proxyToMaps(apiPath) {
function proxyToMaps(apiPath) {
return async (req, res) => {
return async (req, res) => {
const qs = new URL(req.url, `http://localhost`).search;
const qs = new URL(req.url, `http://localhost`).search;
const url = `${MAPS_API}${apiPath}${qs}`;
const url = `${MAPS_API}${apiPath}${qs}`;
try {
try {
- const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
+ const headers = req.ip ? { 'X-Forwarded-For': req.ip, 'X-Real-IP': req.ip } : {};
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(15000) });
const data = await response.json();
const data = await response.json();
res.status(response.status).json(data);
res.status(response.status).json(data);
} catch (e) {
} catch (e) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+// Food proxies every API call to maps. It used to call maps without the visitor's address, so maps'
+// per-IP limit (30/min) was one bucket shared by every food visitor (127.0.0.1). Now food forwards
+// X-Forwarded-For / X-Real-IP from req.ip, and maps believes X-Forwarded-For only from loopback hops.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { startApp } = require('../../_shared/test/spawn');
+
+(async () => {
+ const maps = await startApp('maps', {}, undefined, { readyPath: '/api/ready' });
+ const food = await startApp('food', { MAPS_API: maps.base }, undefined, { readyPath: '/api/ready' });
+ try {
+ const ask = (ip) => fetch(`${food.base}/api/foods?search=rice`, { headers: { 'X-Forwarded-For': ip } });
+ // One visitor uses up maps' budget…
+ let limited = null;
+ for (let i = 0; i < 40 && !limited; i++) { const r = await ask('203.0.113.1'); if (r.status === 429) limited = i; else assert.strictEqual(r.status, 200); }
+ assert.strictEqual(limited, 30, 'maps limits that visitor after 30 requests a minute');
+ // …and another visitor is not affected.
+ const other = await ask('203.0.113.2');
+ assert.strictEqual(other.status, 200, 'a second visitor has their own bucket');
+
+ const src = (app) => fs.readFileSync(path.join(__dirname, '..', '..', app, 'server', 'index.js'), 'utf8');
+ assert.match(src('maps'), /app\.set\('trust proxy', 'loopback'\)/, 'maps trusts forwarded addresses from loopback only');
+ assert.match(src('food'), /'X-Forwarded-For': req\.ip/);
+ } finally {
+ await food.kill();
+ await maps.kill();
+ }
+ console.log('food → maps forwards the visitor address; maps limits per person: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -49,7 +49,10 @@ app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301,
@@ -49,7 +49,10 @@ app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301,
const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
// ── Middleware ──────────────────────────────────────────────
// ── Middleware ──────────────────────────────────────────────
-app.set('trust proxy', 2); // Cloudflare → Nginx → Node
+// X-Forwarded-For is believed only from loopback hops: the host's nginx, the gateway and the food
+// app (which forwards its visitor's address). Maps listens on 0.0.0.0 by default, so a direct
+// caller's own header must not count.
+app.set('trust proxy', 'loopback');
app.use(helmet({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {