api/ping: allowlist hosts and block redirects to close SSRF/LAN-scan vector

This commit is contained in:
Martin Dvořák 2026-09-24 20:43:41 +00:00
commit ea757595b0
3 changed files with 67 additions and 7 deletions

View file

@ -1,5 +1,34 @@
import { NextResponse } from 'next/server';
// This route is reachable without authentication and it makes the server issue
// an outbound request for whoever calls it. Without an allowlist,
// `?url=http://192.168.x.x` turns the site into an open SSRF gadget / LAN port
// scanner (and an open request proxy in general). Only the site's own services,
// the Minecraft SMP and the friends' sites are actually health-checked.
//
// Host matching is exact or by subdomain (e.g. anything under
// dvorakmortonxd.dev). Keep this in sync with the ServiceButton `url`/`checkUrl`
// values in components/apps.tsx and components/TerminalLayout.tsx, and with the
// friend list in data.ts.
const ALLOWED_HOSTS = [
'dvorakmortonxd.dev', // ai / jelly / navidrome / git / proxmox / mc (SMP)
'skopec.dev',
'bukovinafilip.com',
'vlcak.me',
];
function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/\.$/, '');
return ALLOWED_HOSTS.some(
(allowed) => host === allowed || host.endsWith(`.${allowed}`)
);
}
// A Minecraft address we are willing to forward to mcstatus.io: a plain
// hostname/IPv4 with an optional port. Deliberately excludes '@', '/', '?', '#'
// and IPv6 brackets so it cannot smuggle another path into the upstream URL.
const MINECRAFT_ADDRESS = /^[a-z0-9.-]+(:\d{1,5})?$/i;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
@ -7,7 +36,12 @@ export async function GET(request: Request) {
if (!url) return NextResponse.json({ status: 'down' });
if (url.startsWith('minecraft://')) {
const address = url.replace('minecraft://', '');
const address = url.slice('minecraft://'.length);
const host = MINECRAFT_ADDRESS.test(address) ? address.split(':')[0] : '';
if (!host || !isAllowedHost(host)) {
return NextResponse.json({ status: 'down' });
}
try {
// mcsrvstat.us is unreliable at resolving some hostnames, so use mcstatus.io.
const res = await fetch(`https://api.mcstatus.io/v2/status/java/${address}`, {
@ -30,18 +64,38 @@ export async function GET(request: Request) {
}
}
let target: URL;
try {
const httpRes = await fetch(url, {
target = new URL(url);
} catch {
return NextResponse.json({ status: 'down' });
}
// Only allow-listed http(s) hosts. This rejects file:, ftp:, internal IPs,
// `user@host` tricks and anything the allowlist does not cover.
if (
(target.protocol !== 'http:' && target.protocol !== 'https:') ||
!isAllowedHost(target.hostname)
) {
return NextResponse.json({ status: 'down' });
}
try {
const httpRes = await fetch(target, {
method: 'GET',
signal: AbortSignal.timeout(5000)
// Never follow redirects: an allowed host could otherwise redirect to an
// internal address and reopen the SSRF. A 3xx still means the service
// answered, so it counts as "up".
redirect: 'manual',
signal: AbortSignal.timeout(5000),
});
// Accept all non-5xx responses as "up". 5xx usually means origin or gateway errors (like Cloudflare 521, 522, etc.)
if (httpRes && httpRes.status < 500) {
return NextResponse.json({ status: 'up' });
}
} catch (e) {
console.error(`Failed to ping ${url}`, e);
} catch {
// Request failed or timed out; fall through to "down".
}
return NextResponse.json({ status: 'down' });