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
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
|
||||
// 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: "🫂",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue