fix(security): require admin Umami and GlitchTip URLs to be publicly reachable

Both fields are admin-supplied and feed into outbound server-side
fetches: the Umami script URL is proxied through
`/api/monitoring/umami-script` (and the script body is re-served from
the app origin under a strict CSP), and the GlitchTip DSN derives the
`envelopeUrl` / `storeUrl` the server posts errors to. The previous
validators only checked the protocol, so an admin (or an attacker
holding the admin account) could point either at a private-IP target
and drive the server into the RFC1918 / loopback / metadata space.

The `isPublicUrl` helper already governs every other admin-supplied
outbound URL the app dials; this aligns Umami + GlitchTip with that
posture. GlitchTip DSNs carry the public key in userinfo
(`https://<key>@host/projectid`) — the WHATWG URL parser strips the
userinfo from the hostname, so the helper validates the host as
expected.

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:32 +02:00
1 parent a4e4aaf009
commit e16f4038ed
1 file changed
+18 -14
+18 -14
View File
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* Schema for admin settings update (PUT /api/admin/settings).
@@ -29,14 +30,16 @@ export const adminSettingsSchema = z
(v) => {
const trimmed = v.trim();
if (!trimmed) return true;
try {
const parsed = new URL(trimmed);
return ["https:", "http:"].includes(parsed.protocol);
} catch {
return false;
}
// The Umami script proxy fetches this URL server-side and
// re-serves the body from the app origin under a strict CSP
// — a private-IP target would be an SSRF channel. Match the
// posture every other admin-supplied outbound URL takes.
return isPublicUrl(trimmed);
},
{
message:
"Umami script URL must be a public HTTP(S) endpoint (no private / loopback / metadata addresses)",
},
{ message: "Umami script URL is invalid" },
)
.optional(),
umamiWebsiteId: z.string().optional(),
@@ -47,14 +50,15 @@ export const adminSettingsSchema = z
(v) => {
const trimmed = v.trim();
if (!trimmed) return true;
try {
const parsed = new URL(trimmed);
return ["https:", "http:"].includes(parsed.protocol);
} catch {
return false;
}
// Glitchtip DSNs are `https://<key>@host/projectid`. The
// userinfo is the auth material; the SSRF surface is the
// host — same `isPublicUrl` posture as the Umami URL.
return isPublicUrl(trimmed);
},
{
message:
"Glitchtip DSN must point to a public HTTP(S) endpoint (no private / loopback / metadata addresses)",
},
{ message: "Glitchtip DSN is invalid" },
)
.optional(),
glitchtipEnvironment: z.string().optional(),