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)
Changes
@@@ -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;@@@ -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 : '';nnew file mode 100644@@@ -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`);@@@ -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;@@@ -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);nnew file mode 100644@@@ -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`);@@@ -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;@@@ -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;@@@ -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;@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);}nnew file mode 100644@@@ -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('<img src=x onerror=alert(1)>'), '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('<script>alert(3)</script>'), '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); });@@@ -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,@@@ -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 hourconst 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.' });}});@@@ -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();@@@ -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;@@@ -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;@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>':''}@@@ -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)@@@ -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) => {nnew file mode 100644@@@ -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)>'), '<img src=x onerror=alert(1)>', 'markup is escaped');++assert.strictEqual(esc('a"b\'c&d'), 'a"b'c&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');@@@ -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;@@@ -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',};@@@ -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;@@@ -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).@@@ -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') upstremodule.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,};@@@ -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) });}});@@@ -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 fileassert.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/);
@@ -141,6 +141,10 @@ function createCallerResolver(o) {
@@ -141,6 +141,10 @@ function createCallerResolver(o) {
*/
*/
function jobOwnerResolver(callers) {
function jobOwnerResolver(callers) {
return function resolveOwner(req, res, { create = false, action = 'read' } = {}) {
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 });
const c = callers.resolve(req, res, { create, action, jobs: true });
if (c.error) return { error: c.error };
if (c.error) return { error: c.error };
if (!c.owner) return null;
if (!c.owner) return null;
@@ -15,12 +15,25 @@
@@ -15,12 +15,25 @@
// always has (each host is its own canonical). With them, the canonical host comes from the
// 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.
// 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.
// No dependencies: every satellite requires this file by relative path.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const TOOLS_HOME = 'https://openvibe.tools/';
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 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 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) {
function cleanHost(value) {
const h = String(value || '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');
const h = String(value || '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');
@@ -46,6 +59,11 @@ function isLocalHost(host) {
@@ -46,6 +59,11 @@ function isLocalHost(host) {
* @returns {{ viaGateway: boolean, tool: string, role: string, canonicalHost: string, shortHost: string }}
* @returns {{ viaGateway: boolean, tool: string, role: string, canonicalHost: string, shortHost: string }}
*/
*/
function hostRole(req) {
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 h = req.headers || {};
const toolRaw = String(h['x-ov-tool'] || '').trim().toLowerCase();
const toolRaw = String(h['x-ov-tool'] || '').trim().toLowerCase();
const tool = TOOL_RE.test(toolRaw) ? toolRaw : '';
const tool = TOOL_RE.test(toolRaw) ? toolRaw : '';
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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`);
@@ -199,6 +199,28 @@ async function serve(app) {
@@ -199,6 +199,28 @@ async function serve(app) {
} finally { await s.close(); g.close(); }
} 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 ──
// ── Bucket math and cost weighting from the descriptors ──
{
{
let clock = now0;
let clock = now0;
@@ -5,7 +5,10 @@ const assert = require('assert');
@@ -5,7 +5,10 @@ const assert = require('assert');
const path = require('path');
const path = require('path');
const hr = require('../host-role');
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() {
function res() {
const r = { headers: {}, statusCode: 200, body: null, redirected: null };
const r = { headers: {}, statusCode: 200, body: null, redirected: null };
r.setHeader = (k, v) => { r.headers[k.toLowerCase()] = v; };
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');
@@ -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('elsewhere.example', {}, '/api/health')).passed, 'APIs are never redirected');
assert.ok(run(req('localhost:4012')).passed && run(req('png.openvibe.tools')).passed);
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 ───────────────────────────────
// ── stampedPage: maps and food ───────────────────────────────
for (const [app, host] of [['maps', 'maps.openvibe.tools'], ['food', 'food.openvibe.tools']]) {
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);
const send = hr.stampedPage(path.join(__dirname, '..', '..', app, 'public', 'index.html'), host);
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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`);
@@ -83,6 +83,11 @@ server {
@@ -83,6 +83,11 @@ server {
proxy_pass http://127.0.0.1:4014;
proxy_pass http://127.0.0.1:4014;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -98,6 +103,11 @@ server {
@@ -98,6 +103,11 @@ server {
proxy_pass http://127.0.0.1:4014;
proxy_pass http://127.0.0.1:4014;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -63,6 +63,11 @@ server {
@@ -63,6 +63,11 @@ server {
proxy_pass http://127.0.0.1:4016;
proxy_pass http://127.0.0.1:4016;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -78,6 +83,11 @@ server {
@@ -78,6 +83,11 @@ server {
proxy_pass http://127.0.0.1:4016;
proxy_pass http://127.0.0.1:4016;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -45,6 +45,11 @@ server {
@@ -45,6 +45,11 @@ server {
proxy_pass http://127.0.0.1:4011;
proxy_pass http://127.0.0.1:4011;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -56,6 +61,11 @@ server {
@@ -56,6 +61,11 @@ server {
proxy_pass http://127.0.0.1:4011;
proxy_pass http://127.0.0.1:4011;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -8,6 +8,13 @@
@@ -8,6 +8,13 @@
const $ = s => document.querySelector(s);
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = {
const state = {
location: null, // {lat, lon, name}
location: null, // {lat, lon, name}
foods: [],
foods: [],
@@ -52,20 +59,22 @@
@@ -52,20 +59,22 @@
const data = await res.json();
const data = await res.json();
const banks = data?.locations || data || [];
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; }
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">
<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>
</div>
- ${b.description ? `<div class="card-desc">${b.description}</div>` : ''}
+ ${b.description ? `<div class="card-desc">${esc(b.description)}</div>` : ''}
<div class="card-actions">
<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>
- </div>`).join('');
+ </div>`; }).join('');
} catch (e) { c.innerHTML = '<div class="empty-state"><p>Failed to load food banks.</p></div>'; }
} catch (e) { c.innerHTML = '<div class="empty-state"><p>Failed to load food banks.</p></div>'; }
}
}
@@ -85,14 +94,14 @@
@@ -85,14 +94,14 @@
const key = (s.chain || s.name || '').toLowerCase().replace(/\s+/g, '');
const key = (s.chain || s.name || '').toLowerCase().replace(/\s+/g, '');
const color = storeColors[key] || 'var(--text-muted)';
const color = storeColors[key] || 'var(--text-muted)';
return `<div class="card">
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">
<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>
<div class="card-actions">
<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>
</div>`;
</div>`;
}).join('');
}).join('');
@@ -110,7 +119,7 @@
@@ -110,7 +119,7 @@
try {
try {
const res = await fetch(`${API}/api/meal-plan?budget=${budget}&days=${days}&campFriendly=${camp}&shelfStable=${shelf}&randomize=true`);
const res = await fetch(`${API}/api/meal-plan?budget=${budget}&days=${days}&campFriendly=${camp}&shelfStable=${shelf}&randomize=true`);
const plan = await res.json();
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 = '';
let html = '';
if (plan.plan) {
if (plan.plan) {
plan.plan.forEach((day, i) => {
plan.plan.forEach((day, i) => {
@@ -122,8 +131,8 @@
@@ -122,8 +131,8 @@
const price = item?.price ? `$${item.price.toFixed(2)}` : '';
const price = item?.price ? `$${item.price.toFixed(2)}` : '';
const cal = item?.calories ? `${item.calories} cal` : '';
const cal = item?.calories ? `${item.calories} cal` : '';
html += `<div class="meal-row">
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>`;
</div>`;
});
});
}
}
@@ -182,11 +191,11 @@
@@ -182,11 +191,11 @@
const bestPrice = validPrices.length ? Math.min(...validPrices) : null;
const bestPrice = validPrices.length ? Math.min(...validPrices) : null;
const campStars = (f.campFriendly || 0);
const campStars = (f.campFriendly || 0);
html += `<tr>
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 => {
${stores.map(s => {
const p = prices[s];
const p = prices[s];
if (p == null) return '<td class="price-cell price-na">—</td>';
if (p == null) return '<td class="price-cell price-na">—</td>';
@@ -212,7 +221,7 @@
@@ -212,7 +221,7 @@
if (state.location) {
if (state.location) {
state.foodMap.setView([state.location.lat, state.location.lon], 12);
state.foodMap.setView([state.location.lat, state.location.lon], 12);
// Add marker
// 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);
setTimeout(() => state.foodMap.invalidateSize(), 200);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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('<img src=x onerror=alert(1)>'), '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('<script>alert(3)</script>'), '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); });
@@ -6,7 +6,9 @@ const isProduction = (process.env.NODE_ENV || 'development') === 'production';
@@ -6,7 +6,9 @@ const isProduction = (process.env.NODE_ENV || 'development') === 'production';
module.exports = {
module.exports = {
port: parseInt(process.env.PORT, 10) || 4001,
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',
nodeEnv: process.env.NODE_ENV || 'development',
isProduction,
isProduction,
@@ -20,9 +20,15 @@ const guardLimits = require('../../../_shared/guard/limits');
@@ -20,9 +20,15 @@ const guardLimits = require('../../../_shared/guard/limits');
const { readHtml, decodeBody } = require('./reader');
const { readHtml, decodeBody } = require('./reader');
// ── In-memory webhook bin storage ────────────────────────────
// ── 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_BIN_TTL = 60 * 60 * 1000; // 1 hour
const WEBHOOK_MAX_REQUESTS = 200;
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() {
function cleanupBins() {
const now = Date.now();
const now = Date.now();
@@ -91,7 +97,7 @@ function createWebhookRouter(opts = {}) {
@@ -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;
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');
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({
res.json({
ok: true,
ok: true,
@@ -121,10 +127,6 @@ function createWebhookRouter(opts = {}) {
@@ -121,10 +127,6 @@ function createWebhookRouter(opts = {}) {
const bin = webhookBins.get(req.params.binId);
const bin = webhookBins.get(req.params.binId);
if (!bin) return res.status(404).json({ error: 'Bin not found or expired' });
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 = {
const entry = {
id: crypto.randomBytes(6).toString('hex'),
id: crypto.randomBytes(6).toString('hex'),
timestamp: Date.now(),
timestamp: Date.now(),
@@ -142,7 +144,22 @@ function createWebhookRouter(opts = {}) {
@@ -142,7 +144,22 @@ function createWebhookRouter(opts = {}) {
delete entry.headers['cookie'];
delete entry.headers['cookie'];
delete entry.headers['authorization'];
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.requests.push(entry);
+ bin.bytes += entryBytes;
res.status(200).json({ ok: true, message: 'Received' });
res.status(200).json({ ok: true, message: 'Received' });
});
});
@@ -245,7 +262,8 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
@@ -245,7 +262,8 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
} catch (err) {
} catch (err) {
if (err instanceof TargetRefused) return res.status(403).json({ error: err.message, code: err.code });
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 });
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 = {}) {
@@ -317,7 +335,8 @@ module.exports = function createDevRoutes(db, requireAuth, opts = {}) {
} catch (err) {
} catch (err) {
if (err instanceof TargetRefused) return res.status(403).json({ error: err.message, code: err.code });
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 });
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.' });
}
}
});
});
@@ -47,6 +47,7 @@ async function serve(app) {
@@ -47,6 +47,7 @@ async function serve(app) {
const app = express();
const app = express();
app.set('trust proxy', TRUST_PROXY);
app.set('trust proxy', TRUST_PROXY);
app.use(cookieParser());
app.use(cookieParser());
+ app.use(express.json({ limit: '1mb' })); // the gateway's own body parser (webhook bodies)
app.use(guard.identify);
app.use(guard.identify);
app.use('/api/net', createNetRoutes(null, null, { egress, guard }));
app.use('/api/net', createNetRoutes(null, null, { egress, guard }));
app.use('/api/dev', createDevRoutes(null, null, { egress, guard }));
app.use('/api/dev', createDevRoutes(null, null, { egress, guard }));
@@ -114,6 +115,22 @@ async function serve(app) {
@@ -114,6 +115,22 @@ async function serve(app) {
r = await fetch(`${s.base}/api/dev/webhook/bins/${made.binId}`, { method: 'DELETE', headers: { cookie } });
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');
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).
// 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 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();
const app2 = express();
@@ -65,6 +65,11 @@ server {
@@ -65,6 +65,11 @@ server {
proxy_pass http://127.0.0.1:4012;
proxy_pass http://127.0.0.1:4012;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -80,6 +85,11 @@ server {
@@ -80,6 +85,11 @@ server {
proxy_pass http://127.0.0.1:4012;
proxy_pass http://127.0.0.1:4012;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -45,6 +45,11 @@ server {
@@ -45,6 +45,11 @@ server {
proxy_pass http://127.0.0.1:4010;
proxy_pass http://127.0.0.1:4010;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -61,6 +66,11 @@ server {
@@ -61,6 +66,11 @@ server {
proxy_pass http://127.0.0.1:4010;
proxy_pass http://127.0.0.1:4010;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -73,6 +83,11 @@ server {
@@ -73,6 +83,11 @@ server {
proxy_pass http://127.0.0.1:4010;
proxy_pass http://127.0.0.1:4010;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -29,6 +29,15 @@
@@ -29,6 +29,15 @@
const show = el => el && el.classList.remove('hidden');
const show = el => el && el.classList.remove('hidden');
const hide = el => el && el.classList.add('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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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') {
function toast(msg, type = 'info') {
const bg = { info: '#22c55e', success: '#059669', error: '#ef4444', warn: '#f59e0b', warning: '#f59e0b' };
const bg = { info: '#22c55e', success: '#059669', error: '#ef4444', warn: '#f59e0b', warning: '#f59e0b' };
Toastify({ text: msg, duration: 3500, gravity: 'bottom', position: 'right',
Toastify({ text: msg, duration: 3500, gravity: 'bottom', position: 'right',
@@ -230,9 +239,9 @@
@@ -230,9 +239,9 @@
<span class="sp-count" id="sp-count">0 / ${sources.length}</span>
<span class="sp-count" id="sp-count">0 / ${sources.length}</span>
</div>
</div>
<div class="sp-grid">${sources.map(s =>
<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>
<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>
<span class="sp-result"></span>
</div>`
</div>`
).join('')}</div>
).join('')}</div>
@@ -291,7 +300,7 @@
@@ -291,7 +300,7 @@
const btn = $('#btn-search');
const btn = $('#btn-search');
btn.classList.add('loading');
btn.classList.add('loading');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i><span>Searching...</span>';
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'));
hide($('#welcome-card'));
$('#results-list').innerHTML = '<div class="search-progress"><div class="spinner"></div><p>Geocoding location...</p></div>';
$('#results-list').innerHTML = '<div class="search-progress"><div class="spinner"></div><p>Geocoding location...</p></div>';
@@ -316,7 +325,7 @@
@@ -316,7 +325,7 @@
if (state.userMarker) state.map.removeLayer(state.userMarker);
if (state.userMarker) state.map.removeLayer(state.userMarker);
state.userMarker = L.marker([lat, lon], {
state.userMarker = L.marker([lat, lon], {
icon: L.divIcon({ className: 'user-marker', iconSize: [20, 20], iconAnchor: [10, 10] }), zIndexOffset: 1000
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 });
state.map.setView([lat, lon], 11, { animate: false });
// Placeholder for source progress — will be replaced once sources manifest arrives
// Placeholder for source progress — will be replaced once sources manifest arrives
@@ -407,13 +416,13 @@
@@ -407,13 +416,13 @@
fetchWeather(lat, lon);
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}`);
toast(`Found ${state.locations.length} locations near ${query}`);
} catch (err) {
} 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');
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 {
} finally {
btn.classList.remove('loading');
btn.classList.remove('loading');
btn.innerHTML = '<i class="fa-solid fa-person-shelter"></i><span>Search</span>';
btn.innerHTML = '<i class="fa-solid fa-person-shelter"></i><span>Search</span>';
@@ -447,25 +456,25 @@
@@ -447,25 +456,25 @@
const visible = state.filtered.slice(0, limit);
const visible = state.filtered.slice(0, limit);
c.innerHTML = visible.map((loc,i) => {
c.innerHTML = visible.map((loc,i) => {
const tc = loc._typeClass, isFav = state.favorites.includes(loc._id);
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-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 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">
<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>
</div>
<div style="text-align:right;flex-shrink:0">
<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 class="stealth-rating">${stealthStars(loc.stealthRating)}</div>
</div>
</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>`;
</div>`;
}).join('') + (state.filtered.length > limit ? `<div class="muted-text">${state.filtered.length-limit} more results hidden. Zoom in or filter.</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 @@
@@ -486,8 +495,8 @@
})
})
});
});
marker._typeClass = tc;
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));
marker.on('click', () => selectLocation(loc));
state.markerCache.set(loc._id, marker);
state.markerCache.set(loc._id, marker);
}
}
@@ -519,7 +528,7 @@
@@ -519,7 +528,7 @@
function selectLocation(loc) {
function selectLocation(loc) {
const panel = $('#detail-panel'), isFav = state.favorites.includes(loc._id);
const panel = $('#detail-panel'), isFav = state.favorites.includes(loc._id);
$('#detail-name').textContent = loc.name||'Unknown';
$('#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');
const favBtn = $('#btn-favorite-detail');
favBtn.className = `detail-fav-btn ${isFav?'favorited':''}`;
favBtn.className = `detail-fav-btn ${isFav?'favorited':''}`;
favBtn.innerHTML = `<i class="fa-${isFav?'solid':'regular'} fa-heart"></i>`;
favBtn.innerHTML = `<i class="fa-${isFav?'solid':'regular'} fa-heart"></i>`;
@@ -532,24 +541,24 @@
@@ -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 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>
<div class="detail-section"><h3><i class="fa-solid fa-circle-info"></i> Information</h3>
<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>
</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-section"><h3><i class="fa-solid fa-link"></i> Actions</h3>
<div class="detail-actions">
<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>
<button class="detail-btn" id="btn-copy-coords"><i class="fa-solid fa-copy"></i> Copy Coords</button>
</div>
</div>
</div>
</div>
<div class="detail-section"><h3><i class="fa-solid fa-note-sticky"></i> Notes</h3>
<div class="detail-section"><h3><i class="fa-solid fa-note-sticky"></i> Notes</h3>
<div class="detail-note-area">
<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>
<button class="detail-btn primary" id="btn-save-note"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>
</div>`;
</div>`;
@@ -566,8 +575,7 @@
@@ -566,8 +575,7 @@
});
});
// Highlight card
// 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});
state.map.flyTo([loc.lat,loc.lon], 14, {animate:true});
// Terrain
// Terrain
@@ -612,7 +620,7 @@
@@ -612,7 +620,7 @@
if (w.current) {
if (w.current) {
html += `<h3><i class="fa-solid fa-temperature-half"></i> Current Conditions</h3>`;
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.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.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>`;
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 @@
@@ -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.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.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>';
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;
c.innerHTML = html;
show($('#weather-modal'));
show($('#weather-modal'));
}
}
@@ -643,9 +651,9 @@
@@ -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; }
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 => {
c.innerHTML = favLocs.map(loc => {
const tc = loc._typeClass;
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>`;
</div>`;
}).join('');
}).join('');
c.querySelectorAll('[data-fav-id]').forEach(el => el.addEventListener('click', () => {
c.querySelectorAll('[data-fav-id]').forEach(el => el.addEventListener('click', () => {
@@ -690,13 +698,13 @@
@@ -690,13 +698,13 @@
if (!data?.length && !data?.locations?.length) { c.innerHTML = '<p class="muted-text">No food banks found nearby.</p>'; return; }
if (!data?.length && !data?.locations?.length) { c.innerHTML = '<p class="muted-text">No food banks found nearby.</p>'; return; }
const banks = data.locations || data;
const banks = data.locations || data;
c.innerHTML = banks.map(b => `<div class="food-card">
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">
<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>
</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('');
</div>`).join('');
} catch(e) { c.innerHTML = '<p class="muted-text">Failed to load food banks.</p>'; }
} catch(e) { c.innerHTML = '<p class="muted-text">Failed to load food banks.</p>'; }
}
}
@@ -712,10 +720,10 @@
@@ -712,10 +720,10 @@
if (!data?.length && !data?.stores?.length) { c.innerHTML = '<p class="muted-text">No stores found nearby.</p>'; return; }
if (!data?.length && !data?.stores?.length) { c.innerHTML = '<p class="muted-text">No stores found nearby.</p>'; return; }
const stores = data.stores || data;
const stores = data.stores || data;
c.innerHTML = stores.map(s => `<div class="food-card">
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">
<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>
</div>`).join('');
</div>`).join('');
} catch(e) { c.innerHTML = '<p class="muted-text">Failed to load stores.</p>'; }
} catch(e) { c.innerHTML = '<p class="muted-text">Failed to load stores.</p>'; }
@@ -741,7 +749,7 @@
@@ -741,7 +749,7 @@
try {
try {
const res = await fetch(`${API}/api/meal-plan?budget=${budget}&days=${days}&campFriendly=${camp}&randomize=true`);
const res = await fetch(`${API}/api/meal-plan?budget=${budget}&days=${days}&campFriendly=${camp}&randomize=true`);
const plan = await res.json();
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 = '';
let html = '';
if (plan.plan) {
if (plan.plan) {
plan.plan.forEach((day, i) => {
plan.plan.forEach((day, i) => {
@@ -750,7 +758,7 @@
@@ -750,7 +758,7 @@
if (day[meal]) {
if (day[meal]) {
const items = Array.isArray(day[meal]) ? day[meal] : [day[meal]];
const items = Array.isArray(day[meal]) ? day[meal] : [day[meal]];
items.forEach(item => {
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 @@
@@ -781,9 +789,9 @@
function renderFoodList(foods) {
function renderFoodList(foods) {
return foods.map(f => `<div class="food-card">
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">
<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.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.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>':''}
${f.campFriendly?'<span><i class="fa-solid fa-campground"></i> Camp-friendly</span>':''}
@@ -3,7 +3,9 @@ require('dotenv').config();
@@ -3,7 +3,9 @@ require('dotenv').config();
module.exports = {
module.exports = {
port: parseInt(process.env.PORT, 10) || 4010,
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',
baseUrl: process.env.BASE_URL || 'https://maps.openvibe.tools',
// API Keys (optional — free tiers work without most of these)
// API Keys (optional — free tiers work without most of these)
@@ -69,8 +69,8 @@ const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
@@ -69,8 +69,8 @@ const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
// ── Middleware ──────────────────────────────────────────────
// ── Middleware ──────────────────────────────────────────────
// X-Forwarded-For is believed from exactly one hop on loopback: the host's nginx, the gateway or the
// 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.set('trust proxy', TRUST_PROXY);
app.use(helmet({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
@@ -373,11 +373,13 @@ app.get('/api/search/stream', async (req, res) => {
@@ -373,11 +373,13 @@ app.get('/api/search/stream', async (req, res) => {
}
}
} catch (err) {
} catch (err) {
completed++;
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) {
if (!clientClosed) {
send('source', {
send('source', {
name: s.name, icon: s.icon, status: 'error',
name: s.name, icon: s.icon, status: 'error',
- error: err.message, count: 0,
+ error: 'source unavailable', count: 0,
locations: [], bridges: [], crimeHeatmap: [],
locations: [], bridges: [], crimeHeatmap: [],
completed, total: sources.length,
completed, total: sources.length,
elapsed: Date.now() - t0,
elapsed: Date.now() - t0,
@@ -418,7 +420,7 @@ app.get('/api/weather', async (req, res) => {
@@ -418,7 +420,7 @@ app.get('/api/weather', async (req, res) => {
const data = await weather.getWeather(lat, lon);
const data = await weather.getWeather(lat, lon);
cache.set(ck, data, config.cache.weather);
cache.set(ck, data, config.cache.weather);
res.json(data);
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) => {
app.get('/api/terrain', async (req, res) => {
@@ -428,7 +430,7 @@ app.get('/api/terrain', async (req, res) => {
@@ -428,7 +430,7 @@ app.get('/api/terrain', async (req, res) => {
try {
try {
const data = await terrain.getTerrainInfo(lat, lon);
const data = await terrain.getTerrainInfo(lat, lon);
res.json(data);
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) => {
app.get('/api/food-banks', async (req, res) => {
@@ -439,7 +441,7 @@ app.get('/api/food-banks', async (req, res) => {
@@ -439,7 +441,7 @@ app.get('/api/food-banks', async (req, res) => {
try {
try {
const data = await grocery.findFoodBanks(lat, lon, radius);
const data = await grocery.findFoodBanks(lat, lon, radius);
res.json(data);
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) => {
app.get('/api/stores', async (req, res) => {
@@ -449,7 +451,7 @@ app.get('/api/stores', async (req, res) => {
@@ -449,7 +451,7 @@ app.get('/api/stores', async (req, res) => {
try {
try {
const data = await grocery.findNearbyStores(lat, lon);
const data = await grocery.findNearbyStores(lat, lon);
res.json(data);
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) => {
app.get('/api/foods', (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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)>'), '<img src=x onerror=alert(1)>', 'markup is escaped');
+assert.strictEqual(esc('a"b\'c&d'), 'a"b'c&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');
@@ -98,6 +98,11 @@ server {
@@ -98,6 +98,11 @@ server {
proxy_pass http://127.0.0.1:4015;
proxy_pass http://127.0.0.1:4015;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -3,7 +3,9 @@ require('dotenv').config();
@@ -3,7 +3,9 @@ require('dotenv').config();
module.exports = {
module.exports = {
port: parseInt(process.env.PORT, 10) || 4015,
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',
baseUrl: process.env.BASE_URL || 'https://text.openvibe.tools',
openvibeToolsUrl: process.env.OV_NETWORK_URL || 'https://openvibe.network',
openvibeToolsUrl: process.env.OV_NETWORK_URL || 'https://openvibe.network',
};
};
@@ -35,6 +35,11 @@ server {
@@ -35,6 +35,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -51,6 +56,11 @@ server {
@@ -51,6 +56,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -67,6 +77,11 @@ server {
@@ -67,6 +77,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -79,6 +94,11 @@ server {
@@ -79,6 +94,11 @@ server {
proxy_pass http://127.0.0.1:4013;
proxy_pass http://127.0.0.1:4013;
proxy_http_version 1.1;
proxy_http_version 1.1;
proxy_set_header Host $host;
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-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
@@ -181,9 +181,10 @@
@@ -181,9 +181,10 @@
// --- Render Video Card ---
// --- Render Video Card ---
function renderVideoCard(info) {
function renderVideoCard(info) {
+ // The thumbnail URL comes from YouTube: only http(s) may go into src.
videoThumb.innerHTML = `
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 = [];
const meta = [];
@@ -209,6 +210,14 @@
@@ -209,6 +210,14 @@
return d.innerHTML;
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 ---
// --- Upstream status ---
// The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a
// 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).
// link, with the network's shared notice (OpenVibeUI.notice: one design, always under the navbar).
@@ -256,7 +256,21 @@ function friendlyDownloadError(line) {
@@ -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 (/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 (/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]})`;
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 ───────────────────────────────────────────────
// ── File names ───────────────────────────────────────────────
@@ -721,6 +735,6 @@ function noteUpstream(state) { if (state === 'ok' || state === 'blocked') upstre
@@ -721,6 +735,6 @@ function noteUpstream(state) { if (state === 'ok' || state === 'blocked') upstre
module.exports = {
module.exports = {
getInfo, getInfoLimited, cachedInfo, videoId, limitReason, infoStats, startDownload, cancelDownload, getStatus, getFile, removeFile,
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,
cleanup, startCleanup, stopCleanup, getStats, isValidUrl, probeUpstream, startUpstreamProbe, getUpstream,
};
};
@@ -150,11 +150,14 @@ app.get('/api/health', (_req, res) => {
@@ -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 });
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;
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
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 (!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 {
try {
// At most config.info.maxConcurrent yt-dlp lookups at once; the same video is looked up once
// 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) => {
@@ -163,8 +166,9 @@ app.post('/api/info', async (req, res) => {
res.json({ success: true, video: info });
res.json({ success: true, video: info });
} catch (err) {
} catch (err) {
if (err.status === 503) return res.status(503).set('Retry-After', '5').json({ error: err.message });
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);
console.error('[Info] Error:', err.message);
- res.status(422).json({ error: err.message });
+ res.status(422).json({ error: downloader.friendlyInfoError(err.message) });
}
}
});
});
@@ -62,7 +62,9 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
@@ -62,7 +62,9 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const args = process.argv.slice(2);
const args = process.argv.slice(2);
const url = args[args.length - 1];
const url = args[args.length - 1];
const long = url.includes('LONGLONG123');
const long = url.includes('LONGLONG123');
+const errpath = url.includes('ERRPATH');
if (args.includes('--dump-json')) {
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.stdout.write(JSON.stringify({ id: long ? 'LONGLONG123' : 'SHORTxx1234', title: 't', duration: long ? 4000 : 30, formats: [{ vcodec: 'avc1', acodec: 'mp4a' }] }));
process.exit(0);
process.exit(0);
}
}
@@ -80,6 +82,22 @@ process.exit(0); // the match filter skipped it: no file
@@ -80,6 +82,22 @@ process.exit(0); // the match filter skipped it: no file
assert.strictEqual(body.video.downloadable, false);
assert.strictEqual(body.video.downloadable, false);
assert.match(body.video.reason, /67 minutes long; downloads are limited to 10 minutes/);
assert.match(body.video.reason, /67 minutes long; downloads are limited to 10 minutes/);
assert.deepStrictEqual(body.video.limits, { maxDuration: 600, maxFilesizeMB: 100 });
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' });
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.strictEqual(r.status, 422, 'refused from the cached info, before yt-dlp starts');
assert.match((await r.json()).error, /limited to 10 minutes/);
assert.match((await r.json()).error, /limited to 10 minutes/);