load skopec.dev live in its window via an allowlisted header-stripping proxy (/api/frame)

This commit is contained in:
Martin Dvořák 2026-09-30 06:42:36 +00:00
commit a4a12bfe2a
5 changed files with 147 additions and 12 deletions

View file

@ -42,9 +42,10 @@ heatmap**, a gym SBD panel and links to friends' sites.
A spinner covers the frame while the site boots. Discord sends `DENY` and keeps a link card A spinner covers the frame while the site boots. Discord sends `DENY` and keeps a link card
instead. instead.
- **Friend sites in windows** — clicking a friend's desktop icon opens their site in a - **Friend sites in windows** — clicking a friend's desktop icon opens their site in a
window. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they load live; window. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they load directly;
`skopec.dev` sends `X-Frame-Options: SAMEORIGIN`, so the window frames the bundled mirror `skopec.dev` sends `X-Frame-Options: SAMEORIGIN`, so it loads through `/api/frame`, a
at `/skopec.html` and the `www` button opens the real site. header-stripping proxy that serves the live page from our origin. The `www` button always
opens the real site.
- **Minecraft-style SMP window** — the SMP shortcut opens a join menu modelled on - **Minecraft-style SMP window** — the SMP shortcut opens a join menu modelled on
Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled
from mcstatus.io through the site's own ping API. from mcstatus.io through the site's own ping API.
@ -156,6 +157,7 @@ they contend for `:3000` and `.next/dev/lock`.
| --- | --- | --- | | --- | --- | --- |
| `/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/frame?url=…` | `GET` | Header-stripping proxy for a friend's site that refuses framing (`skopec.dev`). **Allow-listed hosts only**; fetches the HTML server-side (so the site's `X-Frame-Options` never reaches the browser), injects a `<base>` for relative assets and a click shim for in-site links. Rejects other hosts, non-http(s) schemes and credentials, and never follows redirects. |
| `/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). | | `/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

View file

@ -84,6 +84,7 @@ src/app/
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); host allowlist ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; 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)
@ -299,10 +300,16 @@ list reordering, not free placement. Don't confuse the two.
is the `embed` flag; `friend.url` is what the frame loads and `friend.realUrl` is the `embed` flag; `friend.url` is what the frame loads and `friend.realUrl`
is what the www button opens — `createWebsiteApp`'s `embedUrl` option lets the is what the www button opens — `createWebsiteApp`'s `embedUrl` option lets the
two differ. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they two differ. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they
frame the live site; `skopec.dev` sends `X-Frame-Options: SAMEORIGIN` on every frame the live site. `skopec.dev` sends `X-Frame-Options: SAMEORIGIN` on every
path, so `friends[0].url` points at the bundled `/public/skopec.html` mirror path, so `friend.proxy` routes it through `/api/frame`.
and only the button is live. Making a blocking site load *live* would need a - `/api/frame` is a **host-allowlisted** header-stripping proxy for sites that
header-stripping proxy (allowlisted) — not implemented. refuse framing. It fetches the HTML server-side (so the upstream
`X-Frame-Options` never reaches the browser), injects `<base href="origin/">`
so relative assets still resolve to the real site, and injects a click shim so
same-host navigation is proxied too. It only proxies `ALLOWED_HOSTS`
(`skopec.dev`), never follows redirects, rejects credentials and non-http(s),
and returns HTML only — it is public, so keep it that narrow. The framed page
executes under our origin, so only allow-list hosts you trust.
- **Embedded iframes swallow the pointer.** While dragging/resizing a window, - **Embedded iframes swallow the pointer.** While dragging/resizing a window,
the moment the cursor passes over a service `<iframe>` the site's own document the moment the cursor passes over a service `<iframe>` the site's own document
receives the `pointermove`/`pointerup`, so a gesture that listens on `window` receives the `pointermove`/`pointerup`, so a gesture that listens on `window`
@ -346,6 +353,9 @@ list reordering, not free placement. Don't confuse the two.
local dir and `LD_LIBRARY_PATH`. Frame the test from an `http(s)` origin (e.g. local dir and `LD_LIBRARY_PATH`. Frame the test from an `http(s)` origin (e.g.
a file dropped in `public/` served at `localhost:3000`); a `file://` a file dropped in `public/` served at `localhost:3000`); a `file://`
top-level blocks the iframe's storage and makes working sites look blank. top-level blocks the iframe's storage and makes working sites look blank.
For a site that refuses framing, `/api/frame?url=<site>` should return `200`
with no `X-Frame-Options` and `frame-ancestors 'self'`, while `?url=` for any
other host returns `403`.
--- ---

108
src/app/api/frame/route.ts Normal file
View file

