v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
142
api/remote/public-address.ts
Normal file
142
api/remote/public-address.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Guards for server-side fetches of user-supplied URLs (link previews, the
|
||||
* image proxy, Bandcamp resolution): the server must not become a way to reach
|
||||
* addresses only it can see — loopback, the LAN, container networks, or the
|
||||
* cloud metadata endpoint at 169.254.169.254.
|
||||
*/
|
||||
|
||||
function parseIPv4(s: string): number[] | null {
|
||||
const parts = s.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const bytes = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
|
||||
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
|
||||
}
|
||||
|
||||
function isPrivateIPv4([a, b]: number[]): boolean {
|
||||
return a === 0 || // "this" network
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
|
||||
(a === 169 && b === 254) || // link-local, cloud metadata
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0) || // IETF protocol assignments (192.0.0.0/24 …)
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 198 && (b === 18 || b === 19)) || // benchmarking
|
||||
a >= 224; // multicast, reserved, broadcast
|
||||
}
|
||||
|
||||
/** 16 bytes, or null when `s` isn't an IPv6 address. */
|
||||
function parseIPv6(s: string): number[] | null {
|
||||
let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id
|
||||
// An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups.
|
||||
const lastColon = addr.lastIndexOf(":");
|
||||
if (lastColon === -1) return null;
|
||||
const tail = addr.slice(lastColon + 1);
|
||||
if (tail.includes(".")) {
|
||||
const v4 = parseIPv4(tail);
|
||||
if (!v4) return null;
|
||||
addr = `${addr.slice(0, lastColon + 1)}${
|
||||
((v4[0] << 8) | v4[1]).toString(16)
|
||||
}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
||||
}
|
||||
const halves = addr.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] ? halves[0].split(":") : [];
|
||||
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
||||
const fill = 8 - head.length - rest.length;
|
||||
if (halves.length === 1 ? fill !== 0 : fill < 1) return null;
|
||||
const groups = [
|
||||
...head,
|
||||
...Array(halves.length === 2 ? fill : 0).fill("0"),
|
||||
...rest,
|
||||
];
|
||||
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
|
||||
return groups.flatMap((g) => {
|
||||
const n = parseInt(g, 16);
|
||||
return [n >> 8, n & 0xff];
|
||||
});
|
||||
}
|
||||
|
||||
function isPrivateIPv6(b: number[]): boolean {
|
||||
const zeroPrefix = (n: number) => b.slice(0, n).every((x) => x === 0);
|
||||
// IPv4 carried inside IPv6 is judged as the IPv4 address it carries.
|
||||
if (zeroPrefix(10) && b[10] === 0xff && b[11] === 0xff) { // ::ffff:a.b.c.d
|
||||
return isPrivateIPv4(b.slice(12));
|
||||
}
|
||||
if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
// 64:ff9b::/96 NAT64
|
||||
b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
|
||||
b.slice(4, 12).every((x) => x === 0)
|
||||
) return isPrivateIPv4(b.slice(12));
|
||||
if (b[0] === 0x20 && b[1] === 0x02) { // 2002::/16 6to4
|
||||
return isPrivateIPv4(b.slice(2, 6));
|
||||
}
|
||||
return (b[0] & 0xfe) === 0xfc || // fc00::/7 unique local
|
||||
(b[0] === 0xfe && (b[1] & 0xc0) === 0x80) || // fe80::/10 link-local
|
||||
(b[0] === 0xfe && (b[1] & 0xc0) === 0xc0) || // fec0::/10 site-local
|
||||
b[0] === 0xff; // multicast
|
||||
}
|
||||
|
||||
/** True for an IP literal outside the public internet. Non-IPs are false. */
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = parseIPv4(ip);
|
||||
if (v4) return isPrivateIPv4(v4);
|
||||
const v6 = parseIPv6(ip);
|
||||
return v6 ? isPrivateIPv6(v6) : false;
|
||||
}
|
||||
|
||||
function isIpLiteral(host: string): boolean {
|
||||
return parseIPv4(host) !== null || parseIPv6(host) !== null;
|
||||
}
|
||||
|
||||
/** `URL.hostname` keeps the brackets around an IPv6 literal. */
|
||||
function bareHostname(url: URL): string {
|
||||
return url.hostname.replace(/^\[(.*)\]$/, "$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* The synchronous part of the check: a hostname that is a private IP literal,
|
||||
* or a name that is local by definition. Names still need resolving (see
|
||||
* resolvePublicAddress) — this only rejects what is obvious without DNS.
|
||||
*/
|
||||
export function isPrivateHostname(url: URL): boolean {
|
||||
const host = bareHostname(url).toLowerCase();
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
return isPrivateAddress(host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `url`'s host and returns one of its addresses, or throws if the
|
||||
* host is private or any address it resolves to is (a name with both a public
|
||||
* and a private record is refused outright, since the fetch may pick either).
|
||||
*
|
||||
* The fetch itself resolves again, so a DNS server answering differently the
|
||||
* second time (rebinding) isn't fully excluded; the curl fallback pins the
|
||||
* address this returns.
|
||||
*/
|
||||
export async function resolvePublicAddress(url: URL): Promise<string> {
|
||||
if (isPrivateHostname(url)) {
|
||||
throw new Error(`Refusing to fetch private address ${url.hostname}`);
|
||||
}
|
||||
const host = bareHostname(url);
|
||||
if (isIpLiteral(host)) return host;
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
Deno.resolveDns(host, "A"),
|
||||
Deno.resolveDns(host, "AAAA"),
|
||||
]);
|
||||
const addresses = results.flatMap((r) =>
|
||||
r.status === "fulfilled" ? r.value : []
|
||||
);
|
||||
if (addresses.length === 0) {
|
||||
throw new Error(`Could not resolve ${host}`);
|
||||
}
|
||||
const blocked = addresses.find(isPrivateAddress);
|
||||
if (blocked) {
|
||||
throw new Error(`Refusing to fetch ${host}: resolves to ${blocked}`);
|
||||
}
|
||||
return addresses[0];
|
||||
}
|
||||
Reference in New Issue
Block a user