File size: 7,368 Bytes
bc4a7e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | /**
* 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 { 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"]`.
* @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 = pathSegments.length > 0 ? "/" + pathSegments.join("/") : "/";
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}` });
}
}
|