openvibers/openvibe.tools Commits

Tools: OSM data is escaped in maps/food, X-OV-* only from the gateway, bounded webhook bins, yt info throttled, no raw error text (#30)

OP OpenVibers committed 10 Oct 2026 · 7f1bbfbc91 · 27 files, +506 −102

Changes

ddiff --git a/apps/_shared/guard/caller.js b/apps/_shared/guard/caller.js
iindex 6c79573..726e890 100644
---- a/apps/_shared/guard/caller.js
++++ b/apps/_shared/guard/caller.js
@@@ -141,6 +141,10 @@ function createCallerResolver(o) {
*/
function jobOwnerResolver(callers) {
return function resolveOwner(req, res, { create = false, action = 'read' } = {}) {
++ // Freeze the caller before a session cookie may be minted. The guard caches its caller on
++ // req._ovCaller, and its sessionRule (auth.anonymous false) must see whether a session existed
++ // when the request arrived — a session this submit is about to create does not count.
++ if (create && req && !req._ovCaller) req._ovCaller = callers.resolve(req, res);
const c = callers.resolve(req, res, { create, action, jobs: true });
if (c.error) return { error: c.error };
if (!c.owner) return null;
ddiff --git a/apps/_shared/host-role.js b/apps/_shared/host-role.js
iindex 12e6a6c..7abbb99 100644
---- a/apps/_shared/host-role.js
++++ b/apps/_shared/host-role.js
@@@ -15,12 +15,25 @@
// always has (each host is its own canonical). With them, the canonical host comes from the
// gateway — which is how a custom domain becomes canonical without the satellite knowing it.
//
++// The X-OV-* headers are only ever set by the Tools gateway (apps/gateway/server/registry/host-middleware.js),
++// which reaches a satellite over loopback. A request that arrives from anywhere else — a direct caller whose
++// packet nginx passed through with the client's own headers, or one bypassing nginx entirely — must not be
++// believed, or anyone could mint a canonical host or an alias redirect. nginx also strips the headers on every
++// satellite vhost (apps/<name>/deploy/nginx/); this check is the second line of defence for a direct connection.
++//
// No dependencies: every satellite requires this file by relative path.
// ═══════════════════════════════════════════════════════════════
const TOOLS_HOME = 'https://openvibe.tools/';
const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
++const LOOPBACK_RE = /^(::1|::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3})$/;
++
++/** Is this request's direct peer loopback (the gateway or the host's nginx)? */
++function isLoopbackPeer(req) {
++ const addr = String((req && req.socket && req.socket.remoteAddress) || '').replace(/^\[|\]$/g, '').toLowerCase();
++ return LOOPBACK_RE.test(addr);
++}
function cleanHost(value) {
const h = String(value || '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');
@@@ -46,6 +59,11 @@ function isLocalHost(host) {
* @returns {{ viaGateway: boolean, tool: string, role: string, canonicalHost: string, shortHost: string }}
*/
function hostRole(req) {
++ // Only the gateway (over loopback) may name a tool, a role or a canonical host: a client-supplied
++ // header is ignored, so it cannot redirect the host or poison the canonical/og:url/JSON-LD.
++ if (!isLoopbackPeer(req)) {
++ return { viaGateway: false, tool: '', role: '', canonicalHost: '', shortHost: '' };
++ }
const h = req.headers || {};
const toolRaw = String(h['x-ov-tool'] || '').trim().toLowerCase();
const tool = TOOL_RE.test(toolRaw) ? toolRaw : '';
ddiff --git a/apps/_shared/test/bind-loopback.test.js b/apps/_shared/test/bind-loopback.test.js
nnew file mode 100644
iindex 0000000..b0d893a
---- /dev/null
++++ b/apps/_shared/test/bind-loopback.test.js
@@@ -0,0 +1,19 @@
++'use strict';
++// No app defaults its LISTEN address to 0.0.0.0 (all interfaces): nginx reaches every app on
++// 127.0.0.1, so an open host firewall must not expose an app directly and skip nginx's rate limits.
++// (The finding: maps and text — and the gateway — defaulted to 0.0.0.0.) node apps/_shared/test/bind-loopback.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const APPS = path.join(__dirname, '..', '..');
++let checked = 0;
++for (const app of fs.readdirSync(APPS)) {
++ const file = path.join(APPS, app, 'server', 'config.js');
++ if (!fs.existsSync(file)) continue;
++ checked++;
++ const src = fs.readFileSync(file, 'utf8');
++ assert.ok(!/process\.env\.HOST\s*\|\|\s*'0\.0\.0\.0'/.test(src), `${app}/server/config.js must not default to 0.0.0.0`);
++}
++assert.ok(checked >= 7, `every app's config checked (${checked})`);
++console.log(`every app binds loopback by default (${checked} configs): all checks passed`);
ddiff --git a/apps/_shared/test/guard.test.js b/apps/_shared/test/guard.test.js
iindex 5543e18..5cc663f 100644
---- a/apps/_shared/test/guard.test.js
++++ b/apps/_shared/test/guard.test.js
@@@ -199,6 +199,28 @@ async function serve(app) {
} finally { await s.close(); g.close(); }
}
++ // ── The session rule sees the caller as it arrived, not the cookie this submit mints ──
++ {
++ const g = makeGuard({ mode: 'enforce' });
++ const app = express();
++ app.set('trust proxy', TRUST_PROXY);
++ app.use(cookieParser());
++ const owner = g.ownerResolver();
++ app.post('/submit', async (req, res) => {
++ const w = owner(req, res, { create: true, action: 'create' });
++ if (!w) return res.status(401).json({ error: 'no owner' });
++ const ok = await g.admitJob(req, res, { type: 'audio.process', input: { tool: 'convert' }, files: [], toolId: 'mp3' });
++ if (!ok) return; // admitJob wrote the problem (401 tools.session_required)
++ res.json({ ok: true, owner: w.owner });
++ });
++ const s = await serve(app);
++ try {
++ const r = await fetch(s.base + '/submit', { method: 'POST' });
++ assert.strictEqual(r.status, 401, 'a session-less submit is refused even though minting one is what the route does first');
++ assert.strictEqual((await r.json()).code, 'tools.session_required');
++ } finally { await s.close(); g.close(); }
++ }
++
// ── Bucket math and cost weighting from the descriptors ──
{
let clock = now0;
ddiff --git a/apps/_shared/test/host-role.test.js b/apps/_shared/test/host-role.test.js
iindex 273e29c..b08391f 100644
---- a/apps/_shared/test/host-role.test.js
++++ b/apps/_shared/test/host-role.test.js
@@@ -5,7 +5,10 @@ const assert = require('assert');
const path = require('path');
const hr = require('../host-role');
--const req = (host, headers = {}, p = '/') => ({ headers: { host, ...headers }, path: p, originalUrl: p, method: 'GET' });
++// A request as the gateway/nginx makes it: from loopback, where X-OV-* are believed.
++const req = (host, headers = {}, p = '/') => ({ headers: { host, ...headers }, path: p, originalUrl: p, method: 'GET', socket: { remoteAddress: '127.0.0.1' } });
++// A request straight from a caller that reached the app without the gateway: its X-OV-* must be ignored.
++const direct = (host, headers = {}, p = '/') => ({ headers: { host, ...headers }, path: p, originalUrl: p, method: 'GET', socket: { remoteAddress: '203.0.113.9' } });
function res() {
const r = { headers: {}, statusCode: 200, body: null, redirected: null };
r.setHeader = (k, v) => { r.headers[k.toLowerCase()] = v; };
@@@ -41,6 +44,12 @@ assert.strictEqual(g.out.redirected, hr.TOOLS_HOME, 'not ours → tools index');
assert.ok(run(req('elsewhere.example', {}, '/api/health')).passed, 'APIs are never redirected');
assert.ok(run(req('localhost:4012')).passed && run(req('png.openvibe.tools')).passed);
++// ── X-OV-* are believed only from the gateway over loopback ──
++assert.strictEqual(hr.canonicalHostFor(direct('png.openvibe.tools', via('png', 'evil.example.com')), 'png.openvibe.tools'), 'png.openvibe.tools', 'a non-loopback caller cannot name the canonical host');
++assert.strictEqual(hr.hostRole(direct('maps.openvibe.tools', via('maps', 'evil.example.com', 'alias', 'evil.example.com'))).tool, '', 'X-OV-Tool from a non-loopback caller is ignored');
++const evil = run(direct('pngs.openvibe.tools', via('png', 'png-converter.example.com', 'alias', 'evil.example.com'), '/x'));
++assert.strictEqual(evil.out.redirected, hr.TOOLS_HOME, "a spoofed alias header cannot redirect a direct request to the attacker's host");
++
// ── stampedPage: maps and food ───────────────────────────────
for (const [app, host] of [['maps', 'maps.openvibe.tools'], ['food', 'food.openvibe.tools']]) {
const send = hr.stampedPage(path.join(__dirname, '..', '..', app, 'public', 'index.html'), host);
ddiff --git a/apps/_shared/test/nginx-xov.test.js b/apps/_shared/test/nginx-xov.test.js
nnew file mode 100644
iindex 0000000..4aa90f3
---- /dev/null
++++ b/apps/_shared/test/nginx-xov.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++// Every proxied location of a satellite vhost drops a client-supplied X-OV-* header, so only the
++// gateway (which reaches the satellite directly over loopback, never through nginx) can set one.
++// Guards the open-redirect and canonical/og:url poisoning fix (apps/_shared/host-role.js is the app-side
++// second line of defence; this is the nginx one). node apps/_shared/test/nginx-xov.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const APPS = path.join(__dirname, '..', '..');
++const HEADERS = ['X-OV-Tool', 'X-OV-Host-Role', 'X-OV-Canonical-Host', 'X-OV-Short-Host'];
++let locations = 0;
++
++for (const app of fs.readdirSync(APPS)) {
++ if (app === 'gateway') continue; // the gateway strips inbound X-OV-* itself (registry/host-middleware.js)
++ const dir = path.join(APPS, app, 'deploy', 'nginx');
++ if (!fs.existsSync(dir)) continue;
++ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.conf'))) {
++ const lines = fs.readFileSync(path.join(dir, file), 'utf8').split('\n');
++ lines.forEach((line, i) => {
++ if (!/^\s*proxy_set_header Host \$host;$/.test(line)) return;
++ locations++;
++ const block = lines.slice(i, i + 7).join('\n');
++ for (const h of HEADERS) {
++ assert.ok(block.includes(`proxy_set_header ${h} "";`), `${app}/${file}: the proxied location at line ${i + 1} clears ${h}`);
++ }
++ });
++ }
++}
++
++assert.ok(locations >= 16, `every satellite proxy location checked (${locations})`);
++console.log(`nginx satellite vhosts clear client X-OV-* (${locations} proxied locations): all checks passed`);
ddiff --git a/apps/audio/deploy/nginx/audio.openvibe.tools.conf b/apps/audio/deploy/nginx/audio.openvibe.tools.conf
iindex 9229552..56363a2 100644
---- a/apps/audio/deploy/nginx/audio.openvibe.tools.conf
++++ b/apps/audio/deploy/nginx/audio.openvibe.tools.conf
@@@ -83,6 +83,11 @@ server {
proxy_pass http://127.0.0.1:4014;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -98,6 +103,11 @@ server {
proxy_pass http://127.0.0.1:4014;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/docs/deploy/nginx/docs.openvibe.tools.conf b/apps/docs/deploy/nginx/docs.openvibe.tools.conf
iindex 9e31ece..1825d4c 100644
---- a/apps/docs/deploy/nginx/docs.openvibe.tools.conf
++++ b/apps/docs/deploy/nginx/docs.openvibe.tools.conf
@@@ -63,6 +63,11 @@ server {
proxy_pass http://127.0.0.1:4016;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -78,6 +83,11 @@ server {
proxy_pass http://127.0.0.1:4016;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/food/deploy/nginx/food.openvibe.tools.conf b/apps/food/deploy/nginx/food.openvibe.tools.conf
iindex f6515ab..abe74a7 100644
---- a/apps/food/deploy/nginx/food.openvibe.tools.conf
++++ b/apps/food/deploy/nginx/food.openvibe.tools.conf
@@@ -45,6 +45,11 @@ server {
proxy_pass http://127.0.0.1:4011;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -56,6 +61,11 @@ server {
proxy_pass http://127.0.0.1:4011;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/food/public/js/app.js b/apps/food/public/js/app.js
iindex 2762754..32540d7 100644
---- a/apps/food/public/js/app.js
++++ b/apps/food/public/js/app.js
@@@ -8,6 +8,13 @@
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
++ // Names, addresses and websites come from OpenStreetMap and other third parties: never insert them as
++ // HTML. Escape every value put into a template, and only accept http(s) for a link.
++ const esc = v => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ function httpUrl(u) {
++ try { const x = new URL(String(u), location.origin); return x.protocol === 'http:' || x.protocol === 'https:' ? x.href : ''; } catch { return ''; }
++ }
++
const state = {
location: null, // {lat, lon, name}
foods: [],
@@@ -52,20 +59,22 @@
const data = await res.json();
const banks = data?.locations || data || [];
if (!banks.length) { c.innerHTML = '<div class="empty-state"><i class="fa-solid fa-hand-holding-heart fa-3x"></i><p>No food banks found within 10 miles. Try a different location.</p></div>'; return; }
-- c.innerHTML = banks.map(b => `<div class="card">
-- <div class="card-title"><i class="fa-solid fa-hand-holding-heart" style="color:var(--green)"></i> ${b.name || 'Food Bank'}</div>
++ c.innerHTML = banks.map(b => {
++ const site = httpUrl(b.website);
++ return `<div class="card">
++ <div class="card-title"><i class="fa-solid fa-hand-holding-heart" style="color:var(--green)"></i> ${esc(b.name || 'Food Bank')}</div>
<div class="card-meta">
-- ${b.address ? `<span><i class="fa-solid fa-location-dot"></i> ${b.address}</span>` : ''}
-- ${b.phone ? `<span><i class="fa-solid fa-phone"></i> ${b.phone}</span>` : ''}
-- ${b.hours ? `<span><i class="fa-solid fa-clock"></i> ${b.hours}</span>` : ''}
-- ${b.distanceMiles ? `<span><i class="fa-solid fa-route"></i> ${b.distanceMiles.toFixed(1)} mi</span>` : ''}
++ ${b.address ? `<span><i class="fa-solid fa-location-dot"></i> ${esc(b.address)}</span>` : ''}
++ ${b.phone ? `<span><i class="fa-solid fa-phone"></i> ${esc(b.phone)}</span>` : ''}
++ ${b.hours ? `<span><i class="fa-solid fa-clock"></i> ${esc(b.hours)}</span>` : ''}
++ ${b.distanceMiles ? `<span><i class="fa-solid fa-route"></i> ${esc(b.distanceMiles.toFixed(1))} mi</span>` : ''}
</div>
-- ${b.description ? `<div class="card-desc">${b.description}</div>` : ''}
++ ${b.description ? `<div class="card-desc">${esc(b.description)}</div>` : ''}
<div class="card-actions">
-- ${b.lat && b.lon ? `<a class="card-btn" href="https://www.google.com/maps/dir/?api=1&destination=${b.lat},${b.lon}" target="_blank"><i class="fa-solid fa-diamond-turn-right"></i> Directions</a>` : ''}
-- ${b.website ? `<a class="card-btn" href="${b.website}" target="_blank"><i class="fa-solid fa-globe"></i> Website</a>` : ''}
++ ${b.lat && b.lon ? `<a class="card-btn" href="https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(b.lat)},${encodeURIComponent(b.lon)}" target="_blank"><i class="fa-solid fa-diamond-turn-right"></i> Directions</a>` : ''}
++ ${site ? `<a class="card-btn" href="${esc(site)}" target="_blank" rel="noopener noreferrer"><i class="fa-solid fa-globe"></i> Website</a>` : ''}
</div>
-- </div>`).join('');
++ </div>`; }).join('');
} catch (e) { c.innerHTML = '<div class="empty-state"><p>Failed to load food banks.</p></div>'; }
}
@@@ -85,14 +94,14 @@
const key = (s.chain || s.name || '').toLowerCase().replace(/\s+/g, '');
const color = storeColors[key] || 'var(--text-muted)';
return `<div class="card">
-- <div class="card-title"><i class="fa-solid fa-store" style="color:${color}"></i> ${s.name || 'Store'}</div>
++ <div class="card-title"><i class="fa-solid fa-store" style="color:${esc(color)}"></i> ${esc(s.name || 'Store')}</div>
<div class="card-meta">
-- ${s.address ? `<span><i class="fa-solid fa-location-dot"></i> ${s.address}</span>` : ''}
-- ${s.distance ? `<span><i class="fa-solid fa-route"></i> ${s.distance}</span>` : ''}
-- ${s.phone ? `<span><i class="fa-solid fa-phone"></i> ${s.phone}</span>` : ''}
++ ${s.address ? `<span><i class="fa-solid fa-location-dot"></i> ${esc(s.address)}</span>` : ''}
++ ${s.distance ? `<span><i class="fa-solid fa-route"></i> ${esc(s.distance)}</span>` : ''}
++ ${s.phone ? `<span><i class="fa-solid fa-phone"></i> ${esc(s.phone)}</span>` : ''}
</div>
<div class="card-actions">
-- ${s.lat && s.lon ? `<a class="card-btn" href="https://www.google.com/maps/dir/?api=1&destination=${s.lat},${s.lon}" target="_blank"><i class="fa-solid fa-diamond-turn-right"></i> Directions</a>` : ''}
++ ${s.lat && s.lon ? `<a class="card-btn" href="https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.lat)},${encodeURIComponent(s.lon)}" target="_blank"><i class="fa-solid fa-diamond-turn-right"></i> Directions</a>` : ''}
</div>
</div>`;
}).join('');
@@@ -110,7 +119,7 @@
try {
const res = await fetch(`${API}/api/meal-plan?budget=${budget}&days=${days}&campFriendly=${camp}&shelfStable=${shelf}&randomize=true`);
const plan = await res.json();
-- if (plan.error) { rc.innerHTML = `<div class="empty-state"><p>${plan.error}</p></div>`; return; }
++ if (plan.error) { rc.innerHTML = `<div class="empty-state"><p>${esc(plan.error)}</p></div>`; return; }
let html = '';
if (plan.plan) {
plan.plan.forEach((day, i) => {
@@@ -122,8 +131,8 @@
const price = item?.price ? `$${item.price.toFixed(2)}` : '';
const cal = item?.calories ? `${item.calories} cal` : '';
html += `<div class="meal-row">
-- <span class="meal-name"><strong>${meal}:</strong> ${name}</span>
-- <span class="meal-meta"><span class="meal-price">${price}</span><span>${cal}</span></span>
++ <span class="meal-name"><strong>${esc(meal)}:</strong> ${esc(name)}</span>
++ <span class="meal-meta"><span class="meal-price">${esc(price)}</span><span>${esc(cal)}</span></span>
</div>`;
});
}
@@@ -182,11 +191,11 @@
const bestPrice = validPrices.length ? Math.min(...validPrices) : null;
const campStars = (f.campFriendly || 0);
html += `<tr>
-- <td><strong>${f.name || '?'}</strong>${f.servingSize ? `<br><span style="color:var(--text-muted);font-size:10px">${f.servingSize}</span>` : ''}</td>
-- <td><span class="food-group-badge ${f.group || ''}">${f.group || '?'}</span></td>
-- <td>${f.calories || '-'}</td>
-- <td>${f.proteinG ? f.proteinG + 'g' : '-'}</td>
-- <td>${f.servings || '-'}</td>
++ <td><strong>${esc(f.name || '?')}</strong>${f.servingSize ? `<br><span style="color:var(--text-muted);font-size:10px">${esc(f.servingSize)}</span>` : ''}</td>
++ <td><span class="food-group-badge ${esc(f.group || '')}">${esc(f.group || '?')}</span></td>
++ <td>${esc(f.calories || '-')}</td>
++ <td>${f.proteinG ? esc(f.proteinG) + 'g' : '-'}</td>
++ <td>${esc(f.servings || '-')}</td>
${stores.map(s => {
const p = prices[s];
if (p == null) return '<td class="price-cell price-na">—</td>';
@@@ -212,7 +221,7 @@
if (state.location) {
state.foodMap.setView([state.location.lat, state.location.lon], 12);
// Add marker
-- L.marker([state.location.lat, state.location.lon]).addTo(state.foodMap).bindPopup(`<b>${state.location.name}</b>`).openPopup();
++ L.marker([state.location.lat, state.location.lon]).addTo(state.foodMap).bindPopup(`<b>${esc(state.location.name)}</b>`).openPopup();
}
setTimeout(() => state.foodMap.invalidateSize(), 200);
}
ddiff --git a/apps/food/test/frontend-xss.test.js b/apps/food/test/frontend-xss.test.js
nnew file mode 100644
iindex 0000000..466bc9e
---- /dev/null
++++ b/apps/food/test/frontend-xss.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++// Food's page renders OpenStreetMap names, addresses and websites. They must become text, never markup
++// (the finding: an OSM name with an onerror handler, or website=javascript:, ran on the page). Loads
++// public/js/app.js in a vm with a small DOM stub and drives a location search whose upstream answers
++// carry HTML, a quote-breaking attribute value and a javascript: website.
++// node apps/food/test/frontend-xss.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++function makeEl() {
++ const listeners = {};
++ const el = {
++ innerHTML: '', textContent: '', value: '', checked: false,
++ dataset: {}, style: {},
++ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
++ addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
++ querySelector: () => makeEl(), querySelectorAll: () => [],
++ fire(type, e) { for (const fn of (listeners[type] || [])) fn(e || {}); },
++ };
++ return el;
++}
++const els = new Map();
++const el = (sel) => { if (!els.has(sel)) els.set(sel, makeEl()); return els.get(sel); };
++
++const ctx = {
++ console, JSON, Promise, Date, Math, Object, Array, Error, Number, String, Boolean, URL, URLSearchParams,
++ parseFloat, parseInt, isNaN,
++ setTimeout: (f) => { if (typeof f === 'function') f(); return 0; }, clearTimeout() {},
++ setInterval: () => 0, clearInterval() {},
++ location: { hostname: 'food.openvibe.tools', origin: 'https://food.openvibe.tools', href: 'https://food.openvibe.tools/' },
++ localStorage: { getItem: () => null, setItem() {} },
++ navigator: {},
++ getComputedStyle: () => ({ getPropertyValue: () => '' }),
++ Toastify: () => ({ showToast() {} }),
++ document: {
++ readyState: 'complete',
++ querySelector: el,
++ querySelectorAll: () => [],
++ getElementById: () => null,
++ addEventListener() {},
++ createElement: () => makeEl(),
++ },
++ fetch: (u) => {
++ const url = String(u);
++ if (url.includes('/api/geocode')) return Promise.resolve({ ok: true, json: async () => [{ lat: 44, lon: -103, name: '<b>Place</b>' }] });
++ if (url.includes('/api/food-banks')) return Promise.resolve({
++ ok: true,
++ json: async () => ({ locations: [
++ { name: '<img src=x onerror=alert(1)>', address: '" onmouseover="alert(2)', phone: '<b>555</b>', hours: '<u>9-5</u>', description: '<script>alert(3)</script>', website: 'javascript:alert(4)', lat: 44, lon: -103, distanceMiles: 1.5 },
++ { name: 'Plain Bank', website: 'https://example.org/page', lat: 44.1, lon: -103.1, distanceMiles: 2 },
++ ] }),
++ });
++ return Promise.resolve({ ok: true, json: async () => ({}) });
++ },
++};
++ctx.window = ctx;
++vm.createContext(ctx);
++vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8'), ctx, { filename: 'app.js' });
++
++(async () => {
++ el('#loc-input').value = 'Nowhere';
++ el('#btn-loc-search').fire('click');
++ for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r));
++
++ const html = el('#food-banks-list').innerHTML;
++ assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'), 'an OSM name is rendered as text');
++ assert.ok(!/<img\b|<script\b|<b>|<u>/i.test(html), 'no markup from OSM fields reaches the page');
++ assert.ok(html.includes('&lt;script&gt;alert(3)&lt;/script&gt;'), 'the description is escaped');
++ assert.ok(!html.includes('onmouseover="alert') && !html.includes('" onmouseover='), 'a trailing quote cannot break out of an attribute');
++ assert.ok(!html.includes('javascript:'), 'a javascript: website is not turned into a link');
++ assert.ok(!/>Website</.test(html) || html.includes('href="https://example.org/page"'), 'the rejected site is dropped, the http(s) one kept');
++ assert.ok(html.includes('href="https://example.org/page"'), 'an https website is still linked');
++
++ console.log('food front end escapes OSM data (XSS): all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/apps/gateway/server/config.js b/apps/gateway/server/config.js
iindex 71cfdfd..37702de 100644
---- a/apps/gateway/server/config.js
++++ b/apps/gateway/server/config.js
@@@ -6,7 +6,9 @@ const isProduction = (process.env.NODE_ENV || 'development') === 'production';
module.exports = {
port: parseInt(process.env.PORT, 10) || 4001,
-- host: process.env.HOST || '0.0.0.0',
++ // Loopback only: nginx (every vhost, custom domains included) reaches it on 127.0.0.1, so it cannot
++ // be bypassed through an open host firewall, skipping nginx's rate limits.
++ host: process.env.HOST || '127.0.0.1',
nodeEnv: process.env.NODE_ENV || 'development',
isProduction,
ddiff --git a/apps/gateway/server/dev/routes.js b/apps/gateway/server/dev/routes.js
iindex 26d569c..34c30a2 100644
---- a/apps/gateway/server/dev/routes.js
++++ b/apps/gateway/server/dev/routes.js
@@@ -20,9 +20,15 @@ const guardLimits = require('../../../_shared/guard/limits');
const { readHtml, decodeBody } = require('./reader');
// ── In-memory webhook bin storage ────────────────────────────
--const webhookBins = new Map(); // binId → { created, requests, owner, ipKey }
++const webhookBins = new Map(); // binId → { created, requests, bytes, owner, ipKey }
const WEBHOOK_BIN_TTL = 60 * 60 * 1000; // 1 hour
const WEBHOOK_MAX_REQUESTS = 200;
++// A bin holds a bounded amount of memory: a received body is kept up to WEBHOOK_MAX_BODY_BYTES (the
++// rest is cut, and the entry says so), and a bin keeps at most WEBHOOK_MAX_BIN_BYTES in all (oldest
++// entries go first). Without the caps, one address's ten bins × 200 entries × the 1 MB JSON body
++// limit could pin gigabytes of the gateway's heap.
++const WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
++const WEBHOOK_MAX_BIN_BYTES = 1024 * 1024;
function cleanupBins() {
const now = Date.now();
@@@ -91,7 +97,7 @@ function createWebhookRouter(opts = {}) {
if (fromHere >= caps.perIp && refuse(req, res, { status: 429, code: 'tools.quota.exceeded', reason: 'webhook', tool: 'webhook', retryAfter: 300, detail: `At most ${caps.perIp} webhook bins from one address at a time.`, extra: { scope: 'address' } })) return undefined;
const binId = crypto.randomBytes(12).toString('hex');
-- webhookBins.set(binId, { created: Date.now(), requests: [], owner: who.owner, ipKey: who.ipKey });
++ webhookBins.set(binId, { created: Date.now(), requests: [], bytes: 0, owner: who.owner, ipKey: who.ipKey });
res.json({
ok: true,
@@@ -121,10 +127,6 @@ function createWebhookRouter(opts = {}) {
const bin = webhookBins.get(req.params.binId);
if (!bin) return res.status(404).json({ error: 'Bin not found or expired' });
-- if (bin.requests.length >= WEBHOOK_MAX_REQUESTS) {
-- bin.requests.shift(); // drop oldest
-- }
--
const entry = {
id: crypto.randomBytes(6).toString('hex'),
timestamp: Date.now(),
@@@ -142,7 +144,22 @@ function createWebhookRouter(opts = {}) {
delete entry.headers['cookie'];
delete entry.headers['authorization'];
++ // A large webhook (a GitHub push, a Stripe event) is still received: its body is kept as text up
++ // to the cap, marked truncated, so the bin shows what arrived without holding all of it.
++ const bodyText = entry.body == null ? '' : (typeof entry.body === 'string' ? entry.body : JSON.stringify(entry.body));
++ if (Buffer.byteLength(bodyText) > WEBHOOK_MAX_BODY_BYTES) {
++ entry.body = Buffer.from(bodyText).subarray(0, WEBHOOK_MAX_BODY_BYTES).toString('utf8');
++ entry.truncated = true;
++ }
++ const entryBytes = Buffer.byteLength(JSON.stringify(entry));
++
++ // Keep the bin within its count and byte bounds, dropping the oldest entries first.
++ while (bin.requests.length && (bin.requests.length >= WEBHOOK_MAX_REQUESTS || bin.bytes + entryBytes > WEBHOOK_MAX_BIN_BYTES)) {
++ bin.bytes -= Buffer.byteLength(JSON.stringify(bin.requests.shift()));
++ }
++
bin.requests.push(entry);
++ bin.bytes += entryBytes;
res.status(200).json({ ok: true, message: 'Received' });
});
@@@ -245,7 +262,8 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
} catch (err) {
if (err instanceof TargetRefused) return res.status(403).json({ error: err.message, code: err.code });
if (err && err.status === 400) return res.status(400).json({ error: err.message });
-- res.status(502).json({ error: `Failed to fetch: ${err.message}` });
++ console.error('[OpenGraph] fetch failed:', err.message);
++ res.status(502).json({ error: 'Failed to fetch the page.' });
}
});
@@@ -317,7 +335,8 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
} catch (err) {
if (err instanceof TargetRefused) return res.status(403).json({ error: err.message, code: err.code });
if (err && err.status === 400) return res.status(400).json({ error: err.message });
-- res.status(502).json({ error: `Failed to fetch: ${err.message}` });
++ console.error('[Read] fetch failed:', err.message);
++ res.status(502).json({ error: 'Failed to fetch the page.' });
}
});
ddiff --git a/apps/gateway/test/guard.test.js b/apps/gateway/test/guard.test.js
iindex 3520688..7ad9503 100644
---- a/apps/gateway/test/guard.test.js
++++ b/apps/gateway/test/guard.test.js
@@@ -47,6 +47,7 @@ async function serve(app) {
const app = express();
app.set('trust proxy', TRUST_PROXY);
app.use(cookieParser());
++ app.use(express.json({ limit: '1mb' })); // the gateway's own body parser (webhook bodies)
app.use(guard.identify);
app.use('/api/net', createNetRoutes(null, null, { egress, guard }));
app.use('/api/dev', createDevRoutes(null, null, { egress, guard }));
@@@ -114,6 +115,22 @@ async function serve(app) {
r = await fetch(`${s.base}/api/dev/webhook/bins/${made.binId}`, { method: 'DELETE', headers: { cookie } });
assert.strictEqual((await r.json()).deleted, true, 'the maker can');
++ // A bin's memory is bounded: a large body is received but kept only up to 64 KB (marked
++ // truncated), and a bin keeps at most 1 MB (oldest dropped), so a flurry of large posts cannot
++ // pin the gateway's heap.
++ r = await fetch(`${s.base}/api/dev/webhook/bins`, { method: 'POST', headers: from('203.0.113.30') });
++ const capBin = await r.json();
++ const capCookie = String(r.headers.get('set-cookie')).split(';')[0];
++ const post = (body) => fetch(`${s.base}/api/dev/webhook/bins/${capBin.binId}/in`, { method: 'POST', headers: { 'content-type': 'application/json' }, body });
++ assert.strictEqual((await post(JSON.stringify({ x: 'a'.repeat(100 * 1024) }))).status, 200, 'a large webhook (a GitHub push) is still received');
++ const big = (await (await get(`/api/dev/webhook/bins/${capBin.binId}`, { cookie: capCookie })).json()).requests[0];
++ assert.ok(big.truncated && Buffer.byteLength(big.body) <= 64 * 1024, 'its body is kept up to 64 KB and marked truncated');
++ const payload = JSON.stringify({ y: 'b'.repeat(48 * 1024) });
++ for (let i = 0; i < 30; i++) assert.strictEqual((await post(payload)).status, 200);
++ const kept = await (await get(`/api/dev/webhook/bins/${capBin.binId}`, { cookie: capCookie })).json();
++ assert.ok(kept.requestCount < 30, `a bin drops the oldest requests rather than growing without bound (${kept.requestCount})`);
++ assert.ok(kept.requestCount <= Math.ceil((1024 * 1024) / (48 * 1024)) + 1, `kept within the bin byte cap (${kept.requestCount})`);
++
// Caps: per owner and per address (report mode records; a guard in enforce mode refuses).
const enforce = createGuard({ app: 'gateway', contracts, issuer: ISSUER, keys: { get: () => publicKey }, specs: require('../server/dev/descriptors').SPECS, env: { TOOLS_GUARD: 'enforce' }, log: { log() {}, warn() {}, error() {} }, pruneIntervalMs: 0 });
const app2 = express();
ddiff --git a/apps/img/deploy/nginx/img.openvibe.tools.conf b/apps/img/deploy/nginx/img.openvibe.tools.conf
iindex 3197f60..657588a 100644
---- a/apps/img/deploy/nginx/img.openvibe.tools.conf
++++ b/apps/img/deploy/nginx/img.openvibe.tools.conf
@@@ -65,6 +65,11 @@ server {
proxy_pass http://127.0.0.1:4012;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -80,6 +85,11 @@ server {
proxy_pass http://127.0.0.1:4012;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/maps/deploy/nginx/maps.openvibe.tools.conf b/apps/maps/deploy/nginx/maps.openvibe.tools.conf
iindex a634f3b..5950d72 100644
---- a/apps/maps/deploy/nginx/maps.openvibe.tools.conf
++++ b/apps/maps/deploy/nginx/maps.openvibe.tools.conf
@@@ -45,6 +45,11 @@ server {
proxy_pass http://127.0.0.1:4010;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -61,6 +66,11 @@ server {
proxy_pass http://127.0.0.1:4010;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -73,6 +83,11 @@ server {
proxy_pass http://127.0.0.1:4010;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/maps/public/js/app.js b/apps/maps/public/js/app.js
iindex f0d131d..1f38e89 100644
---- a/apps/maps/public/js/app.js
++++ b/apps/maps/public/js/app.js
@@@ -29,6 +29,15 @@
const show = el => el && el.classList.remove('hidden');
const hide = el => el && el.classList.add('hidden');
++ // Names, descriptions, amenities and tags come from OpenStreetMap and other third parties: never
++ // insert them as HTML. Escape every value put into a template, and only accept http(s) for a link.
++ const esc = v => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ function httpUrl(u) {
++ try { const x = new URL(String(u), location.origin); return x.protocol === 'http:' || x.protocol === 'https:' ? x.href : ''; } catch { return ''; }
++ }
++ // Coordinates are numbers from the sources; coerce so nothing can break out of an href.
++ const num = v => (Number.isFinite(Number(v)) ? Number(v) : '');
++
function toast(msg, type = 'info') {
const bg = { info: '#22c55e', success: '#059669', error: '#ef4444', warn: '#f59e0b', warning: '#f59e0b' };
Toastify({ text: msg, duration: 3500, gravity: 'bottom', position: 'right',
@@@ -230,9 +239,9 @@
<span class="sp-count" id="sp-count">0 / ${sources.length}</span>
</div>
<div class="sp-grid">${sources.map(s =>
-- `<div class="sp-item" data-source="${s.name}" id="sp-${s.name.replace(/[^a-zA-Z0-9]/g, '')}">
++ `<div class="sp-item" data-source="${esc(s.name)}" id="sp-${s.name.replace(/[^a-zA-Z0-9]/g, '')}">
<i class="fa-solid fa-spinner fa-spin sp-icon"></i>
-- <span class="sp-name">${s.name}</span>
++ <span class="sp-name">${esc(s.name)}</span>
<span class="sp-result"></span>
</div>`
).join('')}</div>
@@@ -291,7 +300,7 @@
const btn = $('#btn-search');
btn.classList.add('loading');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i><span>Searching...</span>';
-- setStatus(`<i class="fa-solid fa-spinner fa-spin"></i> Searching "${query}"...`);
++ setStatus(`<i class="fa-solid fa-spinner fa-spin"></i> Searching "${esc(query)}"...`);
hide($('#welcome-card'));
$('#results-list').innerHTML = '<div class="search-progress"><div class="spinner"></div><p>Geocoding location...</p></div>';
@@@ -316,7 +325,7 @@
if (state.userMarker) state.map.removeLayer(state.userMarker);
state.userMarker = L.marker([lat, lon], {
icon: L.divIcon({ className: 'user-marker', iconSize: [20, 20], iconAnchor: [10, 10] }), zIndexOffset: 1000
-- }).addTo(state.map).bindPopup(`<b>Search Center</b><br>${displayName}`);
++ }).addTo(state.map).bindPopup(`<b>Search Center</b><br>${esc(displayName)}`);
state.map.setView([lat, lon], 11, { animate: false });
// Placeholder for source progress — will be replaced once sources manifest arrives
@@@ -407,13 +416,13 @@
fetchWeather(lat, lon);
-- setStatus(`<i class="fa-solid fa-check-circle"></i> ${state.locations.length} spots near "${query}" in ${state.queryTime}s`);
++ setStatus(`<i class="fa-solid fa-check-circle"></i> ${state.locations.length} spots near "${esc(query)}" in ${state.queryTime}s`);
toast(`Found ${state.locations.length} locations near ${query}`);
} catch (err) {
-- setStatus(`<i class="fa-solid fa-exclamation-triangle"></i> ${err.message}`);
++ setStatus(`<i class="fa-solid fa-exclamation-triangle"></i> ${esc(err.message)}`);
toast(err.message, 'error');
-- $('#results-list').innerHTML = `<div class="muted-text"><i class="fa-solid fa-triangle-exclamation"></i> ${err.message}</div>`;
++ $('#results-list').innerHTML = `<div class="muted-text"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(err.message)}</div>`;
} finally {
btn.classList.remove('loading');
btn.innerHTML = '<i class="fa-solid fa-person-shelter"></i><span>Search</span>';
@@@ -447,25 +456,25 @@
const visible = state.filtered.slice(0, limit);
c.innerHTML = visible.map((loc,i) => {
const tc = loc._typeClass, isFav = state.favorites.includes(loc._id);
-- return `<div class="result-card" data-idx="${i}" data-id="${loc._id}">
-- <button class="result-fav-btn ${isFav?'favorited':''}" data-fav="${loc._id}"><i class="fa-${isFav?'solid':'regular'} fa-heart"></i></button>
++ return `<div class="result-card" data-idx="${i}" data-id="${esc(loc._id)}">
++ <button class="result-fav-btn ${isFav?'favorited':''}" data-fav="${esc(loc._id)}"><i class="fa-${isFav?'solid':'regular'} fa-heart"></i></button>
<div class="result-card-header">
-- <div class="result-type-icon ${tc}"><i class="fa-solid ${typeIcon(tc)}"></i></div>
++ <div class="result-type-icon ${esc(tc)}"><i class="fa-solid ${esc(typeIcon(tc))}"></i></div>
<div style="flex:1;min-width:0">
-- <div class="result-name">${loc.name||'Unknown'}</div>
++ <div class="result-name">${esc(loc.name||'Unknown')}</div>
<div class="result-meta">
-- <span class="result-type-label">${loc.type||'Unknown'}</span>
-- <span><i class="fa-solid ${loc.sourceIcon||'fa-database'}"></i> ${loc.source||''}</span>
-- ${loc.fee&&loc.fee!=='Free'?`<span><i class="fa-solid fa-dollar-sign"></i> ${loc.fee}</span>`:''}
++ <span class="result-type-label">${esc(loc.type||'Unknown')}</span>
++ <span><i class="fa-solid ${esc(loc.sourceIcon||'fa-database')}"></i> ${esc(loc.source||'')}</span>
++ ${loc.fee&&loc.fee!=='Free'?`<span><i class="fa-solid fa-dollar-sign"></i> ${esc(loc.fee)}</span>`:''}
</div>
</div>
<div style="text-align:right;flex-shrink:0">
-- ${loc.distanceMiles?`<div class="result-distance">${distText(loc.distanceMiles)}</div>`:''}
++ ${loc.distanceMiles?`<div class="result-distance">${esc(distText(loc.distanceMiles))}</div>`:''}
<div class="stealth-rating">${stealthStars(loc.stealthRating)}</div>
</div>
</div>
-- ${loc.description?`<div class="result-description">${truncate(loc.description,100)}</div>`:''}
-- <div class="result-tags">${(loc.amenities||[]).slice(0,4).map(a=>`<span class="result-tag"><i class="fa-solid fa-check"></i> ${a}</span>`).join('')}</div>
++ ${loc.description?`<div class="result-description">${esc(truncate(loc.description,100))}</div>`:''}
++ <div class="result-tags">${(loc.amenities||[]).slice(0,4).map(a=>`<span class="result-tag"><i class="fa-solid fa-check"></i> ${esc(a)}</span>`).join('')}</div>
</div>`;
}).join('') + (state.filtered.length > limit ? `<div class="muted-text">${state.filtered.length-limit} more results hidden. Zoom in or filter.</div>` : '');
}
@@@ -486,8 +495,8 @@
})
});
marker._typeClass = tc;
-- const dist = loc.distanceMiles ? `<br><span class="popup-distance">${distText(loc.distanceMiles)}</span>` : '';
-- marker.bindPopup(`<div class="popup-name">${loc.name||'Unknown'}</div><div class="popup-type">${loc.type||''} – ${loc.source||''}</div>${dist}<a href="#" class="popup-link" data-loc-id="${loc._id}">View details →</a>`);
++ const dist = loc.distanceMiles ? `<br><span class="popup-distance">${esc(distText(loc.distanceMiles))}</span>` : '';
++ marker.bindPopup(`<div class="popup-name">${esc(loc.name||'Unknown')}</div><div class="popup-type">${esc(loc.type||'')} – ${esc(loc.source||'')}</div>${dist}<a href="#" class="popup-link" data-loc-id="${esc(loc._id)}">View details →</a>`);
marker.on('click', () => selectLocation(loc));
state.markerCache.set(loc._id, marker);
}
@@@ -519,7 +528,7 @@
function selectLocation(loc) {
const panel = $('#detail-panel'), isFav = state.favorites.includes(loc._id);
$('#detail-name').textContent = loc.name||'Unknown';
-- $('#detail-type').innerHTML = `<i class="fa-solid ${typeIcon(loc._typeClass)}"></i> ${loc.type||'Unknown'} – ${loc.source||''}`;
++ $('#detail-type').innerHTML = `<i class="fa-solid ${esc(typeIcon(loc._typeClass))}"></i> ${esc(loc.type||'Unknown')} – ${esc(loc.source||'')}`;
const favBtn = $('#btn-favorite-detail');
favBtn.className = `detail-fav-btn ${isFav?'favorited':''}`;
favBtn.innerHTML = `<i class="fa-${isFav?'solid':'regular'} fa-heart"></i>`;
@@@ -532,24 +541,24 @@
<div class="stealth-meter">${stealthBars(loc.stealthRating)}<span style="margin-left:8px;font-size:13px;font-weight:700;color:var(--green)">${loc.stealthRating||'?'}/5</span></div>
</div>
<div class="detail-section"><h3><i class="fa-solid fa-circle-info"></i> Information</h3>
-- <div class="detail-info-row"><span class="label"><i class="fa-solid fa-route"></i> Distance</span><span class="value">${dist}</span></div>
-- <div class="detail-info-row"><span class="label"><i class="fa-solid fa-map-pin"></i> Coords</span><span class="value" style="font-family:var(--font-mono);font-size:11px">${loc.lat?.toFixed(5)}, ${loc.lon?.toFixed(5)}</span></div>
-- ${loc.fee?`<div class="detail-info-row"><span class="label"><i class="fa-solid fa-dollar-sign"></i> Fee</span><span class="value">${loc.fee}</span></div>`:''}
-- ${loc.elevation?`<div class="detail-info-row"><span class="label"><i class="fa-solid fa-mountain"></i> Elevation</span><span class="value">${loc.elevation}ft</span></div>`:''}
-- <div class="detail-info-row"><span class="label"><i class="fa-solid fa-database"></i> Source</span><span class="value">${loc.source||'Unknown'}</span></div>
++ <div class="detail-info-row"><span class="label"><i class="fa-solid fa-route"></i> Distance</span><span class="value">${esc(dist)}</span></div>
++ <div class="detail-info-row"><span class="label"><i class="fa-solid fa-map-pin"></i> Coords</span><span class="value" style="font-family:var(--font-mono);font-size:11px">${num(loc.lat)}, ${num(loc.lon)}</span></div>
++ ${loc.fee?`<div class="detail-info-row"><span class="label"><i class="fa-solid fa-dollar-sign"></i> Fee</span><span class="value">${esc(loc.fee)}</span></div>`:''}
++ ${loc.elevation?`<div class="detail-info-row"><span class="label"><i class="fa-solid fa-mountain"></i> Elevation</span><span class="value">${esc(loc.elevation)}ft</span></div>`:''}
++ <div class="detail-info-row"><span class="label"><i class="fa-solid fa-database"></i> Source</span><span class="value">${esc(loc.source||'Unknown')}</span></div>
</div>
-- ${loc.description?`<div class="detail-section"><h3><i class="fa-solid fa-align-left"></i> Description</h3><p style="font-size:12px;line-height:1.6">${loc.description}</p></div>`:''}
-- ${loc.amenities?.length?`<div class="detail-section"><h3><i class="fa-solid fa-list-check"></i> Amenities</h3><div class="result-tags">${loc.amenities.map(a=>`<span class="result-tag"><i class="fa-solid fa-check"></i> ${a}</span>`).join('')}</div></div>`:''}
++ ${loc.description?`<div class="detail-section"><h3><i class="fa-solid fa-align-left"></i> Description</h3><p style="font-size:12px;line-height:1.6">${esc(loc.description)}</p></div>`:''}
++ ${loc.amenities?.length?`<div class="detail-section"><h3><i class="fa-solid fa-list-check"></i> Amenities</h3><div class="result-tags">${loc.amenities.map(a=>`<span class="result-tag"><i class="fa-solid fa-check"></i> ${esc(a)}</span>`).join('')}</div></div>`:''}
<div class="detail-section"><h3><i class="fa-solid fa-link"></i> Actions</h3>
<div class="detail-actions">
-- <a class="detail-btn" href="https://www.google.com/maps/dir/?api=1&destination=${loc.lat},${loc.lon}" target="_blank"><i class="fa-solid fa-diamond-turn-right"></i> Directions</a>
-- <a class="detail-btn" href="https://www.google.com/maps/@${loc.lat},${loc.lon},15z" target="_blank"><i class="fa-solid fa-map"></i> Google Maps</a>
++ <a class="detail-btn" href="https://www.google.com/maps/dir/?api=1&destination=${num(loc.lat)},${num(loc.lon)}" target="_blank"><i class="fa-solid fa-diamond-turn-right"></i> Directions</a>
++ <a class="detail-btn" href="https://www.google.com/maps/@${num(loc.lat)},${num(loc.lon)},15z" target="_blank"><i class="fa-solid fa-map"></i> Google Maps</a>
<button class="detail-btn" id="btn-copy-coords"><i class="fa-solid fa-copy"></i> Copy Coords</button>
</div>
</div>
<div class="detail-section"><h3><i class="fa-solid fa-note-sticky"></i> Notes</h3>
<div class="detail-note-area">
-- <textarea id="detail-note" placeholder="Your notes...">${noteText}</textarea>
++ <textarea id="detail-note" placeholder="Your notes...">${esc(noteText)}</textarea>
<button class="detail-btn primary" id="btn-save-note"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>`;
@@@ -566,8 +575,7 @@
});
// Highlight card
-- $$('.result-card').forEach(c => c.classList.remove('active'));
-- document.querySelector(`.result-card[data-id="${loc._id}"]`)?.classList.add('active');
++ $$('.result-card').forEach(c => c.classList.toggle('active', c.dataset.id === loc._id));
state.map.flyTo([loc.lat,loc.lon], 14, {animate:true});
// Terrain
@@@ -612,7 +620,7 @@
if (w.current) {
html += `<h3><i class="fa-solid fa-temperature-half"></i> Current Conditions</h3>`;
if (w.current.temperature != null) html += `<div class="detail-info-row"><span class="label">Temperature</span><span class="value">${Math.round(w.current.temperature)}°F</span></div>`;
-- if (w.current.description) html += `<div class="detail-info-row"><span class="label">Conditions</span><span class="value">${w.current.description}</span></div>`;
++ if (w.current.description) html += `<div class="detail-info-row"><span class="label">Conditions</span><span class="value">${esc(w.current.description)}</span></div>`;
if (w.current.windSpeed != null) html += `<div class="detail-info-row"><span class="label">Wind</span><span class="value">${w.current.windSpeed} mph</span></div>`;
if (w.current.humidity != null) html += `<div class="detail-info-row"><span class="label">Humidity</span><span class="value">${w.current.humidity}%</span></div>`;
}
@@@ -620,10 +628,10 @@
if (w.sun.sunrise) html += `<div class="detail-info-row"><span class="label"><i class="fa-solid fa-sun"></i> Sunrise</span><span class="value">${new Date(w.sun.sunrise).toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'})}</span></div>`;
if (w.sun.sunset) html += `<div class="detail-info-row"><span class="label"><i class="fa-solid fa-moon"></i> Sunset</span><span class="value">${new Date(w.sun.sunset).toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'})}</span></div>`;
}
-- if (w.moon) html += `<div class="detail-info-row"><span class="label">Moon</span><span class="value">${w.moon.emoji||''} ${w.moon.name||''} — ${w.moon.stealthRating||''}</span></div>`;
++ if (w.moon) html += `<div class="detail-info-row"><span class="label">Moon</span><span class="value">${esc(w.moon.emoji||'')} ${esc(w.moon.name||'')} — ${esc(w.moon.stealthRating||'')}</span></div>`;
html += '</div>';
-- if (w.forecast?.detailedForecast) html += `<div class="detail-section"><h3><i class="fa-solid fa-calendar-days"></i> Forecast</h3><p style="font-size:12px;line-height:1.6">${w.forecast.detailedForecast}</p></div>`;
-- if (w.campingAdvice) html += `<div class="detail-section"><h3><i class="fa-solid fa-person-shelter"></i> Camping Advice</h3><p style="font-size:12px;line-height:1.6;color:var(--green)">${w.campingAdvice}</p></div>`;
++ if (w.forecast?.detailedForecast) html += `<div class="detail-section"><h3><i class="fa-solid fa-calendar-days"></i> Forecast</h3><p style="font-size:12px;line-height:1.6">${esc(w.forecast.detailedForecast)}</p></div>`;
++ if (w.campingAdvice) html += `<div class="detail-section"><h3><i class="fa-solid fa-person-shelter"></i> Camping Advice</h3><p style="font-size:12px;line-height:1.6;color:var(--green)">${esc(w.campingAdvice)}</p></div>`;
c.innerHTML = html;
show($('#weather-modal'));
}
@@@ -643,9 +651,9 @@
if (!favLocs.length) { c.innerHTML = '<p class="muted-text"><i class="fa-regular fa-heart"></i> No favorites yet.</p>'; show($('#favorites-modal')); return; }
c.innerHTML = favLocs.map(loc => {
const tc = loc._typeClass;
-- return `<div class="food-card" style="cursor:pointer" data-fav-id="${loc._id}">
-- <div class="food-card-title"><i class="fa-solid ${typeIcon(tc)}" style="color:${markerColor(tc)}"></i> ${loc.name}</div>
-- <div class="food-card-meta"><span>${loc.type||''}</span><span>${loc.source||''}</span>${loc.distanceMiles?`<span>${distText(loc.distanceMiles)}</span>`:''}</div>
++ return `<div class="food-card" style="cursor:pointer" data-fav-id="${esc(loc._id)}">
++ <div class="food-card-title"><i class="fa-solid ${esc(typeIcon(tc))}" style="color:${esc(markerColor(tc))}"></i> ${esc(loc.name)}</div>
++ <div class="food-card-meta"><span>${esc(loc.type||'')}</span><span>${esc(loc.source||'')}</span>${loc.distanceMiles?`<span>${esc(distText(loc.distanceMiles))}</span>`:''}</div>
</div>`;
}).join('');
c.querySelectorAll('[data-fav-id]').forEach(el => el.addEventListener('click', () => {
@@@ -690,13 +698,13 @@
if (!data?.length && !data?.locations?.length) { c.innerHTML = '<p class="muted-text">No food banks found nearby.</p>'; return; }
const banks = data.locations || data;
c.innerHTML = banks.map(b => `<div class="food-card">
-- <div class="food-card-title"><i class="fa-solid fa-hand-holding-heart" style="color:var(--green)"></i> ${b.name||'Food Bank'}</div>
++ <div class="food-card-title"><i class="fa-solid fa-hand-holding-heart" style="color:var(--green)"></i> ${esc(b.name||'Food Bank')}</div>
<div class="food-card-meta">
-- ${b.address?`<span><i class="fa-solid fa-location-dot"></i> ${b.address}</span>`:''}
-- ${b.phone?`<span><i class="fa-solid fa-phone"></i> ${b.phone}</span>`:''}
-- ${b.hours?`<span><i class="fa-solid fa-clock"></i> ${b.hours}</span>`:''}
++ ${b.address?`<span><i class="fa-solid fa-location-dot"></i> ${esc(b.address)}</span>`:''}
++ ${b.phone?`<span><i class="fa-solid fa-phone"></i> ${esc(b.phone)}</span>`:''}
++ ${b.hours?`<span><i class="fa-solid fa-clock"></i> ${esc(b.hours)}</span>`:''}
</div>
-- ${b.description?`<div style="font-size:11px;color:var(--text-muted);margin-top:6px">${truncate(b.description,200)}</div>`:''}
++ ${b.description?`<div style="font-size:11px;color:var(--text-muted);margin-top:6px">${esc(truncate(b.description,200))}</div>`:''}
</div>`).join('');
} catch(e) { c.innerHTML = '<p class="muted-text">Failed to load food banks.</p>'; }
}
@@@ -712,10 +720,10 @@
if (!data?.length && !data?.stores?.length) { c.innerHTML = '<p class="muted-text">No stores found nearby.</p>'; return; }
const stores = data.stores || data;
c.innerHTML = stores.map(s => `<div class="food-card">
-- <div class="food-card-title"><i class="fa-solid fa-cart-shopping" style="color:var(--blue)"></i> ${s.name||'Store'}</div>
++ <div class="food-card-title"><i class="fa-solid fa-cart-shopping" style="color:var(--blue)"></i> ${esc(s.name||'Store')}</div>
<div class="food-card-meta">
-- ${s.address?`<span><i class="fa-solid fa-location-dot"></i> ${s.address}</span>`:''}
-- ${s.distance?`<span><i class="fa-solid fa-route"></i> ${s.distance}</span>`:''}
++ ${s.address?`<span><i class="fa-solid fa-location-dot"></i> ${esc(s.address)}</span>`:''}
++ ${s.distance?`<span><i class="fa-solid fa-route"></i> ${esc(s.distance)}</span>`:''}
</div>
</div>`).join('');
} catch(e) { c.innerHTML = '<p class="muted-text">Failed to load stores.</p>'; }
@@@ -741,7 +749,7 @@
try {
const res = await fetch(`${API}/api/meal-plan?budget=${budget}&days=${days}&campFriendly=${camp}&randomize=true`);
const plan = await res.json();
-- if (plan.error) { rc.innerHTML = `<p class="muted-text">${plan.error}</p>`; return; }
++ if (plan.error) { rc.innerHTML = `<p class="muted-text">${esc(plan.error)}</p>`; return; }
let html = '';
if (plan.plan) {
plan.plan.forEach((day, i) => {
@@@ -750,7 +758,7 @@
if (day[meal]) {
const items = Array.isArray(day[meal]) ? day[meal] : [day[meal]];
items.forEach(item => {
-- html += `<div class="meal-item"><span>${item.name||item}</span><span style="color:var(--green)">${item.price?'$'+item.price.toFixed(2):''}</span></div>`;
++ html += `<div class="meal-item"><span>${esc(item.name||item)}</span><span style="color:var(--green)">${item.price?'$'+item.price.toFixed(2):''}</span></div>`;
});
}
});
@@@ -781,9 +789,9 @@
function renderFoodList(foods) {
return foods.map(f => `<div class="food-card">
-- <div class="food-card-title">${f.name||'?'}</div>
++ <div class="food-card-title">${esc(f.name||'?')}</div>
<div class="food-card-meta">
-- ${f.group?`<span><i class="fa-solid fa-tag"></i> ${f.group}</span>`:''}
++ ${f.group?`<span><i class="fa-solid fa-tag"></i> ${esc(f.group)}</span>`:''}
${f.calories?`<span><i class="fa-solid fa-fire"></i> ${f.calories} cal</span>`:''}
${f.price?`<span><i class="fa-solid fa-dollar-sign"></i> $${f.price.toFixed(2)}</span>`:''}
${f.campFriendly?'<span><i class="fa-solid fa-campground"></i> Camp-friendly</span>':''}
ddiff --git a/apps/maps/server/config.js b/apps/maps/server/config.js
iindex 1004f38..211b320 100644
---- a/apps/maps/server/config.js
++++ b/apps/maps/server/config.js
@@@ -3,7 +3,9 @@ require('dotenv').config();
module.exports = {
port: parseInt(process.env.PORT, 10) || 4010,
-- host: process.env.HOST || '0.0.0.0',
++ // Loopback only, like the other satellites: nginx (and the food app) reach it on 127.0.0.1, so it
++ // cannot be bypassed through an open host firewall, skipping nginx's rate limits.
++ host: process.env.HOST || '127.0.0.1',
baseUrl: process.env.BASE_URL || 'https://maps.openvibe.tools',
// API Keys (optional — free tiers work without most of these)
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex 1c04082..4c3ee7e 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -69,8 +69,8 @@ const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
// ── Middleware ──────────────────────────────────────────────
// X-Forwarded-For is believed from exactly one hop on loopback: the host's nginx, the gateway or 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.
++// food app (which forwards its visitor's address). Maps binds loopback only (config.js), so a direct
++// caller cannot reach it around nginx, and a caller's own header must not count regardless.
app.set('trust proxy', TRUST_PROXY);
app.use(helmet({
contentSecurityPolicy: {
@@@ -373,11 +373,13 @@ app.get('/api/search/stream', async (req, res) => {
}
} catch (err) {
completed++;
-- allResults.sourceMeta[s.name] = { count: 0, error: err.message };
++ console.warn(`[Search] ${s.name} failed`); // the message can carry a key in a URL: never log it
++ // The upstream's error text can name hosts, keys or paths: the client gets a fixed line.
++ allResults.sourceMeta[s.name] = { count: 0, error: 'source unavailable' };
if (!clientClosed) {
send('source', {
name: s.name, icon: s.icon, status: 'error',
-- error: err.message, count: 0,
++ error: 'source unavailable', count: 0,
locations: [], bridges: [], crimeHeatmap: [],
completed, total: sources.length,
elapsed: Date.now() - t0,
@@@ -418,7 +420,7 @@ app.get('/api/weather', async (req, res) => {
const data = await weather.getWeather(lat, lon);
cache.set(ck, data, config.cache.weather);
res.json(data);
-- } catch (e) { res.status(502).json({ error: e.message }); }
++ } catch (e) { console.error('[Weather]', e.message); res.status(502).json({ error: 'Weather service is unavailable right now.' }); }
});
app.get('/api/terrain', async (req, res) => {
@@@ -428,7 +430,7 @@ app.get('/api/terrain', async (req, res) => {
try {
const data = await terrain.getTerrainInfo(lat, lon);
res.json(data);
-- } catch (e) { res.status(502).json({ error: e.message }); }
++ } catch (e) { console.error('[Terrain]', e.message); res.status(502).json({ error: 'Terrain data is unavailable right now.' }); }
});
app.get('/api/food-banks', async (req, res) => {
@@@ -439,7 +441,7 @@ app.get('/api/food-banks', async (req, res) => {
try {
const data = await grocery.findFoodBanks(lat, lon, radius);
res.json(data);
-- } catch (e) { res.status(502).json({ error: e.message }); }
++ } catch (e) { console.error('[Food banks]', e.message); res.status(502).json({ error: 'Food bank data is unavailable right now.' }); }
});
app.get('/api/stores', async (req, res) => {
@@@ -449,7 +451,7 @@ app.get('/api/stores', async (req, res) => {
try {
const data = await grocery.findNearbyStores(lat, lon);
res.json(data);
-- } catch (e) { res.status(502).json({ error: e.message }); }
++ } catch (e) { console.error('[Stores]', e.message); res.status(502).json({ error: 'Store data is unavailable right now.' }); }
});
app.get('/api/foods', (req, res) => {
ddiff --git a/apps/maps/test/frontend-escape.test.js b/apps/maps/test/frontend-escape.test.js
nnew file mode 100644
iindex 0000000..31d931b
---- /dev/null
++++ b/apps/maps/test/frontend-escape.test.js
@@@ -0,0 +1,37 @@
++'use strict';
++// The maps page renders OpenStreetMap names, descriptions, fees, amenities and coordinates into
++// innerHTML. Every such value must be escaped and every href limited to http(s) — the high finding:
++// an OSM name with an onerror handler ran under the page's unsafe-inline CSP and could read the
++// zone-wide ov_token cookie. This checks the helper the fix added behaves, and that no flagged field
++// is interpolated raw into markup. node apps/maps/test/frontend-escape.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
++const escDef = src.match(/const esc = v => [^\n]+;/);
++const urlDef = src.match(/function httpUrl\(u\) \{[\s\S]*?\n \}/);
++assert.ok(escDef, 'an HTML-escape helper is defined');
++assert.ok(urlDef, 'a http(s)-only URL helper is defined');
++
++const { esc, httpUrl } = vm.runInNewContext(`${escDef[0]}\n${urlDef[0]}\n({ esc, httpUrl })`, { URL, location: { origin: 'https://maps.openvibe.tools' } });
++assert.strictEqual(esc('<img src=x onerror=alert(1)>'), '&lt;img src=x onerror=alert(1)&gt;', 'markup is escaped');
++assert.strictEqual(esc('a"b\'c&d'), 'a&quot;b&#39;c&amp;d', 'quotes and ampersands are escaped');
++assert.strictEqual(httpUrl('javascript:alert(1)'), '', 'javascript: is not a URL');
++assert.strictEqual(httpUrl('data:text/html,<script>'), '', 'data: is not a URL');
++assert.strictEqual(httpUrl('https://example.org/x'), 'https://example.org/x', 'https is kept');
++
++// None of the OSM-derived fields the review flagged is interpolated raw into a template any more.
++for (const raw of [
++ "${loc.name||'Unknown'}", "${loc.type||'Unknown'}", "${loc.source||", '${loc._id}',
++ '${loc.description}', '${truncate(loc.description,100)}', '${loc.fee}', '${a}',
++ "${b.name||", '${b.address}', "${s.name||", '${s.address}',
++]) {
++ assert.ok(!src.includes(raw), `raw interpolation must be escaped: ${raw}`);
++}
++assert.ok(src.includes('${esc(loc.name'), 'the result card escapes the name');
++assert.ok(src.includes('${esc(loc.description)'), 'the detail panel escapes the description');
++assert.ok(src.includes('${esc(a)}'), 'amenities are escaped');
++
++console.log('maps front end escapes OSM data (XSS): all checks passed');
ddiff --git a/apps/text/deploy/nginx/text.openvibe.tools.conf b/apps/text/deploy/nginx/text.openvibe.tools.conf
iindex 0b3cb9b..978cfb3 100644
---- a/apps/text/deploy/nginx/text.openvibe.tools.conf
++++ b/apps/text/deploy/nginx/text.openvibe.tools.conf
@@@ -98,6 +98,11 @@ server {
proxy_pass http://127.0.0.1:4015;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/text/server/config.js b/apps/text/server/config.js
iindex c2104c2..fdda039 100644
---- a/apps/text/server/config.js
++++ b/apps/text/server/config.js
@@@ -3,7 +3,9 @@ require('dotenv').config();
module.exports = {
port: parseInt(process.env.PORT, 10) || 4015,
-- host: process.env.HOST || '0.0.0.0',
++ // Loopback only, like the other satellites: nginx reaches it on 127.0.0.1, so it cannot be
++ // bypassed through an open host firewall, skipping nginx's rate limits.
++ host: process.env.HOST || '127.0.0.1',
baseUrl: process.env.BASE_URL || 'https://text.openvibe.tools',
openvibeToolsUrl: process.env.OV_NETWORK_URL || 'https://openvibe.network',
};
ddiff --git a/apps/yt/deploy/nginx/yt.openvibe.tools.conf b/apps/yt/deploy/nginx/yt.openvibe.tools.conf
iindex fe45e98..945a4dd 100644
---- a/apps/yt/deploy/nginx/yt.openvibe.tools.conf
++++ b/apps/yt/deploy/nginx/yt.openvibe.tools.conf
@@@ -35,6 +35,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -51,6 +56,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -67,6 +77,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@@ -79,6 +94,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_set_header Host $host;
++ # X-OV-* are set only by the gateway (over loopback), never by a client: drop what a caller sends.
++ proxy_set_header X-OV-Tool "";
++ proxy_set_header X-OV-Host-Role "";
++ proxy_set_header X-OV-Canonical-Host "";
++ proxy_set_header X-OV-Short-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
ddiff --git a/apps/yt/public/js/app.js b/apps/yt/public/js/app.js
iindex 43f53d6..efb554e 100644
---- a/apps/yt/public/js/app.js
++++ b/apps/yt/public/js/app.js
@@@ -181,9 +181,10 @@
// --- Render Video Card ---
function renderVideoCard(info) {
++ // The thumbnail URL comes from YouTube: only http(s) may go into src.
videoThumb.innerHTML = `
-- <img src="${info.thumbnail || ''}" alt="" loading="lazy">
-- <span class="duration-badge">${formatDuration(info.duration)}</span>
++ <img src="${safeUrl(info.thumbnail)}" alt="" loading="lazy">
++ <span class="duration-badge">${escHtml(formatDuration(info.duration))}</span>
`;
const meta = [];
@@@ -209,6 +210,14 @@
return d.innerHTML;
}
++ // External URLs (the thumbnail) go into src only when they are http(s).
++ function safeUrl(u) {
++ try {
++ const x = new URL(String(u), location.origin);
++ return x.protocol === 'http:' || x.protocol === 'https:' ? x.href : '';
++ } catch { return ''; }
++ }
++
// --- Upstream status ---
// The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a
// link, with the network's shared notice (OpenVibeUI.notice: one design, always under the navbar).
ddiff --git a/apps/yt/server/downloader.js b/apps/yt/server/downloader.js
iindex 3b64444..7aa5949 100644
---- a/apps/yt/server/downloader.js
++++ b/apps/yt/server/downloader.js
@@@ -256,7 +256,21 @@ function friendlyDownloadError(line) {
if (/ffmpeg|ffprobe|Postprocessing/i.test(t)) return `Conversion failed on the server (${t.slice(0, 120)})`;
if (/Requested format is not available/i.test(t)) return 'That quality is not available for this video — try another';
if (/HTTP Error 4\d\d/i.test(t)) return `YouTube refused the request (${t.match(/HTTP Error \d+/)[0]})`;
-- return t.replace(/^ERROR:\s*/i, '').slice(0, 160) || 'Download failed';
++ // Anything unrecognised: a fixed message. The raw line (filesystem paths, proxy or cookie config)
++ // stays in the server log, never in the answer.
++ return 'The download failed on the server; please try again.';
++}
++
++/** An /api/info failure: the same friendly mapping, with a fixed fallback, since yt-dlp's stderr can
++ * name the server's own paths or proxy/cookies configuration (the raw line is logged by the route). */
++function friendlyInfoError(line) {
++ const t = String(line || '');
++ if (/Sign in to confirm|not a bot|cookies/i.test(t)) return 'YouTube is refusing requests from this server right now. This is on YouTube\'s side; please try again later.';
++ if (/Private video|members-only|login required/i.test(t)) return 'This video is private or members-only';
++ if (/Video unavailable|has been removed|not available/i.test(t)) return 'This video is unavailable';
++ if (/\bage[- ]restrict|confirm your age/i.test(t)) return 'Age-restricted videos cannot be downloaded';
++ if (/HTTP Error 4\d\d/i.test(t)) return 'YouTube refused the request';
++ return 'Could not read this video; it may be unavailable or blocked.';
}
// ── File names ───────────────────────────────────────────────
@@@ -721,6 +735,6 @@ function noteUpstream(state) { if (state === 'ok' || state === 'blocked') upstre
module.exports = {
getInfo, getInfoLimited, cachedInfo, videoId, limitReason, infoStats, startDownload, cancelDownload, getStatus, getFile, removeFile,
-- sanitizeTitle, downloadFilename, contentDisposition, applyLine,
++ sanitizeTitle, downloadFilename, contentDisposition, applyLine, friendlyDownloadError, friendlyInfoError,
cleanup, startCleanup, stopCleanup, getStats, isValidUrl, probeUpstream, startUpstreamProbe, getUpstream,
};
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex 3a90e40..8c42a91 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -150,11 +150,14 @@ app.get('/api/health', (_req, res) => {
res.set('Cache-Control', 'no-store').json({ status: 'ok', service: 'openvibe-yt', version: '1.0.0', stats, info: downloader.infoStats(), limits: { maxDuration: config.download.maxDuration, maxFilesizeMB: config.download.maxFilesize }, youtube: up.state, youtubeCheckedAt: up.checkedAt ? new Date(up.checkedAt).toISOString() : null });
});
--// Get video info
--app.post('/api/info', async (req, res) => {
++// Get video info. Each lookup spawns yt-dlp, so it is counted like every other API call (the tools-api
++// quota above), takes the Origin check (a cookie-bearing cross-site post is refused) and the
++// descriptor's per-target throttle: one video cannot be looked up without bound.
++app.post('/api/info', guard.originCheck(), async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
if (!downloader.isValidUrl(url)) return res.status(400).json({ error: 'Only YouTube URLs are supported (youtube.com, youtu.be)' });
++ if (!guard.target(req, res, { tool: 'yt', target: url })) return;
try {
// At most config.info.maxConcurrent yt-dlp lookups at once; the same video is looked up once
@@@ -163,8 +166,9 @@ app.post('/api/info', async (req, res) => {
res.json({ success: true, video: info });
} catch (err) {
if (err.status === 503) return res.status(503).set('Retry-After', '5').json({ error: err.message });
++ // yt-dlp's last stderr line can name filesystem paths or proxy/cookie config: log it, answer friendly.
console.error('[Info] Error:', err.message);
-- res.status(422).json({ error: err.message });
++ res.status(422).json({ error: downloader.friendlyInfoError(err.message) });
}
});
ddiff --git a/apps/yt/test/limits.test.js b/apps/yt/test/limits.test.js
iindex dce6593..9a10dd2 100644
---- a/apps/yt/test/limits.test.js
++++ b/apps/yt/test/limits.test.js
@@@ -62,7 +62,9 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const args = process.argv.slice(2);
const url = args[args.length - 1];
const long = url.includes('LONGLONG123');
++const errpath = url.includes('ERRPATH');
if (args.includes('--dump-json')) {
++ if (errpath) { process.stderr.write('ERROR: /srv/private/proxy.conf: permission denied\\n'); process.exit(1); }
process.stdout.write(JSON.stringify({ id: long ? 'LONGLONG123' : 'SHORTxx1234', title: 't', duration: long ? 4000 : 30, formats: [{ vcodec: 'avc1', acodec: 'mp4a' }] }));
process.exit(0);
}
@@@ -80,6 +82,22 @@ process.exit(0); // the match filter skipped it: no file
assert.strictEqual(body.video.downloadable, false);
assert.match(body.video.reason, /67 minutes long; downloads are limited to 10 minutes/);
assert.deepStrictEqual(body.video.limits, { maxDuration: 600, maxFilesizeMB: 100 });
++
++ // The info route is guarded: a call carrying our cookie from another site is refused (Origin
++ // check), one video cannot be looked up without bound (the descriptor's per-target throttle),
++ // and yt-dlp's stderr (which can name the server's own paths) never reaches the client.
++ r = await fetch(`${app.base}/api/info`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: `ov_tools_jobs=${'A'.repeat(43)}`, referer: 'https://evil.example/' }, body: JSON.stringify({ url: 'https://www.youtube.com/watch?v=LONGLONG123' }) });
++ assert.strictEqual(r.status, 403, 'a cookie-bearing call from another site is refused');
++ assert.strictEqual((await r.json()).code, 'tools.origin.refused');
++ // The throttle is per target host (targets.js): flood youtu.be so the www.youtube.com bucket
++ // the error check below uses is not spent.
++ let throttled = 0;
++ for (let i = 0; i < 12; i++) throttled += (await post('/api/info', { url: 'https://youtu.be/SHORTxx1234' })).status === 429 ? 1 : 0;
++ assert.ok(throttled >= 1, 'the per-target throttle stops a flood of lookups for one video host');
++ r = await post('/api/info', { url: 'https://www.youtube.com/watch?v=ERRPATH1234' });
++ assert.strictEqual(r.status, 422);
++ const errBody = await r.json();
++ assert.ok(!/srv\/private|proxy\.conf/.test(errBody.error), `yt-dlp stderr paths do not reach the client: ${errBody.error}`);
r = await post('/api/download', { url: 'https://youtu.be/LONGLONG123', quality: 'best' });
assert.strictEqual(r.status, 422, 'refused from the cached info, before yt-dlp starts');
assert.match((await r.json()).error, /limited to 10 minutes/);