From a4e4aaf009cc2844889108fa923d8e5e9333d1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 00:08:26 +0200 Subject: [PATCH] fix(security): replace isAllowedUmamiUrl with the canonical isPublicUrl guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/send` is the public Umami proxy. The hand-rolled allowlist it carried predated the central `isPublicUrl` helper in `src/lib/validations/notifications.ts` and was missing every notation-bypass class the helper catches: - 100.64.0.0/10 CGNAT - 0.0.0.0/8 (only the literal `0.0.0.0` was matched) - 169.254/16 outside the AWS metadata IP (e.g. azure metadata is `169.254.169.254` too, but other addresses in the block are routable to other internal endpoints) - IPv6 ULA (`fc00::/7`) - IPv4-mapped IPv6 (`::ffff:127.0.0.1`) - Leading-zero IPv4 octal bypass (`http://010.0.0.1` → 8.0.0.1) - Hex IPv4 (`http://0x7f.0.0.1`) - Pure-decimal IPv4 (`http://2130706433`) The dev-mode escape hatch is also dropped — serving the proxy against a localhost / RFC1918 Umami target was never a useful configuration anyway, since the proxy sends the visitor's user-agent + body upstream which only makes sense against an externally-reachable analytics host. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/send/route.ts | 42 ++++++++++----------------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index fde331cf7..fe00828d7 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -4,41 +4,21 @@ import { apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; import { checkRateLimit } from "@/lib/rate-limit"; +import { isPublicUrl } from "@/lib/validations/notifications"; export const dynamic = "force-dynamic"; -// Allowed URL patterns for the Umami proxy to prevent SSRF. -// Only permits HTTPS URLs (or localhost in dev) to known analytics paths. +// Use the canonical `isPublicUrl` guard rather than a hand-rolled +// allowlist. The previous local implementation predated the central +// helper and was missing CGNAT (100.64/10), IPv6 ULA (fc00::/7), the +// octal/hex/decimal IPv4 alt-notations, and the IPv4-mapped IPv6 +// shapes — every one of which the shared helper catches. Dev mode is +// no longer special-cased here: a localhost-or-RFC1918 Umami URL was +// never a valid configuration anyway (the proxy ships the request to +// the configured host with the visitor's user-agent + body, which +// only makes sense against an externally-reachable analytics target). function isAllowedUmamiUrl(url: string): boolean { - try { - const parsed = new URL(url); - const isDev = process.env.NODE_ENV === "development"; - if (!isDev && parsed.protocol !== "https:") return false; - // Block requests to private/internal networks - const hostname = parsed.hostname; - if ( - hostname === "169.254.169.254" || // cloud metadata - hostname === "localhost" || - hostname.startsWith("10.") || - hostname.startsWith("192.168.") || - hostname === "127.0.0.1" || - hostname === "[::1]" || - hostname === "0.0.0.0" || - hostname.endsWith(".internal") || - hostname.endsWith(".local") || - // 172.16.0.0/12 (172.16.x.x - 172.31.x.x) - (hostname.startsWith("172.") && - (() => { - const second = parseInt(hostname.split(".")[1] ?? "0", 10); - return second >= 16 && second <= 31; - })()) - ) { - if (!isDev) return false; - } - return true; - } catch { - return false; - } + return isPublicUrl(url); } function resolveUmamiSendUrls(scriptUrl: string | null): string[] {