diff --git a/README.md b/README.md index c909b05..e902dab 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ site. To force the raw server instead, use `npm run dev:next` (don't point a tun | --- | --- | --- | | `/api/heatmap` | `GET` | Proxies the user's contribution heatmap from `git.dvorakmortonxd.dev`. | | `/api/activities` | `GET` | Fetches the GitDXD activity feed and keeps only the user's own events. | -| `/api/ping?url=…` | `GET` | Health-checks a service. Handles `minecraft://host:port` via mcstatus.io; treats any non-5xx HTTP response as "up". | +| `/api/ping?url=…` | `GET` | Health-checks an **allow-listed** service (the site's own `*.dvorakmortonxd.dev` hosts and the friends' sites). Handles `minecraft://host:port` via mcstatus.io; treats any non-5xx response as "up". Other hosts and schemes are rejected and redirects are never followed, so it can't be used to probe the LAN (SSRF guard). | ## 📁 Project Structure diff --git a/opencode.md b/opencode.md index 7cee25a..2d335fb 100644 --- a/opencode.md +++ b/opencode.md @@ -83,7 +83,7 @@ src/app/ api/ heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap activities/route.ts → proxy for the user's GitDXD activity feed - ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io) + ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist components/ ThemeRoot.tsx → picks which shell to show (the router of the UI) TerminalLayout.tsx → "terminal" theme (Gruvbox) @@ -261,6 +261,12 @@ list reordering, not free placement. Don't confuse the two. - The SMP service shortcut opens `SMP_APP` (in `apps.tsx`, registered in `APP_BY_ID` only, like `ALACRITTY_APP`) — a Minecraft-style join menu that reads live data from `/api/ping?url=minecraft://…`. +- `/api/ping` is a **host-allowlisted** SSRF guard (public + unauthenticated): + it only contacts `*.dvorakmortonxd.dev` plus the friends' domains, rejects + other hosts/schemes, validates the `minecraft://` address, and never follows + redirects. **Adding a service or friend host means adding it to + `ALLOWED_HOSTS` in `api/ping/route.ts`**, otherwise its status dot reads + "Dead". Do not turn this route back into an open fetch proxy. - Service shortcuts show the real site in an iframe via `createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP. diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts index c7074a3..0bc19d4 100644 --- a/src/app/api/ping/route.ts +++ b/src/app/api/ping/route.ts @@ -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' });