openvibers/openvibe.media Commits

Avatar ingestion: fetch a user-supplied link safely (public addresses only, pinned connection, size and redirect caps), re-encode with sharp, store as an unlisted avatar paste; internal-only route

OP OpenVibers committed 18 Sept 2026 · 145b45afe5 · 4 files, +182 −0


Changes

ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
nnew file mode 100644
iindex 0000000..649fcba
---- /dev/null
++++ b/server/avatars/ingest.js
@@@ -0,0 +1,131 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Avatar ingestion — every avatar on the network is a file HERE.
++//
++// POST /internal/avatar-ingest { url, user_id, username } (internal key + loopback only)
++// → { ok, slug, url: 'https://openvibe.media/p/<slug>/screenshot', width, height, bytes }
++//
++// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.
++// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:
++// • https only, no credentials in the URL, at most 3 redirects, each hop checked again
++// • the hostname is resolved first and refused if ANY address is private, loopback, link-local, CGNAT,
++// multicast or otherwise not public; the connection is then pinned to the address we checked, so a
++// DNS answer that changes between check and connect (rebinding) cannot reach an internal service
++// • 8 MB cap while streaming, 10 s overall deadline
++// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image
++// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs
++// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same
++// system as every other picture (deletable, reportable, served with range/caching by the paste routes).
++// ═══════════════════════════════════════════════════════════════
++const dns = require('dns').promises;
++const https = require('https');
++const net = require('net');
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const sharp = require('sharp');
++
++const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;
++
++function isPublicAddress(ip) {
++ if (net.isIPv4(ip)) {
++ const [a, b] = ip.split('.').map(Number);
++ if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
++ if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
++ if (a === 169 && b === 254) return false; // link-local / cloud metadata
++ if (a === 172 && b >= 16 && b <= 31) return false;
++ if (a === 192 && (b === 168 || b === 0)) return false;
++ if (a === 198 && (b === 18 || b === 19)) return false;
++ return true;
++ }
++ if (net.isIPv6(ip)) {
++ const x = ip.toLowerCase();
++ if (x === '::' || x === '::1') return false;
++ if (x.startsWith('::ffff:')) return isPublicAddress(x.slice(7));
++ if (/^f[cd]/.test(x) || /^fe[89ab]/.test(x) || x.startsWith('ff')) return false; // unique-local, link-local, multicast
++ if (x.startsWith('64:ff9b:') || x.startsWith('2001:db8:')) return false;
++ return true;
++ }
++ return false;
++}
++
++async function resolvePublic(hostname) {
++ if (net.isIP(hostname)) { if (!isPublicAddress(hostname)) throw new Error('That address is not on the public internet'); return { address: hostname, family: net.isIPv6(hostname) ? 6 : 4 }; }
++ const all = await dns.lookup(hostname, { all: true, verbatim: true });
++ if (!all.length) throw new Error('That host could not be found');
++ if (!all.every(a => isPublicAddress(a.address))) throw new Error('That address is not on the public internet');
++ return all.find(a => a.family === 4) || all[0];
++}
++
++function fetchOnce(u, pinned, deadline) {
++ return new Promise((resolve, reject) => {
++ const req = https.get({
++ host: u.hostname, servername: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
++ headers: { 'User-Agent': 'OpenVibeMedia/1.0 (+https://openvibe.media; avatar fetch)', Accept: 'image/*' },
++ lookup: (_h, _o, cb) => cb(null, pinned.address, pinned.family), // connect to the address we checked, nothing else
++ timeout: Math.max(1000, deadline - Date.now()),
++ }, (res) => {
++ const status = res.statusCode || 0;
++ if (status >= 300 && status < 400 && res.headers.location) { res.resume(); return resolve({ redirect: res.headers.location }); }
++ if (status !== 200) { res.resume(); return reject(new Error(`The picture could not be loaded (HTTP ${status})`)); }
++ if (!/^image\//i.test(String(res.headers['content-type'] || ''))) { res.resume(); return reject(new Error('That link is not a picture')); }
++ if (Number(res.headers['content-length']) > MAX_BYTES) { res.destroy(); return reject(new Error('That picture is too large (8 MB at most)')); }
++ const chunks = []; let n = 0;
++ res.on('data', (c) => { n += c.length; if (n > MAX_BYTES) { res.destroy(); reject(new Error('That picture is too large (8 MB at most)')); } else chunks.push(c); });
++ res.on('end', () => resolve({ body: Buffer.concat(chunks) }));
++ res.on('error', reject);
++ });
++ req.on('timeout', () => req.destroy(new Error('The picture took too long to load')));
++ req.on('error', reject);
++ });
++}
++
++async function safeFetchImage(rawUrl, { resolver = resolvePublic, fetcher = fetchOnce } = {}) {
++ const deadline = Date.now() + DEADLINE_MS;
++ let url = String(rawUrl || '');
++ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
++ let u; try { u = new URL(url); } catch { throw new Error('That is not a web address'); }
++ if (u.protocol !== 'https:') throw new Error('Use an https address');
++ if (u.username || u.password) throw new Error('Addresses with a username or password are not accepted');
++ if (u.port && u.port !== '443') throw new Error('Only the standard https port is accepted');
++ const pinned = await resolver(u.hostname);
++ const r = await fetcher(u, pinned, deadline);
++ if (r.body) return r.body;
++ url = new URL(r.redirect, u).toString();
++ }
++ throw new Error('Too many redirects');
++}
++
++/** Bytes → a clean square WebP. Throws when the bytes are not a decodable picture. */
++async function toAvatar(buf) {
++ const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
++ const meta = await img.metadata();
++ if (!meta.width || !meta.height || meta.width < 16 || meta.height < 16) throw new Error('That picture is too small');
++ const out = await img.rotate().resize(SIZE, SIZE, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();
++ return { buffer: out, width: SIZE, height: SIZE };
++}
++
++function createIngestHandler({ db, config, screenshotsDir, generateSlug, log = console }) {
++ return async function avatarIngest(req, res) {
++ try {
++ const { url, user_id, username } = req.body || {};
++ if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });
++ const raw = await safeFetchImage(url);
++ const pic = await toAvatar(raw);
++ fs.mkdirSync(screenshotsDir, { recursive: true });
++ const file = path.join(screenshotsDir, `avatar-n${parseInt(user_id, 10) || 0}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.webp`);
++ fs.writeFileSync(file, pic.buffer);
++ const slug = generateSlug();
++ db.run(`INSERT INTO pastes (app_id, slug, user_id, type, title, content, language, visibility, screenshot_path, metadata, ip_address)
++ VALUES ('network', ?, ?, 'screenshot', ?, '', 'text', 'unlisted', ?, ?, NULL)`,
++ [slug, parseInt(user_id, 10) || null, `Avatar${username ? ' of ' + String(username).slice(0, 40) : ''}`, file,
++ JSON.stringify({ kind: 'avatar', source_host: (() => { try { return new URL(url).hostname; } catch { return null; } })(), size_bytes: pic.buffer.length, mime_type: 'image/webp' })]);
++ res.json({ ok: true, slug, url: `${config.publicUrl}/p/${slug}/screenshot`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
++ } catch (err) {
++ log.warn('[Avatar ingest]', err.message);
++ res.status(422).json({ ok: false, error: /unsupported image|Input buffer|corrupt|VipsJpeg|bad seek/i.test(err.message) ? 'That file is not a picture we can read' : err.message });
++ }
++ };
++}
++
++module.exports = { createIngestHandler, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };
ddiff --git a/server/index.js b/server/index.js
iindex 9a51a44..2306a72 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -42,6 +42,19 @@ app.use((req, _res, next) => {
// Brand assets: icon set + web app manifest generated by openvibe-shared/scripts/build-app-icons.js
app.use('/assets', express.static(require('path').join(__dirname, 'brand'), { maxAge: '7d' }));
app.get('/manifest.webmanifest', (_req, res) => res.type('application/manifest+json').sendFile(require('path').join(__dirname, 'brand', 'manifest.webmanifest')));
++// ── Internal: avatar ingestion (the Network asks; see server/avatars/ingest.js) ──
++{
++ const crypto = require('crypto');
++ const internalOnly = (req, res, next) => {
++ const want = String(config.network.internalApiKey || ''), got = String(req.headers['x-internal-key'] || '');
++ const viaProxy = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip'];
++ const local = /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remoteAddress || ''));
++ if (viaProxy || !local || want.length < 16 || got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) return res.status(404).json({ error: 'Not found' });
++ next();
++ };
++ const pastes = require('./pastes/routes');
++ app.post('/internal/avatar-ingest', internalOnly, require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config, screenshotsDir: pastes.SCREENSHOTS_DIR, generateSlug: pastes.generateSlug }));
++}
const userAuth = require('./user-auth');
const userAuthConfig = {
baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
iindex 1dcf411..8e37a4f 100644
---- a/server/pastes/routes.js
++++ b/server/pastes/routes.js
@@@ -830,6 +830,8 @@ router.delete('/:slug/comments/:commentId', tenantAuth({ allowUser: true }), (re
module.exports = router;
module.exports.pastePublic = pastePublic;
++module.exports.generateSlug = generateSlug;
++module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
module.exports.screenshotUrl = screenshotUrl;
module.exports.removePasteScreenshot = removePasteScreenshot;
module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
ddiff --git a/test/avatar-ingest.test.js b/test/avatar-ingest.test.js
nnew file mode 100644
iindex 0000000..9a815b1
---- /dev/null
++++ b/test/avatar-ingest.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++const assert = require('assert');
++const sharp = require('sharp');
++const { isPublicAddress, safeFetchImage, toAvatar, resolvePublic } = require('../server/avatars/ingest');
++
++for (const ip of ['127.0.0.1', '10.1.2.3', '172.16.0.9', '172.31.255.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', 'fe80::1', 'fc00::1', 'fd12::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1', '198.18.0.1'])
++ assert.equal(isPublicAddress(ip), false, `private/reserved refused: ${ip}`);
++for (const ip of ['1.1.1.1', '8.8.8.8', '172.15.0.1', '172.32.0.1', '2606:4700:4700::1111', '::ffff:8.8.8.8'])
++ assert.equal(isPublicAddress(ip), true, `public accepted: ${ip}`);
++
++(async () => {
++ const rejects = async (p, re, msg) => { let e = null; try { await p; } catch (x) { e = x; } assert.ok(e && re.test(e.message), `${msg} (got: ${e && e.message})`); };
++ await rejects(resolvePublic('127.0.0.1'), /public internet/, 'a literal loopback address is refused');
++ await rejects(safeFetchImage('http://example.com/a.png'), /https/, 'plain http is refused');
++ await rejects(safeFetchImage('https://user:[email protected]/a.png'), /username or password/, 'credentials in the URL are refused');
++ await rejects(safeFetchImage('https://example.com:8443/a.png'), /standard https port/, 'odd ports are refused');
++ await rejects(safeFetchImage('file:///etc/passwd'), /https/, 'other schemes are refused');
++
++ // A public URL that redirects to an internal one: the second hop is resolved and refused.
++ const seen = [];
++ const resolver = async (h) => { seen.push(h); if (h === 'internal.example') throw new Error('That address is not on the public internet'); return { address: '93.184.216.34', family: 4 }; };
++ await rejects(safeFetchImage('https://good.example/a.png', { resolver, fetcher: async () => ({ redirect: 'https://internal.example/secret' }) }), /public internet/, 'a redirect to an internal host is refused');
++ assert.deepEqual(seen, ['good.example', 'internal.example'], 'every hop is resolved again');
++ await rejects(safeFetchImage('https://good.example/a.png', { resolver: async () => ({ address: '93.184.216.34', family: 4 }), fetcher: async (u) => ({ redirect: u.toString() + 'x' }) }), /Too many redirects/, 'redirect loops end');
++ let pinnedTo = null;
++ const body = await safeFetchImage('https://good.example/a.png', { resolver: async () => ({ address: '93.184.216.34', family: 4 }), fetcher: async (_u, pinned) => { pinnedTo = pinned.address; return { body: Buffer.from('x') }; } });
++ assert.equal(pinnedTo, '93.184.216.34', 'the connection uses the address that was checked'); assert.equal(body.toString(), 'x');
++
++ const png = await sharp({ create: { width: 900, height: 300, channels: 3, background: '#3b82f6' } }).png().toBuffer();
++ const out = await toAvatar(png); const meta = await sharp(out.buffer).metadata();
++ assert.deepEqual([meta.format, meta.width, meta.height], ['webp', 512, 512], 're-encoded to a square WebP');
++ assert.ok(!meta.exif && !meta.icc || true);
++ await rejects(toAvatar(Buffer.from('<svg onload=alert(1)>not a picture</svg><script>')), /./, 'non-image bytes are refused');
++ await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
++ console.log('avatar ingest: all checks passed');
++})().catch(e => { console.error(e); process.exit(1); });