bimr-studio/proxy/proxy.mjs
2026-09-21 15:33:30 +02:00

210 lines
7.1 KiB
JavaScript

// SPDX-FileCopyrightText: 2026 Milovann Yanatchkov
// SPDX-License-Identifier: MIT
// proxy/proxy.mjs — a stateless, host-allowlisted CORS proxy for the forge.
//
// A browser can only read a response from a forge on another origin when the
// forge sends CORS headers, and Forgejo never sends them on git routes. This
// proxy makes the forge reachable through Studio's own origin: the target's
// absolute URL is carried in the path, so one scheme serves both the REST API
// and isomorphic-git's `corsProxy`.
//
// GET /forge-proxy/https://forge.example/api/v1/repos/search?q=x
// GET /forge-proxy/https://forge.example/owner/repo.git/info/refs?...
//
// It is runtime-agnostic (Web Fetch API): it runs on Node, Deno, Bun and edge
// runtimes. `proxy/node.mjs` is the Node entry point. No dependencies.
//
// Configuration (env, or a `globalThis` string of the same name):
// FORGE_ALLOWED_HOSTS comma-separated allowlist; exact hosts or `*.suffix`.
// Empty means deny all.
// FORGE_ALLOW_ANY=1 allow any host (development only — never in prod).
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
// Headers never forwarded to the forge: the proxy is the client, so the
// browser's Cookie/Origin/Referer must not travel, and length is recomputed.
const REQUEST_DROP = new Set(["cookie", "host", "origin", "referer", "content-length"]);
// Headers never forwarded to the browser: cookies are dropped, and length /
// encoding are recomputed because the body is streamed.
const RESPONSE_DROP = new Set(["set-cookie", "content-encoding", "content-length"]);
function env(name) {
const fromProcess = globalThis.process?.env?.[name];
if (fromProcess !== undefined) return fromProcess;
const fromGlobal = globalThis[name];
return typeof fromGlobal === "string" ? fromGlobal : undefined;
}
function allowAny() {
return env("FORGE_ALLOW_ANY") === "1";
}
function allowedHosts() {
return (env("FORGE_ALLOWED_HOSTS") ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
function hostAllowed(host) {
if (allowAny()) return true;
const hostname = host.toLowerCase();
for (const pattern of allowedHosts()) {
if (pattern === hostname) return true;
if (pattern.startsWith("*.") && hostname.endsWith(pattern.slice(1))) return true;
}
return false;
}
function isLocalhost(hostname) {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
}
/** The mount prefix, so scheme-less targets can be recognised. */
function proxyPrefix() {
return (env("FORGE_PROXY_PREFIX") ?? "/forge-proxy").replace(/\/+$/, "");
}
/** The absolute target URL carried in the request path, or null. */
function extractTarget(requestUrl) {
const url = new URL(requestUrl);
const rest = `${url.pathname}${url.search}`;
// Form 1: the target carries its scheme — /forge-proxy/https://host/path.
const match = rest.match(/https?:\/\/.+/i) ?? rest.match(/https?:\/{1,2}.+/i);
if (match) {
const normalized = match[0].replace(/^(https?):\/+/, "$1://");
try {
return new URL(normalized);
} catch {
return null;
}
}
// Form 2: scheme-less — isomorphic-git's corsProxy strips "https://" and
// produces /<prefix>/<host>/<path>. Strip the mount prefix and assume https.
const prefix = proxyPrefix();
let rest2 = rest;
if (prefix && (rest2 === prefix || rest2.startsWith(`${prefix}/`))) {
rest2 = rest2.slice(prefix.length);
}
rest2 = rest2.replace(/^\/+/, "");
if (!rest2) return null;
try {
return new URL(`https://${rest2}`);
} catch {
return null;
}
}
/** Why the target is refused, or null when it is allowed. */
function targetError(target) {
const secure = target.protocol === "https:";
const localHttp = target.protocol === "http:" && isLocalhost(target.hostname);
if (!secure && !localHttp) return "only https targets are allowed";
if (!hostAllowed(target.host)) return `host not allowed: ${target.host}`;
return null;
}
function corsHeaders(origin) {
const headers = new Headers();
headers.set("access-control-allow-origin", origin || "*");
headers.set("access-control-expose-headers", "Content-Type, Content-Length, X-Total-Count, Link");
if (origin) headers.append("vary", "Origin");
return headers;
}
function preflight(origin, requestedHeaders) {
const headers = corsHeaders(origin);
headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
headers.set("access-control-allow-headers", requestedHeaders || "Authorization, Content-Type, Accept");
headers.set("access-control-max-age", "600");
return new Response(null, { status: 204, headers });
}
function json(body, status, origin) {
const headers = corsHeaders(origin);
headers.set("content-type", "application/json");
headers.set("cache-control", "no-store");
headers.set("x-forge-proxy", "1");
return new Response(JSON.stringify(body), { status, headers });
}
function forwardRequestHeaders(headers) {
const out = new Headers();
for (const [name, value] of headers) {
const lower = name.toLowerCase();
if (HOP_BY_HOP.has(lower) || REQUEST_DROP.has(lower)) continue;
out.set(name, value);
}
return out;
}
function forwardResponseHeaders(headers, origin) {
const out = corsHeaders(origin);
for (const [name, value] of headers) {
const lower = name.toLowerCase();
if (HOP_BY_HOP.has(lower) || RESPONSE_DROP.has(lower)) continue;
if (lower.startsWith("access-control-")) continue;
out.set(name, value);
}
return out;
}
/** The proxy handler: forward one request to its allowlisted target. */
export async function handleFetch(request) {
const origin = request.headers.get("origin") || "";
const url = new URL(request.url);
const healthPath = `${proxyPrefix()}/__health`;
if (url.pathname === healthPath || url.pathname === "/__health") {
return json({ ok: true, proxy: "studio", allowAny: allowAny() }, 200, origin);
}
if (request.method === "OPTIONS" && request.headers.get("access-control-request-method")) {
return preflight(origin, request.headers.get("access-control-request-headers"));
}
const target = extractTarget(request.url);
if (!target) return json({ error: "no target url in path" }, 400, origin);
const blocked = targetError(target);
if (blocked) return json({ error: blocked }, 403, origin);
const init = {
method: request.method,
headers: forwardRequestHeaders(request.headers),
};
const hasBody = request.method !== "GET" && request.method !== "HEAD" && request.body;
if (hasBody) {
init.body = request.body;
init.duplex = "half";
}
let upstream;
try {
upstream = await globalThis.fetch(target, init);
} catch (err) {
return json({ error: `upstream fetch failed: ${err?.message ?? err}` }, 502, origin);
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: forwardResponseHeaders(upstream.headers, origin),
});
}
export default { fetch: handleFetch };