/** * @file avatar-url.js * @description Fetches an avatar picture from a URL the account typed * (Settings → Account, `POST /api/me/avatar/url`), so the server — not the * browser, which most image hosts' CORS would block — downloads it. A URL the * user names makes the SERVER connect somewhere, so it is held to: `https://` * with no userinfo, a host whose EVERY resolved address is public (no * loopback, private, link-local, CGNAT, multicast or reserved range — the * dashboard's own network is never reachable this way), the connection pinned * to the vetted address (a second DNS answer cannot rebind it), at most * `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at * most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The * bytes then go through `setAvatar`'s own magic-byte check like an upload. * @author Nguyễn Ngọc Trí Vĩ */ const dns = require("node:dns"); const https = require("node:https"); const net = require("node:net"); const { MAX_AVATAR_BYTES } = require("./avatars"); const MAX_REDIRECTS = 3; const TIMEOUT_MS = 10_000; const fail = (code, message) => Object.assign(new Error(message), { code }); const blocked = new net.BlockList(); for (const [addr, bits] of [ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4], ]) { blocked.addSubnet(addr, bits, "ipv4"); } for (const [addr, bits] of [ ["::", 128], ["::1", 128], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8], ]) { blocked.addSubnet(addr, bits, "ipv6"); } /** True only for a globally routable unicast address. */ function isPublicAddress(address) { const family = net.isIP(address); if (family === 4) return !blocked.check(address, "ipv4"); if (family !== 6) return false; const lower = address.toLowerCase(); // An IPv4-mapped/compatible address is judged as the IPv4 address it carries. const mapped = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(lower); if (mapped) return isPublicAddress(mapped[1]); if (/^::(?:ffff:)?[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(lower)) return false; // Only global unicast (2000::/3) is public. if (!/^[23][0-9a-f]{0,3}:/.test(lower)) return false; return !blocked.check(address, "ipv6"); } /** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */ function parseAvatarUrl(text) { let url; try { url = new URL(String(text ?? "").trim()); } catch { throw fail("EBADURL", "not a valid URL"); } if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link"); if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused"); if (!url.hostname) throw fail("EBADURL", "not a valid URL"); // Node never calls `lookup` for an IP-literal host, so it is checked here. const literal = url.hostname.replace(/^\[|\]$/g, ""); if (net.isIP(literal) && !isPublicAddress(literal)) { throw fail("EPRIVATEURL", "that address is not on the public internet"); } return url; } /** * `lookup` for `https.request`: resolves every address and refuses the host * when ANY of them is not public, then hands over only vetted ones. */ function safeLookup(hostname, options, cb) { if (typeof options === "function") [cb, options] = [options, {}]; const literal = hostname.replace(/^\[|\]$/g, ""); const done = (addrs) => { if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) { return cb(fail("EPRIVATEURL", "that address is not on the public internet")); } if (options?.all) return cb(null, addrs); return cb(null, addrs[0].address, addrs[0].family); }; if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]); dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => { if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`)); done(addrs); }); } function getOnce(url) { return new Promise((resolve, reject) => { const req = https.get( url, { lookup: safeLookup, agent: false, timeout: TIMEOUT_MS, headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" }, }, (res) => { const status = res.statusCode || 0; if (status >= 300 && status < 400 && res.headers.location) { res.resume(); return resolve({ redirect: new URL(res.headers.location, url).href }); } if (status !== 200) { res.resume(); return reject(fail("EFETCH", `the server answered ${status}`)); } if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) { res.destroy(); return reject(fail("ETOOBIG", "the image is too large")); } const chunks = []; let size = 0; res.on("data", (chunk) => { size += chunk.length; if (size > MAX_AVATAR_BYTES) { res.destroy(); return reject(fail("ETOOBIG", "the image is too large")); } chunks.push(chunk); }); res.on("end", () => resolve({ body: Buffer.concat(chunks) })); res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message))); } ); req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long"))); req.on("error", (e) => reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message)) ); }); } /** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */ async function fetchAvatarImage(text) { let url = parseAvatarUrl(text); for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { const out = await getOnce(url); if (out.body) return out.body; url = parseAvatarUrl(out.redirect); } throw fail("EFETCH", "too many redirects"); } module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };