mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
181 lines
7.1 KiB
TypeScript
181 lines
7.1 KiB
TypeScript
/**
|
|
* Generic reverse-proxy helper for embedded service UIs.
|
|
*
|
|
* Forwards HTTP traffic to a locally-running embedded service so its web UI
|
|
* can be iframed inside the OmniRoute dashboard without CORS issues.
|
|
*
|
|
* Security:
|
|
* - Target URL is constructed from the service's registered port — never
|
|
* from user input — eliminating SSRF risk.
|
|
* - Routes that use this helper must be classified LOCAL_ONLY in routeGuard.ts;
|
|
* loopback enforcement blocks all non-loopback access before any handler runs.
|
|
* - Client cookies and Authorization headers are stripped before forwarding
|
|
* to prevent credential leakage between OmniRoute and the embedded service.
|
|
* - Upstream set-cookie, x-frame-options, content-security-policy, and
|
|
* cross-origin-* headers are stripped from responses so the iframe is not
|
|
* broken by the embedded service's own security policies.
|
|
* - HTML responses are rewritten (parse5) so path-absolute URLs work through
|
|
* the proxy prefix.
|
|
*/
|
|
|
|
import { getSupervisor } from "@/lib/services/registry";
|
|
import { getOrCreateApiKey } from "@/lib/services/apiKey";
|
|
import { rewriteHtml } from "@/lib/services/htmlRewriter";
|
|
import { toUpstreamPath } from "@/lib/services/embedPath";
|
|
import { createErrorResponse } from "@/lib/api/errorResponse";
|
|
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
|
|
|
// ─── constants ────────────────────────────────────────────────────────────────
|
|
|
|
/** Standard hop-by-hop headers that must never be forwarded. */
|
|
export const HOP_BY_HOP = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailers",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"host",
|
|
]);
|
|
|
|
/**
|
|
* Request headers stripped before forwarding to the embedded service.
|
|
* Prevents OmniRoute session cookies and Authorization from leaking upstream.
|
|
*/
|
|
export const STRIPPED_REQUEST_HEADERS = new Set(["cookie", "authorization"]);
|
|
|
|
/**
|
|
* Response headers stripped before returning to the browser.
|
|
*
|
|
* - set-cookie: prevents the embedded service from setting cookies in the
|
|
* OmniRoute origin, which would conflict with session management.
|
|
* - content-security-policy / content-security-policy-report-only: the
|
|
* embedded service's CSP is irrelevant inside the OmniRoute iframe.
|
|
* - x-frame-options: would block the iframe entirely if set to DENY/SAMEORIGIN
|
|
* by the embedded service (OmniRoute controls framing via its own CSP).
|
|
* - cross-origin-*: remove COOP/COEP/CORP that could break the framed page.
|
|
*/
|
|
export const STRIPPED_RESPONSE_HEADERS = new Set([
|
|
"set-cookie",
|
|
"content-security-policy",
|
|
"content-security-policy-report-only",
|
|
"x-frame-options",
|
|
"cross-origin-embedder-policy",
|
|
"cross-origin-opener-policy",
|
|
"cross-origin-resource-policy",
|
|
]);
|
|
|
|
export const PROXY_TIMEOUT_MS = 30_000;
|
|
|
|
// ─── public interface ─────────────────────────────────────────────────────────
|
|
|
|
export interface ReverseProxyConfig {
|
|
/** Service name used for supervisor lookup and API key retrieval. */
|
|
name: string;
|
|
/** Proxy prefix path (e.g. "/dashboard/providers/services/9router/embed"). */
|
|
publicPrefix: string;
|
|
/** When true, HTML responses are rewritten via htmlRewriter. Default: true. */
|
|
htmlRewrite?: boolean;
|
|
// Future: stripCookies, injectHeaders, etc.
|
|
}
|
|
|
|
/**
|
|
* Proxy a request through to the locally-running embedded service identified
|
|
* by `config.name`.
|
|
*
|
|
* Resolves the upstream port from the registered supervisor, builds the
|
|
* upstream URL from `pathSegments`, forwards safe headers, injects the
|
|
* service's own API key, and returns the upstream response with
|
|
* security-conflicting headers stripped.
|
|
*
|
|
* @param request The incoming Next.js route Request.
|
|
* @param pathSegments The `[[...path]]` catch-all segments, e.g. `["ui", "index.html"]` or `[]`.
|
|
* @param config Proxy configuration (service name + public prefix).
|
|
*/
|
|
export async function proxyRequest(
|
|
request: Request,
|
|
pathSegments: string[],
|
|
config: ReverseProxyConfig
|
|
): Promise<Response> {
|
|
const { name, publicPrefix, htmlRewrite = true } = config;
|
|
|
|
const supervisor = getSupervisor(name);
|
|
if (!supervisor) {
|
|
return createErrorResponse({ status: 404, message: `Service '${name}' not found.` });
|
|
}
|
|
|
|
const { state, port } = supervisor.getStatus();
|
|
if (state !== "running") {
|
|
return createErrorResponse({
|
|
status: 503,
|
|
message: `Service '${name}' is not running (state: ${state}).`,
|
|
});
|
|
}
|
|
|
|
const incomingUrl = new URL(request.url);
|
|
const upstreamPath = toUpstreamPath(pathSegments);
|
|
const upstreamUrl = `http://127.0.0.1:${port}${upstreamPath}${incomingUrl.search}`;
|
|
|
|
// Build forwarded headers: strip hop-by-hop AND sensitive client headers.
|
|
const forwardHeaders = new Headers();
|
|
for (const [k, v] of request.headers.entries()) {
|
|
const lower = k.toLowerCase();
|
|
if (!HOP_BY_HOP.has(lower) && !STRIPPED_REQUEST_HEADERS.has(lower)) {
|
|
forwardHeaders.set(k, v);
|
|
}
|
|
}
|
|
forwardHeaders.set("host", `127.0.0.1:${port}`);
|
|
|
|
// Inject the embedded service's own API key so upstream can authenticate.
|
|
const apiKey = await getOrCreateApiKey(name);
|
|
forwardHeaders.set("authorization", `Bearer ${apiKey}`);
|
|
|
|
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
|
|
try {
|
|
const upstream = await fetch(upstreamUrl, {
|
|
method: request.method,
|
|
headers: forwardHeaders,
|
|
body: hasBody ? request.body : undefined,
|
|
// @ts-expect-error -- duplex is required by the Fetch spec for streaming
|
|
// request bodies but is not yet in the TS DOM lib (Node.js 18+ supports it).
|
|
duplex: hasBody ? "half" : undefined,
|
|
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
|
|
});
|
|
|
|
// Build response headers: strip hop-by-hop and security-conflicting headers.
|
|
const responseHeaders = new Headers();
|
|
for (const [k, v] of upstream.headers.entries()) {
|
|
const lower = k.toLowerCase();
|
|
if (!HOP_BY_HOP.has(lower) && !STRIPPED_RESPONSE_HEADERS.has(lower)) {
|
|
responseHeaders.set(k, v);
|
|
}
|
|
}
|
|
// Prevent Next.js from caching the proxied response.
|
|
responseHeaders.set("cache-control", "no-store");
|
|
|
|
const contentType = upstream.headers.get("content-type") ?? "";
|
|
|
|
// HTML responses: buffer, rewrite links, return as string.
|
|
if (htmlRewrite && contentType.startsWith("text/html")) {
|
|
const html = await upstream.text();
|
|
const rewritten = rewriteHtml(html, publicPrefix);
|
|
return new Response(rewritten, {
|
|
status: upstream.status,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
// All other content types: stream through unchanged.
|
|
return new Response(upstream.body, {
|
|
status: upstream.status,
|
|
headers: responseHeaders,
|
|
});
|
|
} catch (err) {
|
|
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
|
return createErrorResponse({ status: 502, message: `Proxy error: ${msg}` });
|
|
}
|
|
}
|