All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
/**
|
|
* 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];
|
|
}
|