fix(security): replace isAllowedUmamiUrl with the canonical isPublicUrl guard

`/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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 00:08:26 +02:00
1 parent fc83d8541f
commit a4e4aaf009
1 file changed
+11 -31
+11 -31
View File
@@ -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[] {