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

OP OpenVibers committed 23 Sept 2026 · a9e6762b9b · 4 files, +45 −6


Changes

ddiff --git a/apps/_shared/test/spawn.js b/apps/_shared/test/spawn.js
iindex 04423fd..e359a71 100644
---- a/apps/_shared/test/spawn.js
++++ b/apps/_shared/test/spawn.js
@@@ -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));
}
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex 6457418..df38057 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -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 files
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) {
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) {
ddiff --git a/apps/food/test/maps-forwarding.test.js b/apps/food/test/maps-forwarding.test.js
nnew file mode 100644
iindex 0000000..dd1c056
---- /dev/null
++++ b/apps/food/test/maps-forwarding.test.js
@@@ -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); });
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex 920eabf..c037903 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -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: {