fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)

Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
This commit is contained in:
Maurice
2026-09-25 19:44:40 +02:00
parent 151e32107f
commit 0887f251ce
6 changed files with 830 additions and 58 deletions
+8 -5
View File
@@ -19,11 +19,12 @@ Under the strict guard, these ranges are blocked whatever `ALLOW_INTERNAL_NETWOR
| Range | Description |
|---|---|
| `127.0.0.0/8`, `::1` | Loopback |
| `0.0.0.0/8` | Unspecified |
| `0.0.0.0/8`, `::` | Unspecified |
| `169.254.0.0/16`, `fe80::/10` | Link-local / cloud metadata endpoints |
| `::ffff:127.x.x.x`, `::ffff:169.254.x.x` | IPv4-mapped loopback and link-local |
| `fd00:ec2::/32`, `100.100.100.200`, `100.100.100.100` | AWS (IMDSv6) and Alibaba Cloud metadata. They sit inside the ULA and CGNAT ranges below and stay blocked when those open up |
| IPv4-mapped, IPv4-compatible and NAT64/6to4/Teredo forms of the above | e.g. `::ffff:127.0.0.1`, `::169.254.169.254`, `64:ff9b::a9fe:a9fe` |
The IPv6 link-local rule covers the whole `fe80::/10` prefix (`fe80:` to `febf:`), under the relaxed guard as well.
The IPv6 link-local rule covers the whole `fe80::/10` prefix (`fe80:` to `febf:`). The link-local and metadata rows hold under the relaxed guard as well.
The one way past this table is `ALLOW_LINK_LOCAL_IPS`, for a single IPv4 link-local address, see [below](#a-link-local-address-you-need).
@@ -41,7 +42,7 @@ The one way past this table is `ALLOW_LINK_LOCAL_IPS`, for a single IPv4 link-lo
The hostname `localhost` is matched at the hostname stage too, but it normally resolves to a loopback address (`127.0.0.1` or `::1`), which the always-blocked loopback rule catches first, so under the strict guard it is blocked no matter how `ALLOW_INTERNAL_NETWORK` is set. On a host that maps `localhost` somewhere else, the hostname rule still applies and it stays blocked unless `ALLOW_INTERNAL_NETWORK=true`. The relaxed guard allows `localhost` outright, which is what makes a local Ollama the supported default for AI Parsing.
`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true`: the guard still resolves them to an IP and enforces all IP-level rules, so any such hostname that resolves to a loopback or link-local address remains blocked regardless.
`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true`: the guard still resolves them to an IP and enforces all IP-level rules, so TREK never connects to a loopback or link-local address such a hostname resolves to, and a hostname with no other address remains blocked regardless.
## When to enable
@@ -71,7 +72,9 @@ Several addresses are separated by commas. The list is read at startup, so resta
## DNS rebinding protection
Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the IP. The outbound connection is then made directly to that IP using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request.
Even with `ALLOW_INTERNAL_NETWORK=true`, TREK pins the DNS resolution to prevent rebinding attacks. When the guard checks a URL, it resolves the hostname once and records the addresses it may use. The outbound connection is then made directly to those addresses using a pinned dispatcher (via undici), so the hostname cannot re-resolve to a different address between the check and the actual request.
A hostname can resolve to several addresses, and a LAN DNS server often hands out a host's IPv6 link-local address (`fe80::`) next to its IPv4 one. TREK never connects to an address the guard blocks: it leaves that address out and connects over the ones that remain. Only a hostname whose addresses are all blocked is refused. Under the strict guard, a private address among the rest still needs `ALLOW_INTERNAL_NETWORK=true`.
## Audit log
+2
View File
@@ -269,6 +269,8 @@ Set `OIDC_ISSUER` to that exact string.
**Cause:** Not the SSRF guard, despite what it looks like. All four OIDC calls (discovery, token, userinfo, JWKS) go through the admin-configured fetch path, which deliberately **allows** loopback and private/LAN targets: a Keycloak or Authentik on `192.168.x` or `10.x` is a supported setup and needs no extra variable. `ALLOW_INTERNAL_NETWORK` belongs to the guard on *user*-supplied URLs and changes nothing about OIDC. The only addresses that path refuses are link-local and cloud-metadata ones (`169.254.0.0/16`, `fe80::/10`), which fail with `Requests to link-local / cloud-metadata addresses are not allowed`. A provider behind the host gateway of a rootless Podman container resolves to `169.254.1.2` and fails exactly like that; list that address in `ALLOW_LINK_LOCAL_IPS`, see [Internal-Network-Access](Internal-Network-Access#a-link-local-address-you-need).
Versions 4.3.0 to 4.3.2 also failed like that when the provider's hostname had an IPv6 link-local (`fe80::`) record next to its LAN address, which many LAN DNS servers hand out. From 4.3.3 on, TREK leaves such an address out and connects over the LAN address, so only a hostname that resolves to nothing but link-local or metadata addresses still fails.
**Fix:** Look for the reasons an internal provider actually fails. A failed discovery fetch answers `500 { "error": "OIDC login failed" }` and logs the real message as `[OIDC] Login error: …`, so start there:
```bash