api/ping: allowlist hosts and block redirects to close SSRF/LAN-scan vector
This commit is contained in:
parent
f5a913201a
commit
ea757595b0
3 changed files with 67 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue