From e16f4038edac74ac27b4a58c5153af43b6e18824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 00:08:32 +0200 Subject: [PATCH] fix(security): require admin Umami and GlitchTip URLs to be publicly reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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://@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) --- src/lib/validations/admin.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 9eb1570cd..ac18dec0d 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -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://@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(),