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/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/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
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ src/app/
|
||||||
api/
|
api/
|
||||||
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
|
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
|
||||||
activities/route.ts → proxy for the user's GitDXD activity feed
|
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/
|
components/
|
||||||
ThemeRoot.tsx → picks which shell to show (the router of the UI)
|
ThemeRoot.tsx → picks which shell to show (the router of the UI)
|
||||||
TerminalLayout.tsx → "terminal" theme (Gruvbox)
|
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
|
- 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
|
`APP_BY_ID` only, like `ALACRITTY_APP`) — a Minecraft-style join menu that
|
||||||
reads live data from `/api/ping?url=minecraft://…`.
|
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
|
- Service shortcuts show the real site in an iframe via
|
||||||
`createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only
|
`createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only
|
||||||
works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP.
|
works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,34 @@
|
||||||
import { NextResponse } from 'next/server';
|
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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const url = searchParams.get('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) return NextResponse.json({ status: 'down' });
|
||||||
|
|
||||||
if (url.startsWith('minecraft://')) {
|
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 {
|
try {
|
||||||
// mcsrvstat.us is unreliable at resolving some hostnames, so use mcstatus.io.
|
// mcsrvstat.us is unreliable at resolving some hostnames, so use mcstatus.io.
|
||||||
const res = await fetch(`https://api.mcstatus.io/v2/status/java/${address}`, {
|
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 {
|
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',
|
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.)
|
// Accept all non-5xx responses as "up". 5xx usually means origin or gateway errors (like Cloudflare 521, 522, etc.)
|
||||||
if (httpRes && httpRes.status < 500) {
|
if (httpRes && httpRes.status < 500) {
|
||||||
return NextResponse.json({ status: 'up' });
|
return NextResponse.json({ status: 'up' });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error(`Failed to ping ${url}`, e);
|
// Request failed or timed out; fall through to "down".
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ status: 'down' });
|
return NextResponse.json({ status: 'down' });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue