26952ae0d9
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302 to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no localhost or private/reserved IP literal. Upload and link replace each other. - Settings -> Account first loads the link as an embedded image and refuses one this browser cannot show: a picture behind a login (Mattermost's /api/v4/users/<id>/image) gets no cookie cross-site and 401s. - Files sidebar: each workspace under its owner's home, chevrons remembered per browser (agenthub.files.homesOpen); ownerless ones stay flat.
97 lines
3.1 KiB
JavaScript
97 lines
3.1 KiB
JavaScript
/**
|
|
* @file avatar-url.js
|
|
* @description Checks an avatar LINK the account typed (Settings → Account,
|
|
* `POST /api/me/avatar/url`). The link is stored as it is and the avatar
|
|
* routes redirect the viewer's browser to it — the server never downloads it,
|
|
* so a picture behind the viewer's own login or on a company-internal host
|
|
* (e.g. a Mattermost `/api/v4/users/<id>/image`) still shows. Held to:
|
|
* `https://`, no userinfo, at most `MAX_LINK_LENGTH` characters, and no
|
|
* loopback/private/link-local/reserved IP literal as the host (a link may not
|
|
* aim every viewer's browser at their own machine or LAN).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const net = require("node:net");
|
|
|
|
const MAX_LINK_LENGTH = 2048;
|
|
|
|
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 link, normalised, or EBADURL / EPRIVATEURL: https only, no userinfo, a real host. */
|
|
function parseAvatarUrl(text) {
|
|
const raw = String(text ?? "").trim();
|
|
if (raw.length > MAX_LINK_LENGTH) throw fail("EBADURL", "the link is too long");
|
|
let url;
|
|
try {
|
|
url = new URL(raw);
|
|
} 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");
|
|
const host = url.hostname.toLowerCase();
|
|
const literal = host.replace(/^\[|\]$/g, "");
|
|
if (
|
|
host === "localhost" ||
|
|
host.endsWith(".localhost") ||
|
|
(net.isIP(literal) && !isPublicAddress(literal))
|
|
) {
|
|
throw fail("EPRIVATEURL", "that address is not on the public internet");
|
|
}
|
|
return url.href;
|
|
}
|
|
|
|
module.exports = { MAX_LINK_LENGTH, isPublicAddress, parseAvatarUrl };
|