openvibers/openvibe.tours Commits

Pictures only from hosts the page allows (no more Mapillary refusals) (#56)

OP OpenVibers committed 10 days ago · 54067af367 · 2 files, +22 −8

The place of the day asked for images.mapillary.com (an OSM image tag), which img-src refuses and which no longer
answers. safeImage takes Commons and OpenVibe image hosts only, and the test checks each is in img-src.

Changes

ddiff --git a/server/views/index.js b/server/views/index.js
iindex 4dee337..b1462fd 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -22,14 +22,18 @@ const day = (iso) => (iso ? esc(String(iso).slice(0, 10)) : '—');
const when = (iso) => (iso ? esc(String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')) : '—');
const km = (m) => (Number(m) >= 1000 ? `${(Number(m) / 1000).toFixed(1)} km` : `${Math.round(Number(m) || 0)} m`);
const safeHttp = (v) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : null);
--// A picture is drawn only from a URL that is one: Commons, or a path ending in an image type. A Wikivoyage listing's
--// image field sometimes holds a share link (photos.app.goo.gl), which drew a broken image on the home (2026-11-01).
++// A picture is drawn only from a URL that is one, on a host the page's img-src allows (server/app.js): Commons, or an
++// OpenVibe site's image. A Wikivoyage listing's image field sometimes holds a share link (photos.app.goo.gl), which drew
++// a broken image on the home (2026-11-01); an OSM image tag can name any host, and the place of the day asked for
++// images.mapillary.com, which the policy refuses and which no longer answers (2026-11-02).
++const IMAGE_HOSTS = /^(upload|commons|thumb)\.wikimedia\.org$|^openvibe\.(media|rent|live|network)$/i;
const safeImage = (v) => {
const u = safeHttp(v);
if (!u) return null;
try {
-- const { hostname, pathname } = new URL(u);
-- return /(^|\.)wikimedia\.org$/i.test(hostname) || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;
++ const { protocol, hostname, pathname } = new URL(u);
++ if (protocol !== 'https:' || !IMAGE_HOSTS.test(hostname)) return null;
++ return /wikimedia\.org$/i.test(hostname) || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;
} catch { return null; }
};
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
ddiff --git a/test/safe-image.test.js b/test/safe-image.test.js
iindex 6ca99ca..014cb73 100644
---- a/test/safe-image.test.js
++++ b/test/safe-image.test.js
@@@ -8,15 +8,25 @@ const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'views', 'index.js'), 'utf8');
const http = /const safeHttp = [^\n]+\n/.exec(src);
++const hosts = /const IMAGE_HOSTS = [^\n]+\n/.exec(src);
const img = /const safeImage = \(v\) => \{[\s\S]*?\n\};\n/.exec(src);
--assert.ok(http && img, 'views has safeHttp and safeImage');
--const safeImage = new Function(`${http[0]}${img[0]}; return safeImage;`)();
++assert.ok(http && hosts && img, 'views has safeHttp, IMAGE_HOSTS and safeImage');
++const safeImage = new Function(`${http[0]}${hosts[0]}${img[0]}; return safeImage;`)();
assert.strictEqual(safeImage('https://photos.app.goo.gl/A7PbTNW1RbBbZUrTA'), null, 'a share link is not a picture');
assert.ok(safeImage('https://upload.wikimedia.org/wikipedia/commons/a/ab/Hachiko.jpg'), 'Commons uploads');
assert.ok(safeImage('https://commons.wikimedia.org/wiki/Special:FilePath/Hachiko.jpg?width=640'), 'Commons file paths');
--assert.ok(safeImage('https://example.com/photos/tower.webp'), 'a path ending in an image type');
--assert.strictEqual(safeImage('https://example.com/gallery'), null, 'a page is not a picture');
++assert.ok(safeImage('https://openvibe.media/files/tower.webp'), 'an OpenVibe image');
++assert.strictEqual(safeImage('https://openvibe.media/gallery'), null, 'a page is not a picture');
++// Only hosts the page's img-src allows: anything else would be refused and draw a broken image (2026-11-02).
++assert.strictEqual(safeImage('https://images.mapillary.com/6Vri6u81GdjqVKBQTbQYBg/thumb-2048.jpg'), null, 'Mapillary is not allowed');
++assert.strictEqual(safeImage('https://example.com/photos/tower.webp'), null, 'an image on another host');
++assert.strictEqual(safeImage('http://upload.wikimedia.org/a.jpg'), null, 'https only');
++const app = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
++const imgSrc = /imgSrc: \[([^\]]+)\]/.exec(app)[1];
++for (const h of ['upload.wikimedia.org', 'commons.wikimedia.org', 'thumb.wikimedia.org', 'openvibe.media', 'openvibe.rent', 'openvibe.live', 'openvibe.network']) {
++ assert.ok(imgSrc.includes(`'https://${h}'`), `${h} is in img-src as well`);
++}
assert.strictEqual(safeImage('javascript:alert(1)'), null);
assert.strictEqual(safeImage(null), null);
assert.doesNotMatch(src, /safeHttp\((c|p|city|place)\.image_url\)/, 'every picture goes through safeImage');