mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
fix(collab): harden the link preview fetcher (#2058)
The preview endpoint takes a URL from whoever is typing in a trip chat and fetches it from the server. Trip access was added to it earlier; this is the rest of what that route needed. The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0` but not the IPv6 unspecified address: `::` starts with a colon, so the `0.` check never saw it, and connecting to it lands on loopback. `http://[::]:9200/` therefore passed the guard and came back with the target's title and og: tags scraped out of the body — an exfiltrating read of anything listening locally, from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same address in other spellings and behaved the same way. The two `::ffff:`-prefix regexes had the same shape of gap: they matched the dotted spelling of loopback and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on the expanded hextets, which covers every spelling at once, and fe80::/10 is read as ten bits rather than as the four characters 'fe80'. This is in ssrfGuard, so every caller gets it, not just collab. The scrape could be turned into a denial of service. Each og: regex had two unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it made the engine rescan the remainder from every `<meta` it passed — quadratic, and the byte cap admits half a megabyte. Measured on this build: 240KB of '<meta ' took 58 seconds, and Node has one thread, so that is the entire server, health check included, on one request. Bounding the runs at 512 characters makes it linear (540KB now takes 1.4s); no real attribute list comes close to that. The five-second timeout only covered the headers — `clearTimeout` ran before the body was read — so a server that answered fast and then dripped one byte every few seconds held the handler, the socket and the per-request undici agent open indefinitely. The byte cap counts bytes and at that rate would never reach one. `AbortSignal.timeout` covers both halves, the agent is closed in a `finally`, and the three bail-outs that used to return without touching the body now discard it, which is what cappedFetch's helper is there for. The rest is smaller. Nothing metered the fetches, so an account with a trip of its own could drive arbitrary outbound requests in a loop; there is a budget of sixty new fetches a minute per user now, charged per outbound fetch rather than per request. Previews are cached for ten minutes and simultaneous askers for one URL share a single fetch, so the client's habit of requesting a preview per rendered message — up to a hundred at once, with no debounce — costs one fetch per new link rather than one per render. Refusals answer with one constant reason instead of the guard's own: it distinguishes "could not resolve" from "private address", which together map out the server's internal DNS for anyone willing to guess hostnames. A body whose declared type is not markup is dropped rather than combed for og: tags, and an og:image that is not an http(s) URL is not passed to the client, which puts it straight into an <img src>. Deliberately no `@RequirePermission` on the route: the client asks for a preview while rendering a message or a note, never while composing one, so it belongs with the GET siblings. Requiring collab_edit would blank every preview for members of a trip whose owner narrowed that right while leaving them able to read the chat, and it would not stop the abuse it looks like it addresses — the permission check passes for a trip's owner at every level, and anyone can create a trip.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import dns from 'node:dns/promises';
|
||||
import { Agent } from 'undici';
|
||||
import { readEnv } from '../app-config';
|
||||
import { embeddedTransitionIpv4 } from './ipv6';
|
||||
import { embeddedTransitionIpv4, expandIpv6 } from './ipv6';
|
||||
|
||||
// Frozen at import on purpose (legacy timing; tests reload the module to change it).
|
||||
const ALLOW_INTERNAL_NETWORK = readEnv().net.allowInternalNetwork;
|
||||
@@ -13,6 +13,20 @@ export interface SsrfResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The IPv4 an IPv4-mapped address (`::ffff:a.b.c.d`) stands for, or null.
|
||||
*
|
||||
* Taken from the hextets rather than from the text, because `::ffff:127.0.0.1`
|
||||
* and `::ffff:7f00:1` are one address in two spellings and `dns.lookup` picks
|
||||
* which one comes back. The prefix regexes this replaces covered the dotted
|
||||
* spelling of two ranges; every other mapped address walked past them.
|
||||
*/
|
||||
function mappedIpv4(hextets: number[]): string | null {
|
||||
const g = hextets;
|
||||
if (g[0] || g[1] || g[2] || g[3] || g[4] || g[5] !== 0xffff) return null;
|
||||
return `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
|
||||
}
|
||||
|
||||
// Always blocked — no override possible
|
||||
function isAlwaysBlocked(ip: string): boolean {
|
||||
// Strip IPv6 brackets
|
||||
@@ -23,9 +37,21 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
// Unspecified
|
||||
if (addr.startsWith('0.')) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
if (addr.startsWith('169.254.')) return true;
|
||||
|
||||
const hextets = expandIpv6(addr);
|
||||
if (hextets) {
|
||||
// The IPv6 unspecified address. Connecting to it lands on loopback, so it
|
||||
// reaches a local service without naming one, and it has enough spellings
|
||||
// (`::`, `::0`, `0:0:0:0:0:0:0:0`) that only the expanded hextets settle it.
|
||||
// The `0.` check above never saw any of them: they begin with a colon.
|
||||
if (hextets.every(h => h === 0)) return true;
|
||||
// fe80::/10 spans fe80: through febf:, not just the four characters 'fe80'.
|
||||
if ((hextets[0] & 0xffc0) === 0xfe80) return true;
|
||||
// A mapped address inherits the verdict of the IPv4 it carries.
|
||||
const mapped = mappedIpv4(hextets);
|
||||
if (mapped) return isAlwaysBlocked(mapped);
|
||||
}
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isAlwaysBlocked(embedded);
|
||||
@@ -45,10 +71,12 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
if (/^f[cd]/i.test(addr)) return true;
|
||||
// IPv4-mapped RFC-1918
|
||||
if (/^::ffff:10\./i.test(addr)) return true;
|
||||
if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true;
|
||||
if (/^::ffff:192\.168\./i.test(addr)) return true;
|
||||
// A mapped address inherits the verdict of the IPv4 it carries. Deciding on the
|
||||
// hextets rather than on three `::ffff:`-prefix regexes also covers the hex
|
||||
// spelling and the CGNAT range those regexes never listed.
|
||||
const hextets = expandIpv6(addr);
|
||||
const mapped = hextets && mappedIpv4(hextets);
|
||||
if (mapped) return isPrivateNetwork(mapped);
|
||||
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4.
|
||||
const embedded = embeddedTransitionIpv4(addr);
|
||||
if (embedded) return isPrivateNetwork(embedded);
|
||||
|
||||
Reference in New Issue
Block a user