load skopec.dev live in its window via an allowlisted header-stripping proxy (/api/frame)
This commit is contained in:
parent
d5dfea060f
commit
a4a12bfe2a
5 changed files with 147 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
18
agents.md
18
agents.md
|
|
@ -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
108
src/app/api/frame/route.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
|
@ -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: "🫂",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue