feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding

Closes issue 217. The isPublicUrl input-time guard catches every
RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL
DNS record can flip between accept and dispatch. The new
pinnedPublicDispatcher resolves the hostname literally inside
undici.Agent's connect hook, refuses any address isPublicIp would
reject, and pins the connection to the first valid public address.

The five outbound paths that accept a user-supplied host route
through the dispatcher via safeFetch's requirePublicHost flag —
moodlog sync, moodlog reverse-push, the moodlog credentials probe,
the local AI client (already gated in commit 1), and the ntfy
sender. The flag now buys both the input-time check and the
connect-time pin in one call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 22:32:11 +02:00
1 parent 425503e095
commit b43b57f657
8 files changed
+381 -7

No files matched your search

@@ -116,7 +116,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
try {
// safeFetch keeps the manual-redirect guard (the apiKey would
// otherwise leak on a 302 to 169.254.169.254 / RFC1918) and adds
// the AbortSignal.timeout convention.
// the AbortSignal.timeout convention. requirePublicHost layers in
// the connect-time IP pin so a DNS rebinding cannot flip the host
// between accept and dispatch (issue #217).
const res = await safeFetch(
probeUrl.toString(),
{
@@ -124,7 +126,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store",
},
{ timeoutMs: TIMEOUT_MS },
{ timeoutMs: TIMEOUT_MS, requirePublicHost: true },
);
const latencyMs = Math.round(performance.now() - start);