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

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
// 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
// frame the same-origin mirror at `/skopec.html` instead; the www button still
// opens the live site. To embed a site that blocks framing live, it would need a
// header-stripping proxy.
// directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so
// `proxy: true` routes it through /api/frame, which strips the framing headers
// and serves the live page from our origin. The www button always opens the live
// site (`realUrl`).
const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
id: `friend-${i}`,
label: f.name,
url: f.realUrl || f.url,
embedUrl: f.url,
embedUrl:
f.proxy && f.realUrl
? `/api/frame?url=${encodeURIComponent(f.realUrl)}`
: f.url,
embed: f.iframe,
color: "#c0392b",
icon: "🫂",

View file

@ -19,11 +19,19 @@ export interface TechProject {
export interface Friend {
name: string;
/** What the desktop window frames. A same-origin mirror when the live site blocks framing. */
url: string;
/** The live site: the window's `www` button and the status check use this. */
realUrl: string;
img: string;
desc: string;
/** Render in an iframe in a desktop window. */
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[] = [
@ -233,11 +241,15 @@ export const techProjects: TechProject[] = [
export const friends: Friend[] = [
{
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",
realUrl: "https://skopec.dev/",
img: "/skopec.svg",
desc: "skopec.dev",
iframe: true,
proxy: true,
},
{
name: "Filip Bukovina",