@ -0,0 +1,108 @@
import { NextResponse } from 'next/server';
// Server-side proxy for friend sites that refuse to be framed. `skopec.dev`
// answers every path with `X-Frame-Options: SAMEORIGIN`, so a plain iframe comes
// up blank. This route fetches the HTML itself — because we build our own
// response, the upstream framing headers never reach the browser — injects a
// `<base>` so the site's relative assets still resolve against its own origin,
// and adds a small click shim so in-site navigation is proxied too.
//
// This is a public, unauthenticated endpoint that makes the server fetch a URL
// for the caller, so it is deliberately narrow: allow-listed hosts only, http(s)
// only, no credentials, redirects never followed, HTML only. That keeps it from
// becoming an open proxy or an SSRF gadget. Keep ALLOWED_HOSTS in sync with the
// friends in data.ts.
const ALLOWED_HOSTS = ['skopec.dev', 'www.skopec.dev'];
function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/\.$/, '');
return ALLOWED_HOSTS.includes(host);
}
const FETCH_TIMEOUT_MS = 10_000;
const MAX_HTML_BYTES = 3_000_000;
function fail(status: number, message: string) {
return NextResponse.json({ error: message }, { status });
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const raw = searchParams.get('url');
if (!raw) return fail(400, 'missing url');
let target: URL;
try {
target = new URL(raw);
} catch {
return fail(400, 'invalid url');
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return fail(400, 'unsupported protocol');
}
if (target.username || target.password) return fail(400, 'credentials not allowed');
if (!isAllowedHost(target.hostname)) return fail(403, 'host not allowed');
let upstream: Response;
try {
upstream = await fetch(target, {
// Never chase a Location off the allowlist (that would reopen the SSRF).
redirect: 'manual',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
// A browser-ish UA so bot filters don't challenge the server request.
'user-agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml',
},
});
} catch {
return fail(502, 'upstream fetch failed');
}
if (upstream.status >= 300 && upstream.status < 400) {
return fail(502, 'upstream redirect not followed');
}
if (!upstream.ok) return fail(502, `upstream returned ${upstream.status}`);
const contentType = (upstream.headers.get('content-type') ?? '').toLowerCase();
if (!contentType.includes('text/html')) return fail(415, 'upstream is not HTML');
let html = await upstream.text();
if (html.length > MAX_HTML_BYTES) return fail(413, 'upstream response too large');
// Relative URLs (style.css, favicon.svg, hardware.html, …) must resolve
// against the real site, so inject a <base> for the directory we fetched,
// before any <base> the page itself provides.
const origin = `${target.protocol}//${target.host}`;
const directory = target.pathname.replace(/[^/]*$/, '');
const head = `<base href="${origin}${directory}">${clickShim(target.hostname)}`;
html = /<head\b[^>]*>/i.test(html)
? html.replace(/<head\b[^>]*>/i, (tag) => tag + head)
: head + html;
return new NextResponse(html, {
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
// Only our own pages may frame the proxied copy.
'content-security-policy': "frame-ancestors 'self'",
'x-content-type-options': 'nosniff',
'cache-control': 'public, max-age=60',
},
});
}
/**
* Rewrites same-origin link clicks to go back through this proxy, so following
* e.g. `hardware.html` doesn't navigate the frame to the real (unframeable)
* site. Cross-host links, new-tab links, in-page `#anchors` and non-http schemes
* are left alone.
*/
function clickShim(hostname: string): string {
return `<script>(function(){var HOST=${JSON.stringify(
hostname
)};document.addEventListener("click",function(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var t=e.target;var a=t&&t.closest?t.closest("a"):null;if(!a)return;if(a.target&&a.target!=="_self")return;var href=a.getAttribute("href");if(!href||href.charAt(0)==="#")return;var u;try{u=new URL(href,document.baseURI);}catch(_){return;}if(u.protocol!=="http:"&&u.protocol!=="https:")return;if(u.hostname!==HOST&&u.hostname!=="www."+HOST)return;e.preventDefault();window.location.href="/api/frame?url="+encodeURIComponent(u.href);},true);})();</script>`;
}

View file

@ -62,15 +62,18 @@ const SERVICE_SHORTCUTS: Shortcut[] = [
// Friends' sites, opened in a live window when they allow framing. bukovinafilip // Friends' sites, opened in a live window when they allow framing. bukovinafilip
// and vlcak.me send no framing headers, so `url` (== `realUrl`) is framed // and vlcak.me send no framing headers, so `url` (== `realUrl`) is framed
// directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so we // directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so
// frame the same-origin mirror at `/skopec.html` instead; the www button still // `proxy: true` routes it through /api/frame, which strips the framing headers
// opens the live site. To embed a site that blocks framing live, it would need a // and serves the live page from our origin. The www button always opens the live
// header-stripping proxy. // site (`realUrl`).
const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({ const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
id: `friend-${i}`, id: `friend-${i}`,
label: f.name, label: f.name,
url: f.realUrl || f.url, url: f.realUrl || f.url,
embedUrl: f.url, embedUrl:
f.proxy && f.realUrl
? `/api/frame?url=${encodeURIComponent(f.realUrl)}`
: f.url,
embed: f.iframe, embed: f.iframe,
color: "#c0392b", color: "#c0392b",
icon: "🫂", icon: "🫂",

View file

@ -19,11 +19,19 @@ export interface TechProject {
export interface Friend { export interface Friend {
name: string; name: string;
/** What the desktop window frames. A same-origin mirror when the live site blocks framing. */
url: string; url: string;
/** The live site: the window's `www` button and the status check use this. */
realUrl: string; realUrl: string;
img: string; img: string;
desc: string; desc: string;
/** Render in an iframe in a desktop window. */
iframe: boolean; iframe: boolean;
/**
* Load `realUrl` through `/api/frame` (which strips framing headers) instead
* of framing `url`. Use for sites that send a restrictive `X-Frame-Options`.
*/
proxy?: boolean;
} }
export const socials: Social[] = [ export const socials: Social[] = [
@ -233,11 +241,15 @@ export const techProjects: TechProject[] = [
export const friends: Friend[] = [ export const friends: Friend[] = [
{ {
name: "Josef Skopec", name: "Josef Skopec",
// skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so the
// window goes through /api/frame for the live site; /skopec.html is the
// bundled mirror that was used before the proxy existed (kept as a fallback).
url: "/skopec.html", url: "/skopec.html",
realUrl: "https://skopec.dev/", realUrl: "https://skopec.dev/",
img: "/skopec.svg", img: "/skopec.svg",
desc: "skopec.dev", desc: "skopec.dev",
iframe: true, iframe: true,
proxy: true,
}, },
{ {
name: "Filip Bukovina", name: "Filip Bukovina",