openvibers/openvibe.tours Commits
Pictures only from hosts the page allows (no more Mapillary refusals) (#56)
Changes
@@@ -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}`;@@@ -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');
@@ -22,14 +22,18 @@ const day = (iso) => (iso ? esc(String(iso).slice(0, 10)) : '—');
@@ -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 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 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);
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 safeImage = (v) => {
const u = safeHttp(v);
const u = safeHttp(v);
if (!u) return null;
if (!u) return null;
try {
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; }
} catch { return null; }
};
};
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
@@ -8,15 +8,25 @@ const path = require('path');
@@ -8,15 +8,25 @@ const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'views', 'index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'views', 'index.js'), 'utf8');
const http = /const safeHttp = [^\n]+\n/.exec(src);
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);
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.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://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://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('javascript:alert(1)'), null);
assert.strictEqual(safeImage(null), null);
assert.strictEqual(safeImage(null), null);
assert.doesNotMatch(src, /safeHttp\((c|p|city|place)\.image_url\)/, 'every picture goes through safeImage');
assert.doesNotMatch(src, /safeHttp\((c|p|city|place)\.image_url\)/, 'every picture goes through safeImage');