Files
HealthLog/src/app/api/send/route.ts
T
Marc-André BombeckandClaude Opus 4.7 a4e4aaf009 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>
2026-05-27 00:08:26 +02:00

99 lines
3.3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
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";
// 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 {
return isPublicUrl(url);
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
// Remove script file segment (e.g. script.js, umami.js) if present.
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates.filter(isAllowedUmamiUrl)));
} catch {
return [];
}
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return NextResponse.json({}, { status: 204 });
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0 || !settings.umamiWebsiteId) {
return NextResponse.json({}, { status: 204 });
}
const body = await request.arrayBuffer();
// Limit proxy body size to 64 KB to prevent abuse
if (body.byteLength > 65536) {
return NextResponse.json({}, { status: 413 });
}
let lastResponseStatus = 404;
for (const targetUrl of targetUrls) {
const upstream = await fetch(targetUrl, {
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
});
lastResponseStatus = upstream.status;
// Try next candidate only for path misses.
if (upstream.status === 404) {
continue;
}
return new NextResponse(null, { status: upstream.status });
}
return new NextResponse(null, { status: lastResponseStatus });
});