diff --git a/apps/runtime/README.md b/apps/runtime/README.md index 3712ce0..e153b37 100644 --- a/apps/runtime/README.md +++ b/apps/runtime/README.md @@ -126,12 +126,30 @@ Provide a `Slots` object in `redirects.json` to define routing rules. The table | `appendPath` | boolean | `true` | Whether to append the remaining path when using `prefix` or `proxy` mode. Not applicable to `exact`. | | `status` | number | `302` | HTTP status code from 200 through 599 for non-proxy responses. Do not set for `proxy`. | | `priority` | number | by order | Determines rule precedence for the same path. Smaller numbers are matched first. | +| `proxyPolicy` | object | legacy compatibility | Explicit request, response, redirect, cache, and resource-limit policy. Only valid for `proxy`. | - Keys must start with `/` and can use colon parameters such as `:id` or the `*` wildcard. Captures can be referenced in the target with `$1`, `:id`, and so on. - When multiple path patterns match, literal segments take precedence over colon parameters, parameters take precedence over `*`, and deeper patterns win when shared segments have equal specificity. - The `proxy` type forwards the request to the destination and returns the upstream response. Other types respond with a `Location` redirect. - To configure multiple rules for the same path, provide an array. Array order controls the default priority, or you can specify `priority` explicitly. +New proxy rules created by WebUI use an explicit `isolated` policy. Existing rules without +`proxyPolicy` keep the previous forwarding behavior for compatibility until you assign a profile: + +| Profile | Use case | Default credential handling | Default cache | +|---------|----------|-----------------------------|---------------| +| `isolated` | External or untrusted targets | Strips cookies, authorization, Origin, Referer, and client IP metadata | `bypass` | +| `asset` | Public static assets | Strips cookies, authorization, and source metadata | Public cache headers | +| `trusted-api` | APIs you operate | Credentials remain stripped unless explicitly allowed | `bypass` | + +All explicit profiles preserve upstream CSP and frame protections, check every followed redirect +against the initial or configured allowed origins, and process each `Set-Cookie` header +independently. `cache.mode: "public"` emits portable HTTP cache headers; provider-specific cache +APIs are not used. +Public caching cannot be combined with forwarded request cookies or authorization. Configuration +validation rejects that combination, and the Runtime still forces `private, no-store` if an +unvalidated policy reaches the proxy. + Add the schema reference below to unlock autocomplete and validation in supporting editors. The schema lives on `main`, so it still applies if the JSON sits in a data branch: ```jsonc @@ -179,18 +197,45 @@ Add the schema reference below to unlock autocomplete and validation in supporti "type": "proxy", "target": "https://api.example.com", "appendPath": true, + "proxyPolicy": { + "profile": "trusted-api", + "request": { + "methods": ["GET", "POST"], + "cookies": { + "mode": "allowlist", + "names": ["session"] + } + }, + "response": { + "cookies": { + "mode": "allowlist", + "names": ["session"], + "domain": "remove", + "path": "proxy-base" + } + }, + "cache": { + "mode": "bypass" + } + }, "priority": 10 }, { "type": "proxy", "target": "https://backup-api.example.com", "appendPath": true, + "proxyPolicy": { + "profile": "isolated" + }, "priority": 20 } ], "/media/*": { "type": "proxy", - "target": "https://cdn.example.com/$1" + "target": "https://cdn.example.com/$1", + "proxyPolicy": { + "profile": "asset" + } }, "/admin": { "type": "prefix", diff --git a/apps/runtime/README.zh-CN.md b/apps/runtime/README.zh-CN.md index 6b422bc..42e7477 100644 --- a/apps/runtime/README.zh-CN.md +++ b/apps/runtime/README.zh-CN.md @@ -126,12 +126,24 @@ pnpm runtime:build:nf | `appendPath` | boolean | `true` | `prefix` 或 `proxy` 模式下是否拼接剩余路径,`exact` 不适用。 | | `status` | number | `302` | 非 `proxy` 响应使用 200 到 599 的状态码,`proxy` 不要设置。 | | `priority` | number | 按顺序 | 同一路径存在多条规则时用于排序,数字越小越先匹配。 | +| `proxyPolicy` | object | 旧版兼容行为 | 显式控制请求、响应、跳转、缓存和资源限制,仅适用于 `proxy`。 | - 键名需要以 `/` 开头,可以使用冒号参数,例如 `:id`,也可以使用 `*` 通配符。匹配结果可以在目标地址中用 `$1`、`:id` 等占位符引用。 - 多个路径模式同时匹配时,字面量片段优先于冒号参数,参数优先于 `*`;共享片段特异性相同时,层级更深的模式优先。 - `proxy` 类型会把请求转发到目标地址并返回上游响应,其他类型返回 `Location` 重定向。 - 如果需要为同一路径配置多条规则,可以把值写成数组。数组顺序决定默认优先级,也可以通过 `priority` 显式指定。 +WebUI 新建的代理规则会使用显式 `isolated` 策略。没有 `proxyPolicy` 的现有规则会继续沿用旧版转发行为,直到你为其选择预设: + +| 预设 | 适用场景 | 默认凭据处理 | 默认缓存 | +|------|----------|--------------|----------| +| `isolated` | 外部或不受信任的目标 | 移除 Cookie、Authorization、Origin、Referer 和客户端 IP 元数据 | `bypass` | +| `asset` | 公开静态资源 | 移除 Cookie、Authorization 和来源元数据 | 公开缓存头 | +| `trusted-api` | 自己运营的 API | 默认仍移除凭据,必须显式放行 | `bypass` | + +所有显式预设都会保留上游 CSP 与防嵌入响应头;每次跟随跳转时都会检查初始来源或配置的允许来源;每个 `Set-Cookie` 也会单独处理。`cache.mode: "public"` 只输出跨平台的 HTTP 缓存头,不调用平台专属缓存 API。 +公共缓存不能与放行的请求 Cookie 或 Authorization 同时使用。配置校验会拒绝这种组合;即使未经校验的策略进入代理,Runtime 也会强制改为 `private, no-store`。 + 在文件顶部添加下面的 schema 引用,可以在支持的编辑器里获得自动补全和校验。Schema 放在 `main` 分支,即使 `redirects.json` 在 `data` 分支也能生效: ```jsonc @@ -179,18 +191,45 @@ pnpm runtime:build:nf "type": "proxy", "target": "https://api.example.com", "appendPath": true, + "proxyPolicy": { + "profile": "trusted-api", + "request": { + "methods": ["GET", "POST"], + "cookies": { + "mode": "allowlist", + "names": ["session"] + } + }, + "response": { + "cookies": { + "mode": "allowlist", + "names": ["session"], + "domain": "remove", + "path": "proxy-base" + } + }, + "cache": { + "mode": "bypass" + } + }, "priority": 10 }, { "type": "proxy", "target": "https://backup-api.example.com", "appendPath": true, + "proxyPolicy": { + "profile": "isolated" + }, "priority": 20 } ], "/media/*": { "type": "proxy", - "target": "https://cdn.example.com/$1" + "target": "https://cdn.example.com/$1", + "proxyPolicy": { + "profile": "asset" + } }, "/admin": { "type": "prefix", diff --git a/apps/runtime/src/lib/handlers/analytics/events.ts b/apps/runtime/src/lib/handlers/analytics/events.ts index 440e7b1..e3581f7 100644 --- a/apps/runtime/src/lib/handlers/analytics/events.ts +++ b/apps/runtime/src/lib/handlers/analytics/events.ts @@ -14,6 +14,7 @@ import { normalizeAnalyticsHostname } from "./attribution"; import type { VerifiedAttributionToken } from "./attribution"; +import { DEFAULT_STATUS } from "../core/constants"; import type { AnalyticsBotCategory, AnalyticsBotConfidence, @@ -149,7 +150,7 @@ export async function createMatchedAnalyticsEvent( eventKind: "link", analyticsId, routePath: input.routePath, - linkType: input.rule.type === "proxy" ? "proxy" : "redirect", + linkType: input.rule.action.type, matchKind: input.matchKind, matchOutcome: "matched", ...resolveReferrerField(input.request), @@ -288,10 +289,10 @@ async function resolveAnalyticsId(path: string, rule: NormalizedRule): Promise): Com } const values = toRouteArray(rawValue); + const compiled = compilePattern(base); let fallbackPriority = 0; for (const entry of values) { fallbackPriority += 1; - const rule = normaliseRule(entry, fallbackPriority); + const rule = normaliseRule(entry, fallbackPriority, compiled.isParam); if (!rule) { continue; } - const compiled = compilePattern(base); list.push({ base, rule, ...compiled, order: sequence }); sequence += 1; } @@ -131,15 +131,29 @@ export function getCompiledList(source: SlotBranch): CompiledEntry[] { return compiled; } -function normaliseRule(value: RouteValue, fallbackPriority: number): NormalizedRule | null { +function normaliseRule( + value: RouteValue, + fallbackPriority: number, + isParam: boolean +): NormalizedRule | null { if (typeof value === "string") { return value - ? { type: "prefix", target: value, appendPath: true, status: DEFAULT_STATUS, priority: fallbackPriority } + ? { + match: { type: isParam ? "pattern" : "prefix" }, + action: { + type: "redirect", + target: value, + appendPath: true, + status: DEFAULT_STATUS + }, + priority: fallbackPriority, + sourceType: "prefix" + } : null; } if (value && typeof value === "object") { - const type: RouteType = value.type === "exact" ? "exact" : value.type === "proxy" ? "proxy" : "prefix"; + const sourceType: RouteType = value.type === "exact" ? "exact" : value.type === "proxy" ? "proxy" : "prefix"; const targetValue = value.target ?? value.to ?? value.url; if (typeof targetValue !== "string" || !targetValue) { return null; @@ -156,7 +170,31 @@ function normaliseRule(value: RouteValue, fallbackPriority: number): NormalizedR ? value.analyticsId.trim() : undefined; - return { analyticsId, type, target: targetValue, appendPath, status, priority }; + return { + analyticsId, + match: { + type: isParam + ? "pattern" + : sourceType === "exact" + ? "exact" + : "prefix" + }, + action: sourceType === "proxy" + ? { + type: "proxy", + target: targetValue, + appendPath, + policy: value.proxyPolicy + } + : { + type: "redirect", + target: targetValue, + appendPath, + status + }, + priority, + sourceType + }; } return null; @@ -209,18 +247,18 @@ export function applyTemplate(target: string, match: RegExpMatchArray, names: st } export function resolvePrefixTarget(pathname: string, search: string, rule: NormalizedRule, base: string): string | null { - const targetBase = String(rule.target); + const targetBase = rule.action.target; if (base === "/") { const rest = pathname === "/" ? "" : pathname; - const resolved = rule.appendPath ? appendTargetPath(targetBase, rest) : targetBase; + const resolved = rule.action.appendPath ? appendTargetPath(targetBase, rest) : targetBase; return appendOriginalQuery(resolved, search); } if (pathname === base || pathname.startsWith(`${base}/`)) { let rest = pathname.slice(base.length); rest = rest.startsWith("/") ? rest : rest ? `/${rest}` : ""; - const resolved = rule.appendPath ? appendTargetPath(targetBase, rest) : targetBase; + const resolved = rule.action.appendPath ? appendTargetPath(targetBase, rest) : targetBase; return appendOriginalQuery(resolved, search); } @@ -265,9 +303,9 @@ export function resolveCompiledTarget( let targetUrl: string | null = null; if (match) { - const resolved = applyTemplate(entry.rule.target, match, entry.names); + const resolved = applyTemplate(entry.rule.action.target, match, entry.names); targetUrl = appendOriginalQuery(resolved, search); - } else if ((entry.rule.type === "prefix" || entry.rule.type === "proxy") && !entry.isParam) { + } else if (entry.rule.match.type === "prefix") { targetUrl = resolvePrefixTarget(pathname, search, entry.rule, entry.base); } @@ -296,12 +334,12 @@ export function collectProxyRaceCandidates( const candidates: Array<{ base: string; matchKind: AnalyticsLinkMatchKind; rule: NormalizedRule; targetUrl: string }> = []; const maybeAdd = (entry: CompiledEntry): void => { - if (!entry.rule.target) { + if (!entry.rule.action.target) { return; } const resolved = resolveCompiledTarget(entry, pathname, search); - if (resolved && entry.rule.type === "proxy") { + if (resolved && entry.rule.action.type === "proxy") { candidates.push({ base: entry.base, matchKind: resolved.matchKind, @@ -330,12 +368,12 @@ export function collectProxyRaceCandidates( function resolveMatchKind(entry: CompiledEntry): AnalyticsLinkMatchKind { if ( entry.base.split("/").includes("*") || - (entry.base === "/" && (entry.rule.type === "prefix" || entry.rule.type === "proxy")) + (entry.base === "/" && entry.rule.match.type === "prefix") ) { return "catch_all"; } if (entry.isParam) { return "parameterized"; } - return entry.rule.type === "exact" ? "exact" : "prefix"; + return entry.rule.match.type === "exact" ? "exact" : "prefix"; } diff --git a/apps/runtime/src/lib/handlers/routing/proxy/headers.ts b/apps/runtime/src/lib/handlers/routing/proxy/headers.ts new file mode 100644 index 0000000..ec7b483 --- /dev/null +++ b/apps/runtime/src/lib/handlers/routing/proxy/headers.ts @@ -0,0 +1,362 @@ +/** + * @file headers.ts + * @description + * [EN] Proxy header and cookie policy. + * Builds sanitized upstream request headers and applies response cookie, security-header, and + * cache decisions without folding multiple Set-Cookie fields. + * + * [CN] 反向代理请求头与 Cookie 策略。 + * 构建净化后的上游请求头,并在不折叠多个 Set-Cookie 字段的前提下应用响应 Cookie、 + * 安全响应头与缓存决策。 + * + * @see {@link https://github.com/Revaea/i0c.cc} for repository info. + */ + +import { HSTS_HEADER_VALUE } from "../../core/constants"; + +import type { ResolvedProxyPolicy } from "./policy"; + +const SENSITIVE_FORWARD_HEADERS = [ + "cookie", + "authorization", + "client-ip", + "fastly-client-ip", + "fly-client-ip", + "forwarded", + "forwarded-for", + "proxy-authorization", + "true-client-ip", + "x-client-ip", + "x-cluster-client-ip", + "x-envoy-external-address", + "x-real-ip" +] as const; +const SENSITIVE_FORWARD_HEADER_PREFIXES = [ + "cf-", + "x-forwarded-", + "x-nf-", + "x-vercel-" +] as const; +const HOP_BY_HOP_HEADERS = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade" +] as const; +const REQUEST_BODY_HEADERS = [ + "content-encoding", + "content-language", + "content-length", + "content-location", + "content-type" +] as const; +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i; + +interface ExtendedHeaders { + getAll?(name: string): string[]; + getSetCookie?(): string[]; +} + +interface BuildProxyRequestHeadersOptions { + request: Request; + originalUrl: URL; + currentTarget: URL; + initialTarget: URL; + policy: ResolvedProxyPolicy; + shouldDropBodyHeaders: boolean; +} + +interface ApplyProxyResponseHeadersOptions { + sourceHeaders: Headers; + policy: ResolvedProxyPolicy; + basePath: string; + requestMethod: string; + responseStatus: number; + upstreamStatus: number; + upstreamLocation: string | null; + redirectsFollowed: number; +} + +function filterCookieHeader( + value: string | null, + allowedNames: readonly string[] +): string | null { + if (!value || allowedNames.length === 0) { + return null; + } + const allowed = new Set(allowedNames); + const cookies = value + .split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => { + const separator = cookie.indexOf("="); + const name = separator >= 0 ? cookie.slice(0, separator).trim() : cookie; + return allowed.has(name); + }); + return cookies.length > 0 ? cookies.join("; ") : null; +} + +function applySourceHeader( + headers: Headers, + name: "origin" | "referer", + mode: "strip" | "preserve" | "target", + originalValue: string | null, + currentTarget: URL +): void { + headers.delete(name); + if (mode === "preserve" && originalValue) { + headers.set(name, originalValue); + } else if (mode === "target") { + headers.set( + name, + name === "origin" ? currentTarget.origin : currentTarget.toString() + ); + } +} + +export function buildProxyRequestHeaders({ + request, + originalUrl, + currentTarget, + initialTarget, + policy, + shouldDropBodyHeaders +}: BuildProxyRequestHeadersOptions): Headers { + const headers = new Headers(request.headers); + const originalAuthorization = headers.get("authorization"); + const originalCookie = headers.get("cookie"); + const originalOrigin = headers.get("origin"); + const originalReferer = headers.get("referer") ?? headers.get("referrer"); + const connectionHeaders = headers.get("connection") + ?.split(",") + .map((name) => name.trim().toLowerCase()) + .filter((name) => HEADER_NAME_PATTERN.test(name)) ?? []; + + headers.delete("host"); + for (const name of SENSITIVE_FORWARD_HEADERS) { + headers.delete(name); + } + for (const name of HOP_BY_HOP_HEADERS) { + headers.delete(name); + } + for (const name of connectionHeaders) { + headers.delete(name); + } + for (const name of [...headers.keys()]) { + if (SENSITIVE_FORWARD_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))) { + headers.delete(name); + } + } + if (shouldDropBodyHeaders) { + for (const name of REQUEST_BODY_HEADERS) { + headers.delete(name); + } + } + + const canForwardCredentials = currentTarget.origin === initialTarget.origin; + if ( + canForwardCredentials + && policy.request.authorization === "preserve" + && originalAuthorization + ) { + headers.set("authorization", originalAuthorization); + } + if (canForwardCredentials && policy.request.cookies.mode === "allowlist") { + const cookie = filterCookieHeader( + originalCookie, + policy.request.cookies.names + ); + if (cookie) { + headers.set("cookie", cookie); + } + } + + applySourceHeader( + headers, + "origin", + policy.request.origin, + originalOrigin, + currentTarget + ); + applySourceHeader( + headers, + "referer", + policy.request.referer, + originalReferer, + currentTarget + ); + + headers.set("x-forwarded-host", originalUrl.host); + headers.set("x-forwarded-proto", originalUrl.protocol.slice(0, -1)); + return headers; +} + +export function getSetCookieValues(headers: Headers): readonly string[] { + const extended = headers as unknown as ExtendedHeaders; + if (typeof extended.getSetCookie === "function") { + return extended.getSetCookie(); + } + if (typeof extended.getAll === "function") { + return extended.getAll("Set-Cookie"); + } + const value = headers.get("set-cookie"); + return value ? [value] : []; +} + +function getSetCookieName(value: string): string { + const separator = value.indexOf("="); + return separator >= 0 ? value.slice(0, separator).trim() : value.trim(); +} + +function rewriteCookieAttribute( + value: string, + attribute: "domain" | "path", + replacement: string | null +): string { + const pattern = new RegExp(`;\\s*${attribute}=[^;]*`, "ig"); + const stripped = value.replace(pattern, ""); + return replacement === null + ? stripped + : `${stripped}; ${attribute[0].toUpperCase()}${attribute.slice(1)}=${replacement}`; +} + +function normalizeProxyBasePath(basePath: string): string { + if (!basePath || basePath === "/") { + return "/"; + } + const normalized = basePath.startsWith("/") ? basePath : `/${basePath}`; + return normalized.replace( + /[\u0000-\u0020;\u007f]/gu, + (character) => encodeURIComponent(character) + ); +} + +function applyResponseCookies( + responseHeaders: Headers, + sourceHeaders: Headers, + policy: ResolvedProxyPolicy, + basePath: string +): void { + const sourceCookies = getSetCookieValues(sourceHeaders); + responseHeaders.delete("set-cookie"); + if (policy.response.cookies.mode === "strip") { + return; + } + + const allowedNames = new Set(policy.response.cookies.names); + for (const sourceCookie of sourceCookies) { + if ( + allowedNames.size > 0 + && !allowedNames.has(getSetCookieName(sourceCookie)) + ) { + continue; + } + + let cookie = sourceCookie; + if (policy.response.cookies.domain === "remove") { + cookie = rewriteCookieAttribute(cookie, "domain", null); + } + if (policy.response.cookies.path === "remove") { + cookie = rewriteCookieAttribute(cookie, "path", null); + } else if (policy.response.cookies.path === "proxy-base") { + cookie = rewriteCookieAttribute( + cookie, + "path", + normalizeProxyBasePath(basePath) + ); + } + responseHeaders.append("set-cookie", cookie); + } +} + +function applyCachePolicy( + responseHeaders: Headers, + policy: ResolvedProxyPolicy, + requestMethod: string, + responseStatus: number +): void { + if (policy.cache.mode === "upstream") { + return; + } + if (policy.cache.mode === "bypass") { + responseHeaders.set("cache-control", "private, no-store"); + return; + } + + const forwardsCredentials = ( + policy.request.authorization === "preserve" + || policy.request.cookies.mode === "allowlist" + ); + const upstreamCacheControl = responseHeaders.get("cache-control") ?? ""; + const hasPrivateDirective = /(?:^|,)\s*(?:no-store|private)(?=\s*(?:=|,|$))/i.test( + upstreamCacheControl + ); + if ( + forwardsCredentials + || (requestMethod !== "GET" && requestMethod !== "HEAD") + || responseStatus < 200 + || responseStatus >= 300 + || responseHeaders.has("set-cookie") + || hasPrivateDirective + ) { + responseHeaders.set("cache-control", "private, no-store"); + return; + } + + responseHeaders.set( + "cache-control", + `public, max-age=${policy.cache.browserTtlSeconds}, s-maxage=${policy.cache.edgeTtlSeconds}` + ); +} + +export function applyProxyResponseHeaders({ + sourceHeaders, + policy, + basePath, + requestMethod, + responseStatus, + upstreamStatus, + upstreamLocation, + redirectsFollowed +}: ApplyProxyResponseHeadersOptions): Headers { + const responseHeaders = new Headers(sourceHeaders); + const connectionHeaders = responseHeaders.get("connection") + ?.split(",") + .map((name) => name.trim().toLowerCase()) + .filter((name) => HEADER_NAME_PATTERN.test(name)) ?? []; + for (const name of HOP_BY_HOP_HEADERS) { + responseHeaders.delete(name); + } + for (const name of connectionHeaders) { + responseHeaders.delete(name); + } + + if (policy.exposeDebugHeaders) { + responseHeaders.set("x-upstream-status", String(upstreamStatus)); + responseHeaders.set("x-upstream-location", upstreamLocation ?? ""); + responseHeaders.set( + "x-proxy-redirects-followed", + String(redirectsFollowed) + ); + } else { + responseHeaders.delete("x-upstream-status"); + responseHeaders.delete("x-upstream-location"); + responseHeaders.delete("x-proxy-redirects-followed"); + } + + if (policy.response.securityHeaders === "strip") { + responseHeaders.delete("content-security-policy"); + responseHeaders.delete("content-security-policy-report-only"); + responseHeaders.delete("x-frame-options"); + } + responseHeaders.set("strict-transport-security", HSTS_HEADER_VALUE); + + applyResponseCookies(responseHeaders, sourceHeaders, policy, basePath); + applyCachePolicy(responseHeaders, policy, requestMethod, responseStatus); + return responseHeaders; +} diff --git a/apps/runtime/src/lib/handlers/routing/proxy/policy.ts b/apps/runtime/src/lib/handlers/routing/proxy/policy.ts new file mode 100644 index 0000000..6ec66a2 --- /dev/null +++ b/apps/runtime/src/lib/handlers/routing/proxy/policy.ts @@ -0,0 +1,302 @@ +/** + * @file policy.ts + * @description + * [EN] Proxy policy resolution. + * Resolves explicit proxy profiles into immutable runtime decisions while preserving a dedicated + * compatibility policy for legacy rules that do not declare proxyPolicy. + * + * [CN] 反向代理策略解析。 + * 将显式代理预设解析为不可变的运行时决策,同时为未声明 proxyPolicy 的旧规则保留独立兼容策略。 + * + * @see {@link https://github.com/Revaea/i0c.cc} for repository info. + */ + +import type { + ProxyCacheMode, + ProxyCookieMode, + ProxyCredentialMode, + ProxyPolicy, + ProxyProfile, + ProxyRedirectMode, + ProxyResponseCookieAttributeMode, + ProxyResponseCookiePathMode, + ProxySecurityHeadersMode, + ProxySourceHeaderMode +} from "@i0c/config"; + +interface ResolvedProxyCookiePolicy { + mode: ProxyCookieMode; + names: readonly string[]; +} + +interface ResolvedProxyRequestPolicy { + methods: readonly string[] | null; + cookies: ResolvedProxyCookiePolicy; + authorization: ProxyCredentialMode; + origin: ProxySourceHeaderMode; + referer: ProxySourceHeaderMode; + clientIp: "strip"; +} + +interface ResolvedProxyResponseCookiePolicy extends ResolvedProxyCookiePolicy { + domain: ProxyResponseCookieAttributeMode; + path: ProxyResponseCookiePathMode; +} + +interface ResolvedProxyResponsePolicy { + cookies: ResolvedProxyResponseCookiePolicy; + securityHeaders: ProxySecurityHeadersMode | "strip"; +} + +interface ResolvedProxyCachePolicy { + mode: ProxyCacheMode | "upstream"; + edgeTtlSeconds: number; + browserTtlSeconds: number; +} + +interface ResolvedProxyRedirectPolicy { + mode: ProxyRedirectMode; + maxHops: number; + allowedOrigins: readonly string[] | null; +} + +interface ResolvedProxyLimitPolicy { + timeoutMs: number | null; + maxRequestBodyBytes: number | null; +} + +export interface ResolvedProxyPolicy { + profile: ProxyProfile | "legacy"; + isLegacy: boolean; + request: ResolvedProxyRequestPolicy; + response: ResolvedProxyResponsePolicy; + cache: ResolvedProxyCachePolicy; + redirects: ResolvedProxyRedirectPolicy; + limits: ResolvedProxyLimitPolicy; + exposeDebugHeaders: boolean; + rewriteHtml: boolean; +} + +const SAFE_METHODS = ["GET", "HEAD", "OPTIONS"] as const; +const API_METHODS = [ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE" +] as const; + +const profileDefaults: Record = { + isolated: { + profile: "isolated", + isLegacy: false, + request: { + methods: SAFE_METHODS, + cookies: { mode: "strip", names: [] }, + authorization: "strip", + origin: "strip", + referer: "strip", + clientIp: "strip" + }, + response: { + cookies: { + mode: "strip", + names: [], + domain: "remove", + path: "proxy-base" + }, + securityHeaders: "preserve" + }, + cache: { + mode: "bypass", + edgeTtlSeconds: 0, + browserTtlSeconds: 0 + }, + redirects: { + mode: "follow", + maxHops: 5, + allowedOrigins: [] + }, + limits: { + timeoutMs: 10_000, + maxRequestBodyBytes: 1_048_576 + }, + exposeDebugHeaders: false, + rewriteHtml: false + }, + asset: { + profile: "asset", + isLegacy: false, + request: { + methods: SAFE_METHODS, + cookies: { mode: "strip", names: [] }, + authorization: "strip", + origin: "strip", + referer: "strip", + clientIp: "strip" + }, + response: { + cookies: { + mode: "strip", + names: [], + domain: "remove", + path: "remove" + }, + securityHeaders: "preserve" + }, + cache: { + mode: "public", + edgeTtlSeconds: 86_400, + browserTtlSeconds: 3_600 + }, + redirects: { + mode: "follow", + maxHops: 5, + allowedOrigins: [] + }, + limits: { + timeoutMs: 10_000, + maxRequestBodyBytes: 0 + }, + exposeDebugHeaders: false, + rewriteHtml: false + }, + "trusted-api": { + profile: "trusted-api", + isLegacy: false, + request: { + methods: API_METHODS, + cookies: { mode: "strip", names: [] }, + authorization: "strip", + origin: "preserve", + referer: "strip", + clientIp: "strip" + }, + response: { + cookies: { + mode: "strip", + names: [], + domain: "remove", + path: "proxy-base" + }, + securityHeaders: "preserve" + }, + cache: { + mode: "bypass", + edgeTtlSeconds: 0, + browserTtlSeconds: 0 + }, + redirects: { + mode: "follow", + maxHops: 5, + allowedOrigins: [] + }, + limits: { + timeoutMs: 10_000, + maxRequestBodyBytes: 1_048_576 + }, + exposeDebugHeaders: false, + rewriteHtml: false + } +}; + +const legacyPolicy: ResolvedProxyPolicy = { + profile: "legacy", + isLegacy: true, + request: { + methods: null, + cookies: { mode: "strip", names: [] }, + authorization: "strip", + origin: "target", + referer: "target", + clientIp: "strip" + }, + response: { + cookies: { + mode: "allowlist", + names: [], + domain: "remove", + path: "preserve" + }, + securityHeaders: "strip" + }, + cache: { + mode: "upstream", + edgeTtlSeconds: 0, + browserTtlSeconds: 0 + }, + redirects: { + mode: "follow", + maxHops: 5, + allowedOrigins: null + }, + limits: { + timeoutMs: null, + maxRequestBodyBytes: null + }, + exposeDebugHeaders: true, + rewriteHtml: true +}; + +function unique(values: readonly string[]): readonly string[] { + return [...new Set(values)]; +} + +function normalizeOrigins(values: readonly string[]): readonly string[] { + return unique(values.map((value) => new URL(value).origin)); +} + +export function resolveProxyPolicy(policy: ProxyPolicy | undefined): ResolvedProxyPolicy { + if (!policy) { + return legacyPolicy; + } + + const defaults = profileDefaults[policy.profile]; + const requestCookies = policy.request?.cookies; + const responseCookies = policy.response?.cookies; + + return { + ...defaults, + request: { + ...defaults.request, + ...policy.request, + methods: policy.request?.methods + ? unique(policy.request.methods.map((method) => method.toUpperCase())) + : defaults.request.methods, + cookies: requestCookies + ? { + mode: requestCookies.mode, + names: unique(requestCookies.names ?? []) + } + : defaults.request.cookies + }, + response: { + ...defaults.response, + ...policy.response, + cookies: responseCookies + ? { + ...defaults.response.cookies, + ...responseCookies, + names: unique(responseCookies.names ?? []) + } + : defaults.response.cookies + }, + cache: { + ...defaults.cache, + ...policy.cache + }, + redirects: { + ...defaults.redirects, + ...policy.redirects, + allowedOrigins: policy.redirects?.allowedOrigins + ? normalizeOrigins(policy.redirects.allowedOrigins) + : defaults.redirects.allowedOrigins + }, + limits: { + ...defaults.limits, + ...policy.limits + } + }; +} diff --git a/apps/runtime/src/lib/handlers/routing/proxy/request.ts b/apps/runtime/src/lib/handlers/routing/proxy/request.ts new file mode 100644 index 0000000..591ee96 --- /dev/null +++ b/apps/runtime/src/lib/handlers/routing/proxy/request.ts @@ -0,0 +1,383 @@ +/** + * @file request.ts + * @description + * [EN] Proxy request execution. + * Enforces resolved policy decisions across upstream requests, redirect hops, response headers, + * body limits, timeouts, and legacy HTML path rewriting. + * + * [CN] 反向代理请求执行。 + * 在上游请求、跳转链、响应头、请求体限制、超时和旧版 HTML 路径改写中执行已解析的策略决策。 + * + * @see {@link https://github.com/Revaea/i0c.cc} for repository info. + */ + +import type { ResolvedRuntime } from "../../core/types"; + +import { + applyProxyResponseHeaders, + buildProxyRequestHeaders +} from "./headers"; +import type { ResolvedProxyPolicy } from "./policy"; +import { + assertSafeProxyUrl, + isAllowedRedirectOrigin +} from "./safety"; + +interface ProxyAbortContext { + cleanup(): void; + didTimeout(): boolean; + signal: AbortSignal; +} + +async function discardResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + } +} + +function shouldSwitchRedirectToGet(status: number, method: string): boolean { + return ( + ((status === 301 || status === 302) && method === "POST") + || (status === 303 && method !== "GET" && method !== "HEAD") + ); +} + +function prependProxyBasePath(pathname: string, basePath: string): string { + if (!basePath || basePath === "/") { + return pathname; + } + + const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + return pathname === "/" ? `${prefix}/` : `${prefix}${pathname}`; +} + +function createProxyAbortContext( + parentSignal: AbortSignal, + timeoutMs: number | null +): ProxyAbortContext { + if (timeoutMs === null) { + return { + signal: parentSignal, + didTimeout: () => false, + cleanup: () => undefined + }; + } + + const controller = new AbortController(); + let timedOut = false; + const abortFromParent = () => controller.abort(parentSignal.reason); + if (parentSignal.aborted) { + abortFromParent(); + } else { + parentSignal.addEventListener("abort", abortFromParent, { once: true }); + } + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new Error("Proxy request timed out")); + }, timeoutMs); + + return { + signal: controller.signal, + didTimeout: () => timedOut, + cleanup: () => { + clearTimeout(timer); + parentSignal.removeEventListener("abort", abortFromParent); + } + }; +} + +async function readRequestBody( + request: Request, + maximumBytes: number | null +): Promise { + const method = request.method.toUpperCase(); + if (method === "GET" || method === "HEAD" || !request.body) { + return undefined; + } + + const contentLengthHeader = request.headers.get("content-length"); + const contentLength = contentLengthHeader === null + ? null + : Number(contentLengthHeader); + if ( + maximumBytes !== null + && contentLength !== null + && Number.isFinite(contentLength) + && contentLength > maximumBytes + ) { + return new Response("Payload Too Large", { status: 413 }); + } + + if (maximumBytes === null) { + return request.arrayBuffer(); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + totalBytes += value.byteLength; + if (totalBytes > maximumBytes) { + await reader.cancel(); + return new Response("Payload Too Large", { status: 413 }); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body.buffer; +} + +function rewriteResponseLocation( + responseHeaders: Headers, + originalUrl: URL, + initialTarget: URL, + currentTarget: string, + basePath: string +): void { + const location = responseHeaders.get("location"); + if (!location) { + return; + } + + let finalLocation = location; + try { + const locationUrl = new URL(location, currentTarget); + if (locationUrl.origin === initialTarget.origin && originalUrl.host) { + const rewrittenUrl = new URL(originalUrl.origin); + rewrittenUrl.pathname = prependProxyBasePath( + locationUrl.pathname, + basePath + ); + rewrittenUrl.search = locationUrl.search; + rewrittenUrl.hash = locationUrl.hash; + const rewritten = rewrittenUrl.toString(); + finalLocation = rewritten !== originalUrl.href + ? rewritten + : locationUrl.toString(); + } else { + finalLocation = locationUrl.toString(); + } + } catch { + } + + responseHeaders.set("location", finalLocation); +} + +function rewriteHtmlPaths(html: string, basePath: string): string { + return html + .replace( + /(href|src|action)="\/((?!\/|#|\.\/|\.\.\/)[^"]*)"/g, + (_, attribute: string, pathPart: string) => { + return `${attribute}="${basePath}/${pathPart}"`; + } + ) + .replace(//gi, ``); +} + +export async function proxyRequest( + request: Request, + targetUrl: string, + runtime: ResolvedRuntime, + policy: ResolvedProxyPolicy, + basePath: string = "", + parentSignal: AbortSignal = request.signal +): Promise { + const originalMethod = request.method.toUpperCase(); + if ( + policy.request.methods + && !policy.request.methods.includes(originalMethod) + ) { + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: policy.request.methods.join(", ") } + }); + } + + const originalUrl = new URL(request.url); + const initialTarget = new URL(targetUrl); + try { + assertSafeProxyUrl(initialTarget); + } catch (error) { + console.error("Unsafe proxy target:", error); + return new Response("Bad Request: Unsafe proxy target.", { status: 400 }); + } + + const bodyResult = await readRequestBody( + request, + policy.limits.maxRequestBodyBytes + ); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const abortContext = createProxyAbortContext( + parentSignal, + policy.limits.timeoutMs + ); + let currentTarget = targetUrl; + let redirectCount = 0; + let lastResponse: Response | null = null; + let effectiveMethod = originalMethod; + let shouldDropBodyHeaders = false; + + try { + while (true) { + const currentTargetUrl = new URL(currentTarget); + try { + assertSafeProxyUrl(currentTargetUrl); + } catch (error) { + console.error("Unsafe proxy redirect target:", error); + return new Response( + "Bad Gateway: Upstream redirect blocked.", + { status: 502 } + ); + } + + const headers = buildProxyRequestHeaders({ + request, + originalUrl, + currentTarget: currentTargetUrl, + initialTarget, + policy, + shouldDropBodyHeaders + }); + const forwardBody = effectiveMethod !== "GET" + && effectiveMethod !== "HEAD" + ? bodyResult ?? null + : null; + const forwarded = new Request(currentTarget, { + method: effectiveMethod, + headers, + body: forwardBody, + redirect: "manual", + signal: abortContext.signal + }); + + try { + lastResponse = await runtime.fetchImpl(forwarded); + } catch (error) { + if (abortContext.didTimeout()) { + return new Response( + "Gateway Timeout: Upstream request timed out.", + { status: 504 } + ); + } + if (!parentSignal.aborted) { + console.error(`Proxy fetch failed for ${currentTarget}:`, error); + } + return new Response( + "Bad Gateway: Upstream fetch failed.", + { status: 502 } + ); + } + + const status = lastResponse.status; + const location = lastResponse.headers.get("location"); + if ( + status < 300 + || status >= 400 + || !location + || policy.redirects.mode === "manual" + || redirectCount >= policy.redirects.maxHops + ) { + break; + } + + let nextTarget: URL; + try { + nextTarget = new URL(location, currentTargetUrl); + assertSafeProxyUrl(nextTarget); + } catch (error) { + console.error("Blocked unsafe upstream redirect:", error); + await discardResponseBody(lastResponse); + return new Response( + "Bad Gateway: Unsafe upstream redirect.", + { status: 502 } + ); + } + if ( + !isAllowedRedirectOrigin( + nextTarget, + initialTarget, + policy.redirects.allowedOrigins + ) + ) { + await discardResponseBody(lastResponse); + return new Response( + "Bad Gateway: Upstream redirect origin is not allowed.", + { status: 502 } + ); + } + + if (shouldSwitchRedirectToGet(status, effectiveMethod)) { + effectiveMethod = "GET"; + shouldDropBodyHeaders = true; + } + + await discardResponseBody(lastResponse); + currentTarget = nextTarget.toString(); + redirectCount += 1; + } + } finally { + abortContext.cleanup(); + } + + if (!lastResponse) { + return new Response("Gateway Timeout", { status: 504 }); + } + + const responseHeaders = applyProxyResponseHeaders({ + sourceHeaders: lastResponse.headers, + policy, + basePath, + requestMethod: originalMethod, + responseStatus: lastResponse.status, + upstreamStatus: lastResponse.status, + upstreamLocation: lastResponse.headers.get("location"), + redirectsFollowed: redirectCount + }); + rewriteResponseLocation( + responseHeaders, + originalUrl, + initialTarget, + currentTarget, + basePath + ); + + const contentType = responseHeaders.get("content-type") ?? ""; + const shouldRewriteHtml = ( + policy.rewriteHtml + && Boolean(basePath) + && basePath !== "/" + && contentType.includes("text/html") + ); + if (!shouldRewriteHtml) { + return new Response(lastResponse.body, { + status: lastResponse.status, + headers: responseHeaders + }); + } + + const html = await lastResponse.text(); + responseHeaders.delete("content-length"); + return new Response(rewriteHtmlPaths(html, basePath), { + status: lastResponse.status, + headers: responseHeaders + }); +} diff --git a/apps/runtime/src/lib/handlers/routing/proxy/safety.ts b/apps/runtime/src/lib/handlers/routing/proxy/safety.ts new file mode 100644 index 0000000..26a8a51 --- /dev/null +++ b/apps/runtime/src/lib/handlers/routing/proxy/safety.ts @@ -0,0 +1,97 @@ +/** + * @file safety.ts + * @description + * [EN] Proxy target safety checks. + * Rejects unsupported protocols and literal non-public network targets before every upstream hop. + * + * [CN] 反向代理目标安全检查。 + * 在每一次上游请求前拒绝不支持的协议和字面量形式的非公网网络目标。 + * + * @see {@link https://github.com/Revaea/i0c.cc} for repository info. + */ + +function isIPv4(hostname: string): boolean { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname); +} + +function isNonPublicIPv4(hostname: string): boolean { + if (!isIPv4(hostname)) return false; + const parts = hostname.split(".").map((part) => Number(part)); + if ( + parts.length !== 4 + || parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255) + ) { + return false; + } + const [a, b, c] = parts; + + if (a === 0 || a === 10 || a === 127) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 0 && (c === 0 || c === 2)) return true; + if (a === 192 && b === 88 && c === 99) return true; + if (a === 192 && b === 168) return true; + if (a === 198 && (b === 18 || b === 19)) return true; + if (a === 198 && b === 51 && c === 100) return true; + if (a === 203 && b === 0 && c === 113) return true; + return a >= 224; +} + +function normalizeHostname(hostname: string): string { + const host = hostname.toLowerCase().replace(/\.+$/u, ""); + return host.startsWith("[") && host.endsWith("]") + ? host.slice(1, -1) + : host; +} + +function isNonPublicIPv6(hostname: string): boolean { + const host = normalizeHostname(hostname); + if (!host.includes(":")) return false; + if ( + host.startsWith("::") + || host.startsWith("64:ff9b:") + || host.startsWith("100:") + || host.startsWith("2001:db8:") + || host.startsWith("2002:") + ) { + return true; + } + + const firstHextet = Number.parseInt(host.split(":", 1)[0], 16); + return ( + (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) + || (firstHextet >= 0xfe80 && firstHextet <= 0xfeff) + || (firstHextet >= 0xff00 && firstHextet <= 0xffff) + ); +} + +function isNonPublicProxyHost(hostname: string): boolean { + const host = normalizeHostname(hostname); + if (host === "localhost" || host === "127.0.0.1") return true; + if (host.endsWith(".localhost")) return true; + return isNonPublicIPv4(host) || isNonPublicIPv6(host); +} + +export function assertSafeProxyUrl(url: URL): void { + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`Unsupported proxy protocol: ${url.protocol}`); + } + if (url.username || url.password) { + throw new Error("Proxy targets must not contain credentials"); + } + if (isNonPublicProxyHost(url.hostname)) { + throw new Error(`Blocked proxy target host: ${url.hostname}`); + } +} + +export function isAllowedRedirectOrigin( + candidate: URL, + initialTarget: URL, + allowedOrigins: readonly string[] | null +): boolean { + if (allowedOrigins === null || candidate.origin === initialTarget.origin) { + return true; + } + return allowedOrigins.includes(candidate.origin); +} diff --git a/apps/runtime/src/lib/handlers/routing/response.ts b/apps/runtime/src/lib/handlers/routing/response.ts index ae3dc58..7f1b2e2 100644 --- a/apps/runtime/src/lib/handlers/routing/response.ts +++ b/apps/runtime/src/lib/handlers/routing/response.ts @@ -1,154 +1,26 @@ /** * @file response.ts * @description - * [EN] Response Factory. - * Constructs the final HTTP responses for the client. Contains specific logic for handling - * Redirects (3xx status codes) and Proxies (request forwarding), including security headers - * and proxy candidate failure classification. + * [EN] Route response factory. + * Dispatches normalized redirect and proxy actions and classifies proxy candidate failures. * - * [CN] 响应工厂。 - * 为客户端构造最终的 HTTP 响应。包含处理重定向(3xx 状态码)和代理(请求转发)的具体逻辑, - * 包括设置安全响应头以及区分代理候选的失败原因。 + * [CN] 路由响应工厂。 + * 分发规范化后的重定向与代理动作,并对代理候选失败进行分类。 * * @see {@link https://github.com/Revaea/i0c.cc} for repository info. */ import { DEFAULT_STATUS, HSTS_HEADER_VALUE } from "../core/constants"; -import { NormalizedRule, ResolvedRuntime } from "../core/types"; +import type { NormalizedRule, ResolvedRuntime } from "../core/types"; -const SENSITIVE_FORWARD_HEADERS = [ - "cookie", - "authorization", - "client-ip", - "fastly-client-ip", - "fly-client-ip", - "forwarded", - "forwarded-for", - "proxy-authorization", - "true-client-ip", - "x-client-ip", - "x-cluster-client-ip", - "x-envoy-external-address", - "x-real-ip" -] as const; -const SENSITIVE_FORWARD_HEADER_PREFIXES = [ - "cf-", - "x-forwarded-", - "x-nf-", - "x-vercel-" -] as const; -const HOP_BY_HOP_HEADERS = [ - "connection", - "keep-alive", - "proxy-authenticate", - "te", - "trailer", - "transfer-encoding", - "upgrade" -] as const; -const REQUEST_BODY_HEADERS = [ - "content-encoding", - "content-language", - "content-length", - "content-location", - "content-type" -] as const; -const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i; -const MAX_PROXY_REDIRECTS = 5; - -async function discardResponseBody(response: Response): Promise { - try { - await response.body?.cancel(); - } catch { - } -} - -function isIPv4(hostname: string): boolean { - return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname); -} - -function isNonPublicIPv4(hostname: string): boolean { - if (!isIPv4(hostname)) return false; - const parts = hostname.split(".").map((p) => Number(p)); - if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return false; - const [a, b, c] = parts; - - if (a === 0 || a === 10 || a === 127) return true; - if (a === 100 && b >= 64 && b <= 127) return true; - if (a === 169 && b === 254) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 0 && (c === 0 || c === 2)) return true; - if (a === 192 && b === 88 && c === 99) return true; - if (a === 192 && b === 168) return true; - if (a === 198 && (b === 18 || b === 19)) return true; - if (a === 198 && b === 51 && c === 100) return true; - if (a === 203 && b === 0 && c === 113) return true; - return a >= 224; -} - -function normalizeHostname(hostname: string): string { - const host = hostname.toLowerCase(); - return host.startsWith("[") && host.endsWith("]") - ? host.slice(1, -1) - : host; -} - -function isNonPublicIPv6(hostname: string): boolean { - const host = normalizeHostname(hostname); - if (!host.includes(":")) return false; - if ( - host.startsWith("::") - || host.startsWith("64:ff9b:") - || host.startsWith("100:") - || host.startsWith("2001:db8:") - || host.startsWith("2002:") - ) { - return true; - } - - const firstHextet = Number.parseInt(host.split(":", 1)[0], 16); - return ( - (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) || - (firstHextet >= 0xfe80 && firstHextet <= 0xfeff) || - (firstHextet >= 0xff00 && firstHextet <= 0xffff) - ); -} - -function isNonPublicProxyHost(hostname: string): boolean { - const host = normalizeHostname(hostname); - if (host === "localhost" || host === "127.0.0.1") return true; - if (host.endsWith(".localhost")) return true; - return isNonPublicIPv4(host) || isNonPublicIPv6(host); -} - -function assertSafeProxyUrl(url: URL): void { - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new Error(`Unsupported proxy protocol: ${url.protocol}`); - } - if (isNonPublicProxyHost(url.hostname)) { - throw new Error(`Blocked proxy target host: ${url.hostname}`); - } -} - -function shouldSwitchRedirectToGet(status: number, method: string): boolean { - return ( - ((status === 301 || status === 302) && method === "POST") - || (status === 303 && method !== "GET" && method !== "HEAD") - ); -} - -function prependProxyBasePath(pathname: string, basePath: string): string { - if (!basePath || basePath === "/") { - return pathname; - } - - const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; - return pathname === "/" ? `${prefix}/` : `${prefix}${pathname}`; -} +import { resolveProxyPolicy } from "./proxy/policy"; +import { proxyRequest } from "./proxy/request"; export type ProxyFailureReason = "not_found" | "unavailable"; -export function classifyProxyFailure(response: Response): ProxyFailureReason | null { +export function classifyProxyFailure( + response: Response +): ProxyFailureReason | null { if (response.status === 404) { return "not_found"; } @@ -160,215 +32,24 @@ export function classifyProxyFailure(response: Response): ProxyFailureReason | n export async function respondUsingRule( request: Request, - rule: NormalizedRule, - targetUrl: string, + rule: NormalizedRule, + targetUrl: string, runtime: ResolvedRuntime, basePath?: string, signal?: AbortSignal ): Promise { - if (rule.type === "proxy") { - return proxyRequest(request, targetUrl, runtime, basePath, signal); - } - - return redirectResponse(targetUrl, rule.status); -} - -async function proxyRequest( - request: Request, - targetUrl: string, - runtime: ResolvedRuntime, - basePath: string = "", - signal: AbortSignal = request.signal -): Promise { - const originalUrl = new URL(request.url); - const originalHost = originalUrl.host; - const targetUrlObj = new URL(targetUrl); - try { - assertSafeProxyUrl(targetUrlObj); - } catch (e) { - console.error("Unsafe proxy target:", e); - return new Response("Bad Request: Unsafe proxy target.", { status: 400 }); - } - - let currentTarget = targetUrl; - let redirectCount = 0; - let lastResponse: Response | null = null; - - let bodyBuffer: ArrayBuffer | undefined; - const originalMethod = request.method.toUpperCase(); - if (originalMethod !== "GET" && originalMethod !== "HEAD" && request.body) { - bodyBuffer = await request.arrayBuffer(); - } - - let effectiveMethod = originalMethod; - let shouldDropBodyHeaders = false; - - while (true) { - const headers = new Headers(request.headers); - const currentUrlObj = new URL(currentTarget); - - try { - assertSafeProxyUrl(currentUrlObj); - } catch (e) { - console.error("Unsafe proxy redirect target:", e); - return new Response("Bad Gateway: Upstream redirect blocked.", { status: 502 }); - } - - const connectionHeaders = headers.get("connection") - ?.split(",") - .map((name) => name.trim().toLowerCase()) - .filter((name) => HEADER_NAME_PATTERN.test(name)) ?? []; - - headers.delete("host"); - for (const name of SENSITIVE_FORWARD_HEADERS) { - headers.delete(name); - } - for (const name of HOP_BY_HOP_HEADERS) { - headers.delete(name); - } - for (const name of connectionHeaders) { - headers.delete(name); - } - for (const name of [...headers.keys()]) { - if (SENSITIVE_FORWARD_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))) { - headers.delete(name); - } - } - if (shouldDropBodyHeaders) { - for (const name of REQUEST_BODY_HEADERS) { - headers.delete(name); - } - } - - // Re-assert forwarding headers after stripping user-controlled versions. - headers.set("x-forwarded-host", originalHost); - headers.set("x-forwarded-proto", originalUrl.protocol.slice(0, -1)); - - headers.set("origin", currentUrlObj.origin); - headers.set("referer", currentTarget); - - let forwardBody: BodyInit | null = null; - if (effectiveMethod !== "GET" && effectiveMethod !== "HEAD" && bodyBuffer) { - forwardBody = bodyBuffer; - } - - const forwarded = new Request(currentTarget, { - method: effectiveMethod, - headers, - body: forwardBody, - redirect: "manual", + if (rule.action.type === "proxy") { + return proxyRequest( + request, + targetUrl, + runtime, + resolveProxyPolicy(rule.action.policy), + basePath, signal - }); - - try { - lastResponse = await runtime.fetchImpl(forwarded); - } catch (e) { - if (!signal.aborted) { - console.error(`Proxy fetch failed for ${currentTarget}:`, e); - } - return new Response("Bad Gateway: Upstream fetch failed.", { status: 502 }); - } - - const status = lastResponse.status; - if (status >= 300 && status < 400) { - const location = lastResponse.headers.get("Location"); - if (!location) break; - - let nextUrlObj: URL; - try { - nextUrlObj = new URL(location, currentUrlObj); - assertSafeProxyUrl(nextUrlObj); - } catch (e) { - console.error("Blocked unsafe upstream redirect:", e); - await discardResponseBody(lastResponse); - return new Response("Bad Gateway: Unsafe upstream redirect.", { status: 502 }); - } - - if (redirectCount >= MAX_PROXY_REDIRECTS) { - break; - } - - const nextUrl = nextUrlObj.toString(); - - if (shouldSwitchRedirectToGet(status, effectiveMethod)) { - effectiveMethod = "GET"; - shouldDropBodyHeaders = true; - } - - await discardResponseBody(lastResponse); - currentTarget = nextUrl; - redirectCount += 1; - continue; - } - - break; - } - - if (!lastResponse) { - return new Response("Gateway Timeout", { status: 504 }); - } - - const responseHeaders = new Headers(lastResponse.headers); - - responseHeaders.set("x-upstream-status", String(lastResponse.status)); - responseHeaders.set("x-upstream-location", lastResponse.headers.get("Location") ?? ""); - responseHeaders.set("x-proxy-redirects-followed", String(redirectCount)); - - responseHeaders.delete("content-security-policy"); - responseHeaders.delete("content-security-policy-report-only"); - responseHeaders.delete("x-frame-options"); - responseHeaders.set("Strict-Transport-Security", HSTS_HEADER_VALUE); - - const setCookie = responseHeaders.get("set-cookie"); - if (setCookie) { - const fixedCookie = setCookie.replace(/;\s*domain=[^;]+/ig, ""); - responseHeaders.set("set-cookie", fixedCookie); - } - - const location = responseHeaders.get("Location"); - if (location) { - let finalLocation = location; - - try { - const locUrl = new URL(location, currentTarget); - if (locUrl.origin === targetUrlObj.origin && originalHost) { - const rewrittenUrl = new URL(originalUrl.origin); - rewrittenUrl.pathname = prependProxyBasePath(locUrl.pathname, basePath); - rewrittenUrl.search = locUrl.search; - rewrittenUrl.hash = locUrl.hash; - const rewritten = rewrittenUrl.toString(); - finalLocation = rewritten !== originalUrl.href ? rewritten : locUrl.toString(); - } else { - finalLocation = locUrl.toString(); - } - } catch { - } - - responseHeaders.set("Location", finalLocation); - } - - const contentType = responseHeaders.get("content-type") || ""; - const shouldRewriteHtml = basePath && basePath !== "/" && contentType.includes("text/html"); - - if (!shouldRewriteHtml) { - return new Response(lastResponse.body, { - status: lastResponse.status, - headers: responseHeaders - }); + ); } - const html = await lastResponse.text(); - const prefix = basePath || ""; - const rewrittenHtml = html.replace(/(href|src|action)="\/((?!\/|#|\.\/|\.\.\/)[^"]*)"/g, (_, attr, pathPart) => { - return `${attr}="${prefix}/${pathPart}"`; - }).replace(//gi, ``); - - responseHeaders.delete("content-length"); - - return new Response(rewrittenHtml, { - status: lastResponse.status, - headers: responseHeaders - }); + return redirectResponse(targetUrl, rule.action.status); } function redirectResponse(location: string, status: number): Response { diff --git a/apps/runtime/tests/analytics/analytics-orchestration.test.ts b/apps/runtime/tests/analytics/analytics-orchestration.test.ts index ecf7093..42a259b 100644 --- a/apps/runtime/tests/analytics/analytics-orchestration.test.ts +++ b/apps/runtime/tests/analytics/analytics-orchestration.test.ts @@ -31,11 +31,15 @@ const completedAt = 1_700_000_000_500; const rule: NormalizedRule = { analyticsId: "7ca38115-22d0-4de6-be48-4e7c98010b0d", - type: "exact", - target: "https://example.com/", - appendPath: false, - status: 302, - priority: 0 + match: { type: "exact" }, + action: { + type: "redirect", + target: "https://example.com/", + appendPath: false, + status: 302 + }, + priority: 0, + sourceType: "exact" }; interface CapturedDelivery { diff --git a/apps/runtime/tests/analytics/analytics-resilience.test.ts b/apps/runtime/tests/analytics/analytics-resilience.test.ts index 77ad1b7..2e5b1c3 100644 --- a/apps/runtime/tests/analytics/analytics-resilience.test.ts +++ b/apps/runtime/tests/analytics/analytics-resilience.test.ts @@ -27,11 +27,15 @@ const request = new Request("https://i0c.cc/r", { const rule: NormalizedRule = { analyticsId: "7ca38115-22d0-4de6-be48-4e7c98010b0d", - type: "exact", - target: "https://example.com/", - appendPath: false, - status: 302, - priority: 0 + match: { type: "exact" }, + action: { + type: "redirect", + target: "https://example.com/", + appendPath: false, + status: 302 + }, + priority: 0, + sourceType: "exact" }; function createRuntime(overrides: Partial = {}): ResolvedRuntime { diff --git a/apps/runtime/tests/handlers/dispatcher.test.ts b/apps/runtime/tests/handlers/dispatcher.test.ts index a9ef2a3..dee8c3f 100644 --- a/apps/runtime/tests/handlers/dispatcher.test.ts +++ b/apps/runtime/tests/handlers/dispatcher.test.ts @@ -135,7 +135,7 @@ test("falls through failed proxies in priority order", async () => { ]); assert.ok(result.match); assert.equal(await result.match.response.text(), "fallback"); - assert.equal(result.match.rule.target, "https://second.example"); + assert.equal(result.match.rule.action.target, "https://second.example"); assert.equal(result.proxyFailureReason, null); assert.equal(discardedResponses, 1); }); @@ -221,7 +221,7 @@ test("races static asset proxies and returns a successful candidate", async () = ]); assert.ok(result.match); assert.equal(await result.match.response.text(), "asset"); - assert.equal(result.match.rule.target, "https://second.example"); + assert.equal(result.match.rule.action.target, "https://second.example"); assert.equal(result.proxyFailureReason, null); }); @@ -278,6 +278,6 @@ test("aborts slower proxy candidates after the race has a winner", async () => { isStaticAssetPath: true }); - assert.equal(result.match?.rule.target, "https://first.example"); + assert.equal(result.match?.rule.action.target, "https://first.example"); assert.equal(didAbortSlowerCandidate, true); }); diff --git a/apps/runtime/tests/handlers/matcher.test.ts b/apps/runtime/tests/handlers/matcher.test.ts index 332edf4..67bdcc9 100644 --- a/apps/runtime/tests/handlers/matcher.test.ts +++ b/apps/runtime/tests/handlers/matcher.test.ts @@ -107,7 +107,13 @@ test("drops malformed targets and bounds response status values", () => { assert.equal(entries.length, 1); assert.equal(entries[0]?.base, "/invalid-status"); - assert.equal(entries[0]?.rule.status, 302); + assert.equal(entries[0]?.rule.action.type, "redirect"); + assert.equal( + entries[0]?.rule.action.type === "redirect" + ? entries[0].rule.action.status + : undefined, + 302 + ); }); test("normalizes schema-compatible numeric strings", () => { @@ -121,6 +127,10 @@ test("normalizes schema-compatible numeric strings", () => { }); assert.ok(entry); - assert.equal(entry.rule.status, 307); + assert.equal(entry.rule.action.type, "redirect"); + assert.equal( + entry.rule.action.type === "redirect" ? entry.rule.action.status : undefined, + 307 + ); assert.equal(entry.rule.priority, -2); }); diff --git a/apps/runtime/tests/handlers/proxy-policy.test.ts b/apps/runtime/tests/handlers/proxy-policy.test.ts new file mode 100644 index 0000000..e1f6415 --- /dev/null +++ b/apps/runtime/tests/handlers/proxy-policy.test.ts @@ -0,0 +1,421 @@ +/** + * @file proxy-policy.test.ts + * @description + * [EN] Explicit proxy-policy regression tests. + * Verifies profile defaults, request and response controls, redirect boundaries, resource limits, + * cache behavior, and shared configuration validation. + * + * [CN] 显式反向代理策略回归测试。 + * 验证预设默认值、请求与响应控制、跳转边界、资源限制、缓存行为和共享配置校验。 + * + * @see {@link https://github.com/Revaea/i0c.cc} for repository info. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + validateRedirectsConfig, + type ProxyPolicy +} from "@i0c/config"; + +import { resolveRuntimeOptions } from "../../src/lib/handlers/configuration/loader"; +import type { + NormalizedRule, + ResolvedRuntime +} from "../../src/lib/handlers/core/types"; +import { getSetCookieValues } from "../../src/lib/handlers/routing/proxy/headers"; +import { resolveProxyPolicy } from "../../src/lib/handlers/routing/proxy/policy"; +import { respondUsingRule } from "../../src/lib/handlers/routing/response"; + +function createRuntime(fetchImpl: typeof fetch): ResolvedRuntime { + return resolveRuntimeOptions({ + configUrl: "https://config.example/redirects.json", + dataConfigUrl: null, + fetchImpl, + provider: "cloudflare", + now: () => Date.now(), + random: () => 0 + }); +} + +function createProxyRule(policy?: ProxyPolicy): NormalizedRule { + return { + match: { type: "prefix" }, + action: { + type: "proxy", + target: "https://example.com", + appendPath: true, + policy + }, + priority: 0, + sourceType: "proxy" + }; +} + +test("resolves safe profile defaults while keeping legacy rules compatible", () => { + const legacy = resolveProxyPolicy(undefined); + const isolated = resolveProxyPolicy({ profile: "isolated" }); + const asset = resolveProxyPolicy({ profile: "asset" }); + const trustedApi = resolveProxyPolicy({ profile: "trusted-api" }); + + assert.equal(legacy.profile, "legacy"); + assert.equal(legacy.request.methods, null); + assert.equal(legacy.request.origin, "target"); + assert.equal(legacy.response.securityHeaders, "strip"); + assert.equal(isolated.request.cookies.mode, "strip"); + assert.equal(isolated.cache.mode, "bypass"); + assert.deepEqual(isolated.redirects.allowedOrigins, []); + assert.equal(asset.cache.mode, "public"); + assert.equal(asset.limits.maxRequestBodyBytes, 0); + assert.ok(trustedApi.request.methods?.includes("POST")); + assert.equal(trustedApi.request.origin, "preserve"); +}); + +test("applies isolated request, response, and security defaults", async () => { + let forwarded: Request | undefined; + const upstreamHeaders = new Headers({ + Connection: "x-internal", + "Content-Security-Policy": "default-src 'self'", + "Keep-Alive": "timeout=5", + "X-Internal": "secret", + "X-Frame-Options": "DENY" + }); + upstreamHeaders.append("Set-Cookie", "session=secret; Domain=example.com; Path=/"); + const runtime = createRuntime(async (input) => { + forwarded = input instanceof Request ? input : new Request(input); + return new Response("ok", { status: 200, headers: upstreamHeaders }); + }); + const request = new Request("https://i0c.cc/proxy", { + headers: { + Authorization: "Bearer secret", + Cookie: "session=secret", + Origin: "https://client.example", + Referer: "https://client.example/page" + } + }); + + const response = await respondUsingRule( + request, + createProxyRule({ profile: "isolated" }), + "https://example.com/upstream", + runtime, + "/proxy" + ); + + assert.ok(forwarded); + assert.equal(forwarded.headers.get("authorization"), null); + assert.equal(forwarded.headers.get("cookie"), null); + assert.equal(forwarded.headers.get("origin"), null); + assert.equal(forwarded.headers.get("referer"), null); + assert.equal(response.headers.get("content-security-policy"), "default-src 'self'"); + assert.equal(response.headers.get("keep-alive"), null); + assert.equal(response.headers.get("x-internal"), null); + assert.equal(response.headers.get("x-frame-options"), "DENY"); + assert.deepEqual(getSetCookieValues(response.headers), []); + assert.equal(response.headers.get("cache-control"), "private, no-store"); + assert.equal(response.headers.get("x-upstream-status"), null); +}); + +test("allowlists request and response cookies without folding Set-Cookie", async () => { + let forwarded: Request | undefined; + const upstreamHeaders = new Headers(); + upstreamHeaders.append( + "Set-Cookie", + "ri_visitor=visitor-1; Domain=example.com; Path=/; HttpOnly" + ); + upstreamHeaders.append( + "Set-Cookie", + "internal=secret; Domain=example.com; Path=/" + ); + const runtime = createRuntime(async (input) => { + forwarded = input instanceof Request ? input : new Request(input); + return new Response("ok", { status: 200, headers: upstreamHeaders }); + }); + const policy: ProxyPolicy = { + profile: "trusted-api", + request: { + cookies: { mode: "allowlist", names: ["ri_visitor"] }, + authorization: "preserve", + origin: "preserve", + referer: "preserve" + }, + response: { + cookies: { + mode: "allowlist", + names: ["ri_visitor"], + domain: "remove", + path: "proxy-base" + } + } + }; + const request = new Request("https://i0c.cc/proxy", { + headers: { + Authorization: "Bearer trusted", + Cookie: "ri_visitor=visitor-1; internal=secret", + Origin: "https://client.example", + Referer: "https://client.example/page" + } + }); + + const response = await respondUsingRule( + request, + createProxyRule(policy), + "https://example.com/upstream", + runtime, + "/proxy; scoped" + ); + + assert.ok(forwarded); + assert.equal(forwarded.headers.get("authorization"), "Bearer trusted"); + assert.equal(forwarded.headers.get("cookie"), "ri_visitor=visitor-1"); + assert.equal(forwarded.headers.get("origin"), "https://client.example"); + assert.equal(forwarded.headers.get("referer"), "https://client.example/page"); + const responseCookies = getSetCookieValues(response.headers); + assert.equal(responseCookies.length, 1); + assert.match(responseCookies[0] ?? "", /^ri_visitor=visitor-1;/); + assert.doesNotMatch(responseCookies[0] ?? "", /Domain=/i); + assert.match(responseCookies[0] ?? "", /Path=\/proxy%3B%20scoped/i); +}); + +test("blocks undeclared redirect origins and strips credentials on allowed hops", async () => { + const blockedRuntime = createRuntime(async () => new Response(null, { + status: 302, + headers: { Location: "https://cdn.example/final" } + })); + const blockedResponse = await respondUsingRule( + new Request("https://i0c.cc/proxy"), + createProxyRule({ profile: "isolated" }), + "https://example.com/start", + blockedRuntime, + "/proxy" + ); + assert.equal(blockedResponse.status, 502); + + const forwarded: Request[] = []; + const allowedRuntime = createRuntime(async (input) => { + const request = input instanceof Request ? input : new Request(input); + forwarded.push(request); + return forwarded.length === 1 + ? new Response(null, { + status: 302, + headers: { Location: "https://cdn.example/final" } + }) + : new Response("ok", { status: 200 }); + }); + const policy: ProxyPolicy = { + profile: "trusted-api", + request: { + authorization: "preserve", + cookies: { mode: "allowlist", names: ["session"] } + }, + redirects: { + mode: "follow", + allowedOrigins: ["https://cdn.example"] + } + }; + const allowedResponse = await respondUsingRule( + new Request("https://i0c.cc/proxy", { + headers: { + Authorization: "Bearer trusted", + Cookie: "session=secret" + } + }), + createProxyRule(policy), + "https://example.com/start", + allowedRuntime, + "/proxy" + ); + + assert.equal(allowedResponse.status, 200); + assert.equal(forwarded.length, 2); + assert.equal(forwarded[0]?.headers.get("authorization"), "Bearer trusted"); + assert.equal(forwarded[0]?.headers.get("cookie"), "session=secret"); + assert.equal(forwarded[1]?.headers.get("authorization"), null); + assert.equal(forwarded[1]?.headers.get("cookie"), null); +}); + +test("enforces method, body-size, timeout, and public-cache policies", async () => { + let fetchCalls = 0; + const runtime = createRuntime(async () => { + fetchCalls += 1; + return new Response("asset", { status: 200 }); + }); + const methodResponse = await respondUsingRule( + new Request("https://i0c.cc/proxy", { method: "POST", body: "payload" }), + createProxyRule({ profile: "isolated" }), + "https://example.com/upstream", + runtime, + "/proxy" + ); + assert.equal(methodResponse.status, 405); + assert.equal(fetchCalls, 0); + + const bodyResponse = await respondUsingRule( + new Request("https://i0c.cc/proxy", { method: "POST", body: "payload" }), + createProxyRule({ + profile: "trusted-api", + limits: { maxRequestBodyBytes: 3 } + }), + "https://example.com/upstream", + runtime, + "/proxy" + ); + assert.equal(bodyResponse.status, 413); + assert.equal(fetchCalls, 0); + + const cacheResponse = await respondUsingRule( + new Request("https://i0c.cc/assets/app.js"), + createProxyRule({ profile: "asset" }), + "https://example.com/app.js", + runtime, + "/assets" + ); + assert.equal(cacheResponse.status, 200); + assert.equal( + cacheResponse.headers.get("cache-control"), + "public, max-age=3600, s-maxage=86400" + ); + + const privateCacheRuntime = createRuntime(async () => new Response("asset", { + status: 200, + headers: { "Cache-Control": 'private="Set-Cookie"' } + })); + const privateCacheResponse = await respondUsingRule( + new Request("https://i0c.cc/assets/private.js"), + createProxyRule({ profile: "asset" }), + "https://example.com/private.js", + privateCacheRuntime, + "/assets" + ); + assert.equal( + privateCacheResponse.headers.get("cache-control"), + "private, no-store" + ); + + const credentialCacheResponse = await respondUsingRule( + new Request("https://i0c.cc/api/me", { + headers: { Authorization: "Bearer private" } + }), + createProxyRule({ + profile: "trusted-api", + request: { authorization: "preserve" }, + cache: { + mode: "public", + browserTtlSeconds: 60, + edgeTtlSeconds: 60 + } + }), + "https://example.com/me", + runtime, + "/api" + ); + assert.equal( + credentialCacheResponse.headers.get("cache-control"), + "private, no-store" + ); + + const timeoutRuntime = createRuntime(async (input) => { + const request = input instanceof Request ? input : new Request(input); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve(new Response("late", { status: 200 })), + 1_000 + ); + const rejectOnAbort = () => { + clearTimeout(timer); + reject(request.signal.reason); + }; + if (request.signal.aborted) { + rejectOnAbort(); + } else { + request.signal.addEventListener("abort", rejectOnAbort, { once: true }); + } + }); + }); + const timeoutResponse = await respondUsingRule( + new Request("https://i0c.cc/proxy"), + createProxyRule({ + profile: "isolated", + limits: { timeoutMs: 100 } + }), + "https://example.com/upstream", + timeoutRuntime, + "/proxy" + ); + assert.equal(timeoutResponse.status, 504); +}); + +test("validates proxyPolicy only on proxy routes and rejects unsafe overrides", () => { + const valid = validateRedirectsConfig({ + Slots: { + Main: { + "/api": { + type: "proxy", + target: "https://api.example.com", + proxyPolicy: { + profile: "trusted-api", + request: { + cookies: { + mode: "allowlist", + names: ["session"] + } + }, + redirects: { + mode: "follow", + allowedOrigins: ["https://cdn.example"] + } + } + } + } + } + }); + assert.equal(valid.status, "valid"); + + const invalid = validateRedirectsConfig({ + Slots: { + Main: { + "/redirect": { + type: "exact", + target: "https://example.com", + proxyPolicy: { profile: "isolated" } + }, + "/proxy": { + type: "proxy", + target: "https://example.com", + proxyPolicy: { + profile: "trusted-api", + request: { + methods: ["CONNECT"], + cookies: { mode: "allowlist" }, + authorization: "preserve" + }, + response: { + cookies: { + mode: "strip", + domain: "preserve" + } + }, + cache: { + mode: "public" + }, + redirects: { + mode: "follow", + allowedOrigins: ["https://example.com/path"] + } + } + } + } + } + }); + assert.equal(invalid.status, "invalid"); + if (invalid.status === "invalid") { + const issuePaths = invalid.issues.map((issue) => issue.path); + assert.ok(issuePaths.includes("/Slots/Main/~1redirect/proxyPolicy")); + assert.ok(issuePaths.includes("/Slots/Main/~1proxy/proxyPolicy/request/cookies/names")); + assert.ok(issuePaths.includes("/Slots/Main/~1proxy/proxyPolicy/request/methods/0")); + assert.ok(issuePaths.includes("/Slots/Main/~1proxy/proxyPolicy/response/cookies")); + assert.ok(issuePaths.includes("/Slots/Main/~1proxy/proxyPolicy/cache/mode")); + assert.ok(issuePaths.includes("/Slots/Main/~1proxy/proxyPolicy/redirects/allowedOrigins/0")); + } +}); diff --git a/apps/runtime/tests/handlers/response-security.test.ts b/apps/runtime/tests/handlers/response-security.test.ts index e9e5916..adfb7c9 100644 --- a/apps/runtime/tests/handlers/response-security.test.ts +++ b/apps/runtime/tests/handlers/response-security.test.ts @@ -18,11 +18,14 @@ import { respondUsingRule } from "../../src/lib/handlers/routing/response"; import type { NormalizedRule, ResolvedRuntime } from "../../src/lib/handlers/core/types"; const proxyRule: NormalizedRule = { - type: "proxy", - target: "https://example.com", - appendPath: true, - status: 302, - priority: 0 + match: { type: "prefix" }, + action: { + type: "proxy", + target: "https://example.com", + appendPath: true + }, + priority: 0, + sourceType: "proxy" }; function createRuntime(fetchImpl: typeof fetch): ResolvedRuntime { @@ -46,6 +49,7 @@ test("blocks non-public literal IP proxy targets", async (context) => { for (const target of [ "http://2130706433/", + "http://localhost./", "http://10.0.0.1/", "http://100.64.0.1/", "http://169.254.169.254/", diff --git a/apps/webui/README.md b/apps/webui/README.md index d4148ac..9407c45 100644 --- a/apps/webui/README.md +++ b/apps/webui/README.md @@ -141,7 +141,7 @@ The WebUI does not read former non-sensitive environment variables as overrides ## Features Overview - Versioned authenticated, numeric-ID allowlist, or GitHub-wide read-only access with configured managers and optional blocked users. -- Visual editing of `redirects.json`: group tree management + rule form editing. +- Visual editing of `redirects.json`: group tree management, rule forms, and explicit proxy-policy profiles with advanced request and response controls. - GitHub Repository-only rules source override and JSON editor with line highlighting and syntax validation. - Visual, validated `config.json` settings with a raw recovery editor only when the current document cannot be represented safely. - First-run database initialization without hand-written JSON or a seed command. diff --git a/apps/webui/README.zh-CN.md b/apps/webui/README.zh-CN.md index f62455e..5e06714 100644 --- a/apps/webui/README.zh-CN.md +++ b/apps/webui/README.zh-CN.md @@ -135,7 +135,7 @@ WebUI 不会把原有非敏感环境变量作为覆盖值或回退值读取。Ve ## 功能概览 - 通过版本化配置选择任意已登录用户、数字用户 ID 白名单或带指定管理员与可选黑名单的 GitHub 全员只读模式。 -- 可视化编辑 `redirects.json`:分组树管理 + 规则表单编辑。 +- 可视化编辑 `redirects.json`:分组树、规则表单,以及带高级请求与响应控制的显式代理策略预设。 - GitHub Repository 专属的规则来源切换和 JSON 编辑器,支持当前行高亮与语法校验。 - 可视化并校验 `config.json`;只有当前文档无法安全转换为表单时,才显示原始内容恢复编辑器。 - 数据库首次初始化,无需手写 JSON 或执行 seed。 diff --git a/apps/webui/messages/en/redirects.json b/apps/webui/messages/en/redirects.json index 25f6aa1..2b03a5a 100644 --- a/apps/webui/messages/en/redirects.json +++ b/apps/webui/messages/en/redirects.json @@ -326,6 +326,79 @@ "priorityLabel": "Priority", "priorityTooltip": "Matching Priority\nSmaller number = higher priority", "priorityInvalid": "Must be an integer (e.g. 0, 10, -1).", + "proxyPolicyLegacyTitle": "Legacy proxy behavior", + "proxyPolicyLegacyDescription": "This rule predates explicit proxy policies. It keeps the previous forwarding behavior until you choose a profile.", + "proxyPolicyApplyIsolated": "Use isolated profile", + "proxyPolicyInherit": "Use profile default", + "proxyProfileLabel": "Proxy profile", + "proxyProfile": { + "isolated": "Isolated", + "asset": "Static assets", + "trusted-api": "Trusted API" + }, + "proxyProfileDescription": { + "isolated": "For external targets. Credentials and source headers are removed, responses are not shared-cached, and only same-origin redirects are followed.", + "asset": "For public static files. Credentials and cookies are removed and successful responses may use public cache headers.", + "trusted-api": "For APIs you control. Methods and selected credentials can be allowed explicitly; shared caching remains off by default." + }, + "proxyRequestTitle": "Upstream request", + "proxyRequestDescription": "Control methods, credentials, and source headers sent to the upstream.", + "proxyMethodsLabel": "Allowed methods", + "proxyMethodsPlaceholder": "GET, HEAD, OPTIONS", + "proxyRequestCookiesLabel": "Request cookies", + "proxyResponseCookiesLabel": "Response cookies", + "proxyCookieNamesLabel": "Allowed cookie names", + "proxyCookieNamesPlaceholder": "session, visitor", + "proxyAuthorizationLabel": "Authorization", + "proxyOriginLabel": "Origin", + "proxyRefererLabel": "Referer", + "proxyCookieMode": { + "strip": "Remove all", + "allowlist": "Allow selected names" + }, + "proxyCredentialMode": { + "strip": "Remove", + "preserve": "Preserve for the initial origin" + }, + "proxySourceHeaderMode": { + "strip": "Remove", + "preserve": "Preserve client value", + "target": "Rewrite to upstream" + }, + "proxyResponseTitle": "Upstream response", + "proxyResponseDescription": "Control which response cookies return to the browser and how their scope is rewritten.", + "proxyCookieDomainLabel": "Cookie Domain", + "proxyCookiePathLabel": "Cookie Path", + "proxyCookieAttributeMode": { + "remove": "Remove", + "preserve": "Preserve" + }, + "proxyCookiePathMode": { + "remove": "Remove", + "preserve": "Preserve", + "proxy-base": "Rewrite to proxy path" + }, + "proxyRedirectCacheTitle": "Redirects and cache", + "proxyRedirectCacheDescription": "Limit followed origins and choose whether safe responses can expose public cache headers.", + "proxyRedirectModeLabel": "Upstream redirects", + "proxyRedirectMode": { + "manual": "Return redirect to client", + "follow": "Follow allowed origins" + }, + "proxyMaxHopsLabel": "Maximum redirect hops", + "proxyAllowedOriginsLabel": "Additional allowed origins", + "proxyCacheModeLabel": "Cache mode", + "proxyCacheMode": { + "bypass": "Private, no-store", + "public": "Public cache headers" + }, + "proxyPublicCacheCredentialWarning": "Public caching requires request cookies and authorization to remain stripped.", + "proxyBrowserTtlLabel": "Browser TTL (seconds)", + "proxyEdgeTtlLabel": "Shared-cache TTL (seconds)", + "proxyLimitsTitle": "Resource limits", + "proxyLimitsDescription": "Bound upstream wait time and the request body buffered for redirect replay.", + "proxyTimeoutLabel": "Request timeout (ms)", + "proxyBodyLimitLabel": "Maximum request body (bytes)", "analyticsIdLabel": "Analytics ID", "analyticsIdTooltip": "Stable identifier used to attribute requests after a path or target changes." } diff --git a/apps/webui/messages/zh-CN/redirects.json b/apps/webui/messages/zh-CN/redirects.json index 35b4771..57abc62 100644 --- a/apps/webui/messages/zh-CN/redirects.json +++ b/apps/webui/messages/zh-CN/redirects.json @@ -326,6 +326,79 @@ "priorityLabel": "优先级", "priorityTooltip": "匹配优先级\n数字越小优先级越高", "priorityInvalid": "需为整数(例如 0、10、-1)。", + "proxyPolicyLegacyTitle": "旧版代理行为", + "proxyPolicyLegacyDescription": "这条规则创建于代理策略功能之前。在选择预设前,它会继续沿用原有转发行为。", + "proxyPolicyApplyIsolated": "使用隔离预设", + "proxyPolicyInherit": "使用预设默认值", + "proxyProfileLabel": "代理预设", + "proxyProfile": { + "isolated": "隔离代理", + "asset": "静态资源", + "trusted-api": "可信 API" + }, + "proxyProfileDescription": { + "isolated": "适用于外部目标。移除凭据和来源请求头、不使用共享缓存,并且只跟随同源跳转。", + "asset": "适用于公开静态文件。移除凭据和 Cookie,成功响应可输出公开缓存头。", + "trusted-api": "适用于你控制的 API。可显式放行方法和指定凭据,默认仍关闭共享缓存。" + }, + "proxyRequestTitle": "上游请求", + "proxyRequestDescription": "控制发送给上游的方法、凭据和来源请求头。", + "proxyMethodsLabel": "允许的方法", + "proxyMethodsPlaceholder": "GET, HEAD, OPTIONS", + "proxyRequestCookiesLabel": "请求 Cookie", + "proxyResponseCookiesLabel": "响应 Cookie", + "proxyCookieNamesLabel": "允许的 Cookie 名称", + "proxyCookieNamesPlaceholder": "session, visitor", + "proxyAuthorizationLabel": "Authorization", + "proxyOriginLabel": "Origin", + "proxyRefererLabel": "Referer", + "proxyCookieMode": { + "strip": "全部移除", + "allowlist": "仅允许指定名称" + }, + "proxyCredentialMode": { + "strip": "移除", + "preserve": "仅向初始来源保留" + }, + "proxySourceHeaderMode": { + "strip": "移除", + "preserve": "保留客户端值", + "target": "改写为上游地址" + }, + "proxyResponseTitle": "上游响应", + "proxyResponseDescription": "控制哪些响应 Cookie 返回浏览器,以及如何改写其作用域。", + "proxyCookieDomainLabel": "Cookie Domain", + "proxyCookiePathLabel": "Cookie Path", + "proxyCookieAttributeMode": { + "remove": "移除", + "preserve": "保留" + }, + "proxyCookiePathMode": { + "remove": "移除", + "preserve": "保留", + "proxy-base": "改写为代理路径" + }, + "proxyRedirectCacheTitle": "跳转与缓存", + "proxyRedirectCacheDescription": "限制可跟随的来源,并选择安全响应是否可以输出公开缓存头。", + "proxyRedirectModeLabel": "上游跳转", + "proxyRedirectMode": { + "manual": "将跳转返回客户端", + "follow": "跟随允许的来源" + }, + "proxyMaxHopsLabel": "最大跳转次数", + "proxyAllowedOriginsLabel": "额外允许的来源", + "proxyCacheModeLabel": "缓存模式", + "proxyCacheMode": { + "bypass": "私有且不缓存", + "public": "输出公开缓存头" + }, + "proxyPublicCacheCredentialWarning": "使用公共缓存时,请求 Cookie 和 Authorization 必须保持移除状态。", + "proxyBrowserTtlLabel": "浏览器缓存时间(秒)", + "proxyEdgeTtlLabel": "共享缓存时间(秒)", + "proxyLimitsTitle": "资源限制", + "proxyLimitsDescription": "限制等待上游的时间,以及为重放跳转而缓冲的请求体大小。", + "proxyTimeoutLabel": "请求超时(毫秒)", + "proxyBodyLimitLabel": "请求体上限(字节)", "analyticsIdLabel": "统计 ID", "analyticsIdTooltip": "用于稳定归因请求;即使路径或目标地址变化,该标识也保持不变。" } diff --git a/apps/webui/src/components/editor/route-entry/object-editor.tsx b/apps/webui/src/components/editor/route-entry/object-editor.tsx index 7cf9c89..df306bc 100644 --- a/apps/webui/src/components/editor/route-entry/object-editor.tsx +++ b/apps/webui/src/components/editor/route-entry/object-editor.tsx @@ -16,9 +16,12 @@ import { normalizePriority, normalizeStatus, setExclusiveDestination, + setRouteType, type DestinationKey, } from "@/composables/editor/route-utils"; +import { ProxyPolicyEditor } from "./proxy-policy-editor"; + interface RouteObjectEditorProps { value: Record; onChange: (next: Record) => void; @@ -51,10 +54,7 @@ export function RouteObjectEditor({ value={(value.type as string | undefined) ?? "prefix"} disabled={isReadOnly} onChange={(next) => { - const nextConfig: Record = { ...value, type: next }; - if (next === "proxy") delete nextConfig.status; - if (next === "exact") delete nextConfig.appendPath; - onChange(nextConfig); + onChange(setRouteType(value, next)); }} options={[ { value: "prefix", label: "prefix" }, @@ -168,6 +168,14 @@ export function RouteObjectEditor({ + {routeType === "proxy" ? ( + onChange({ ...value, proxyPolicy: next })} + isReadOnly={isReadOnly} + /> + ) : null} +
) => void; + isReadOnly: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function isProxyProfile(value: unknown): value is ProxyProfile { + return ( + typeof value === "string" + && (proxyProfiles as readonly string[]).includes(value) + ); +} + +function asStringList(value: unknown): string { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string").join(", ") + : ""; +} + +function parseStringList(value: string, uppercase = false): string[] | undefined { + const items = value + .split(/[,\n]/u) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => uppercase ? item.toUpperCase() : item); + return items.length > 0 ? [...new Set(items)] : undefined; +} + +function setNestedValue( + source: Record, + path: readonly string[], + value: unknown, +): Record { + const [key, ...rest] = path; + if (!key) return source; + + const next = { ...source }; + if (rest.length === 0) { + if (value === undefined || value === "") { + delete next[key]; + } else { + next[key] = value; + } + return next; + } + + const child = setNestedValue(asRecord(next[key]), rest, value); + if (Object.keys(child).length === 0) { + delete next[key]; + } else { + next[key] = child; + } + return next; +} + +function numericValue(value: unknown): string { + return typeof value === "number" && Number.isFinite(value) ? String(value) : ""; +} + +function parseOptionalNumber(value: string): number | undefined { + if (value.trim() === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function Section({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+ + + {title} + {description} + + + +
{children}
+
+ ); +} + +function Field({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
{label}
+ {children} +
+ ); +} + +export function ProxyPolicyEditor({ + value, + onChange, + isReadOnly, +}: ProxyPolicyEditorProps) { + const t = useTranslations("routeEntry"); + if (!isRecord(value)) { + return ( +
+

{t("proxyPolicyLegacyTitle")}

+

{t("proxyPolicyLegacyDescription")}

+ +
+ ); + } + + const policy = value; + const profile = isProxyProfile(policy.profile) + ? policy.profile + : "isolated"; + const requestPolicy = asRecord(policy.request); + const requestCookies = asRecord(requestPolicy.cookies); + const responsePolicy = asRecord(policy.response); + const responseCookies = asRecord(responsePolicy.cookies); + const cachePolicy = asRecord(policy.cache); + const redirectPolicy = asRecord(policy.redirects); + const limitPolicy = asRecord(policy.limits); + const forwardsCredentials = ( + requestPolicy.authorization === "preserve" + || requestCookies.mode === "allowlist" + ); + const inheritOption = { value: "", label: t("proxyPolicyInherit") }; + const update = (path: readonly string[], next: unknown) => { + onChange(setNestedValue(policy, path, next)); + }; + + return ( +
+
+ + update(["profile"], next)} + options={proxyProfiles.map((item) => ({ + value: item, + label: t(`proxyProfile.${item}`), + }))} + /> + +

+ {t(`proxyProfileDescription.${profile}`)} +

+
+ +
+ + update( + ["request", "methods"], + parseStringList(event.target.value, true), + )} + placeholder={t("proxyMethodsPlaceholder")} + className={formControlClassName({ className: "w-full" })} + /> + + + { + let nextPolicy = setNestedValue(policy, ["request", "cookies", "mode"], next); + if (next !== "allowlist") { + nextPolicy = setNestedValue(nextPolicy, ["request", "cookies", "names"], undefined); + } + onChange(nextPolicy); + }} + options={[ + inheritOption, + ...proxyCookieModes.map((item) => ({ + value: item, + label: t(`proxyCookieMode.${item}`), + })), + ]} + /> + + {requestCookies.mode === "allowlist" ? ( + + update( + ["request", "cookies", "names"], + parseStringList(event.target.value), + )} + placeholder={t("proxyCookieNamesPlaceholder")} + className={formControlClassName({ className: "w-full" })} + /> + + ) : null} + + update(["request", "authorization"], next)} + options={[ + inheritOption, + ...proxyCredentialModes.map((item) => ({ + value: item, + label: t(`proxyCredentialMode.${item}`), + })), + ]} + /> + + + update(["request", "origin"], next)} + options={[ + inheritOption, + ...proxySourceHeaderModes.map((item) => ({ + value: item, + label: t(`proxySourceHeaderMode.${item}`), + })), + ]} + /> + + + update(["request", "referer"], next)} + options={[ + inheritOption, + ...proxySourceHeaderModes.map((item) => ({ + value: item, + label: t(`proxySourceHeaderMode.${item}`), + })), + ]} + /> + +
+ +
+ + { + let nextPolicy = setNestedValue(policy, ["response", "cookies", "mode"], next); + if (next !== "allowlist") { + nextPolicy = setNestedValue(nextPolicy, ["response", "cookies", "names"], undefined); + nextPolicy = setNestedValue(nextPolicy, ["response", "cookies", "domain"], undefined); + nextPolicy = setNestedValue(nextPolicy, ["response", "cookies", "path"], undefined); + } + onChange(nextPolicy); + }} + options={[ + inheritOption, + ...proxyCookieModes.map((item) => ({ + value: item, + label: t(`proxyCookieMode.${item}`), + })), + ]} + /> + + {responseCookies.mode === "allowlist" ? ( + <> + + update( + ["response", "cookies", "names"], + parseStringList(event.target.value), + )} + placeholder={t("proxyCookieNamesPlaceholder")} + className={formControlClassName({ className: "w-full" })} + /> + + + update(["response", "cookies", "domain"], next)} + options={[ + inheritOption, + ...proxyResponseCookieAttributeModes.map((item) => ({ + value: item, + label: t(`proxyCookieAttributeMode.${item}`), + })), + ]} + /> + + + update(["response", "cookies", "path"], next)} + options={[ + inheritOption, + ...proxyResponseCookiePathModes.map((item) => ({ + value: item, + label: t(`proxyCookiePathMode.${item}`), + })), + ]} + /> + + + ) : null} +
+ +
+ + { + let nextPolicy = setNestedValue(policy, ["redirects", "mode"], next); + if (next === "manual" || next === "") { + nextPolicy = setNestedValue(nextPolicy, ["redirects", "maxHops"], undefined); + nextPolicy = setNestedValue(nextPolicy, ["redirects", "allowedOrigins"], undefined); + } + onChange(nextPolicy); + }} + options={[ + inheritOption, + ...proxyRedirectModes.map((item) => ({ + value: item, + label: t(`proxyRedirectMode.${item}`), + })), + ]} + /> + + {redirectPolicy.mode === "follow" ? ( + <> + + update( + ["redirects", "maxHops"], + parseOptionalNumber(event.target.value), + )} + placeholder="5" + className={formControlClassName({ className: "w-full" })} + /> + + + update( + ["redirects", "allowedOrigins"], + parseStringList(event.target.value), + )} + placeholder="https://api.example.com" + className={formControlClassName({ className: "w-full" })} + /> + + + ) : null} + + { + let nextPolicy = setNestedValue(policy, ["cache", "mode"], next); + if (next !== "public") { + nextPolicy = setNestedValue(nextPolicy, ["cache", "edgeTtlSeconds"], undefined); + nextPolicy = setNestedValue(nextPolicy, ["cache", "browserTtlSeconds"], undefined); + } + onChange(nextPolicy); + }} + options={[ + inheritOption, + ...proxyCacheModes.map((item) => ({ + value: item, + label: t(`proxyCacheMode.${item}`), + })), + ]} + /> + + {cachePolicy.mode === "public" && forwardsCredentials ? ( +

+ {t("proxyPublicCacheCredentialWarning")} +

+ ) : null} + {cachePolicy.mode === "public" ? ( + <> + + update( + ["cache", "browserTtlSeconds"], + parseOptionalNumber(event.target.value), + )} + className={formControlClassName({ className: "w-full" })} + /> + + + update( + ["cache", "edgeTtlSeconds"], + parseOptionalNumber(event.target.value), + )} + className={formControlClassName({ className: "w-full" })} + /> + + + ) : null} +
+ +
+ + update( + ["limits", "timeoutMs"], + parseOptionalNumber(event.target.value), + )} + placeholder="10000" + className={formControlClassName({ className: "w-full" })} + /> + + + update( + ["limits", "maxRequestBodyBytes"], + parseOptionalNumber(event.target.value), + )} + placeholder="1048576" + className={formControlClassName({ className: "w-full" })} + /> + +
+
+ ); +} diff --git a/apps/webui/src/components/ui/controls/dropdown-select.tsx b/apps/webui/src/components/ui/controls/dropdown-select.tsx index a526bde..a2c54be 100644 --- a/apps/webui/src/components/ui/controls/dropdown-select.tsx +++ b/apps/webui/src/components/ui/controls/dropdown-select.tsx @@ -12,12 +12,14 @@ export function DropdownSelect({ onChange, className, disabled = false, + ariaLabel, }: { value: string; options: DropdownOption[]; onChange: (next: string) => void; className?: string; disabled?: boolean; + ariaLabel?: string; }) { const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -49,6 +51,7 @@ export function DropdownSelect({ type="button" onClick={() => setOpen((previous) => !previous)} disabled={disabled} + aria-label={ariaLabel} className={formControlClassName({ className: "relative w-full pl-3.5 pr-10 text-left disabled:cursor-default disabled:bg-panel-muted disabled:text-muted", })} diff --git a/apps/webui/src/composables/editor/route-utils.ts b/apps/webui/src/composables/editor/route-utils.ts index b4be9d2..666fcb4 100644 --- a/apps/webui/src/composables/editor/route-utils.ts +++ b/apps/webui/src/composables/editor/route-utils.ts @@ -103,3 +103,22 @@ export function setExclusiveDestination( next[key] = value; return next; } + +export function setRouteType( + config: Record, + nextType: string, +): Record { + const next: Record = { ...config, type: nextType }; + if (nextType === "proxy") { + delete next.status; + if (next.proxyPolicy === undefined) { + next.proxyPolicy = { profile: "isolated" }; + } + } else { + delete next.proxyPolicy; + } + if (nextType === "exact") { + delete next.appendPath; + } + return next; +} diff --git a/apps/webui/tests/analytics-id.test.ts b/apps/webui/tests/analytics-id.test.ts index ace9a96..7a69b8f 100644 --- a/apps/webui/tests/analytics-id.test.ts +++ b/apps/webui/tests/analytics-id.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { createDeterministicAnalyticsId, ensureAnalyticsId, + setRouteType, } from "../src/composables/editor/route-utils"; import { buildConfig, @@ -66,6 +67,51 @@ test("persists hydrated analytics IDs through config serialization", async () => ); }); +test("defaults new proxy rules to isolated and removes proxy policy from redirects", () => { + const proxy = setRouteType({ + analyticsId: "eb5deba4-32b7-476f-b7f3-4b5c598a397c", + type: "prefix", + target: "https://example.com", + appendPath: true, + status: 302, + }, "proxy"); + assert.deepEqual(proxy.proxyPolicy, { profile: "isolated" }); + assert.equal(proxy.status, undefined); + + const redirect = setRouteType(proxy, "exact"); + assert.equal(redirect.proxyPolicy, undefined); + assert.equal(redirect.appendPath, undefined); +}); + +test("preserves proxy policy fields through serialization", async () => { + const source = JSON.stringify({ + Slots: { + Main: { + "/api": { + analyticsId: "eb5deba4-32b7-476f-b7f3-4b5c598a397c", + type: "proxy", + target: "https://api.example.com", + proxyPolicy: { + profile: "trusted-api", + request: { + cookies: { + mode: "allowlist", + names: ["session"], + }, + }, + }, + }, + }, + }, + }); + const parsed = await parseInitialContent(source); + + assert.deepEqual( + buildConfig(parsed.rootGroup, parsed.baseConfig, parsed.slotsKey), + JSON.parse(source), + ); +}); + test("keeps a missing analytics ID stable across destination edits", async () => { async function readGeneratedId(target: string) { const parsed = await parseInitialContent(JSON.stringify({ diff --git a/apps/webui/tests/redirect-config-validation.test.ts b/apps/webui/tests/redirect-config-validation.test.ts index 1e37252..2d4df02 100644 --- a/apps/webui/tests/redirect-config-validation.test.ts +++ b/apps/webui/tests/redirect-config-validation.test.ts @@ -85,6 +85,141 @@ test("keeps Runtime proxy validation aligned with the explicit URL schema", () = assert.equal(result.status, "invalid"); }); +test("accepts a structured proxy policy", () => { + const result = validateRedirectConfig({ + Slots: { + Main: { + "/api": { + type: "proxy", + target: "https://api.example.com", + proxyPolicy: { + profile: "trusted-api", + request: { + methods: ["GET", "POST"], + cookies: { + mode: "allowlist", + names: ["session"], + }, + authorization: "preserve", + origin: "preserve", + }, + response: { + cookies: { + mode: "allowlist", + names: ["session"], + domain: "remove", + path: "proxy-base", + }, + securityHeaders: "preserve", + }, + cache: { + mode: "bypass", + }, + redirects: { + mode: "follow", + maxHops: 3, + allowedOrigins: ["https://cdn.example"], + }, + limits: { + timeoutMs: 5_000, + maxRequestBodyBytes: 1_048_576, + }, + }, + }, + }, + }, + }); + + assert.equal(result.status, "valid"); +}); + +test("rejects proxy policies on redirects and unsafe nested overrides", () => { + const redirectResult = validateRedirectConfig({ + Slots: { + Main: { + "/docs": { + type: "exact", + target: "https://example.com/docs", + proxyPolicy: { + profile: "isolated", + }, + }, + }, + }, + }); + assert.equal(redirectResult.status, "invalid"); + + const proxyResult = validateRedirectConfig({ + Slots: { + Main: { + "/proxy": { + type: "proxy", + target: "https://example.com", + proxyPolicy: { + profile: "trusted-api", + request: { + cookies: { + mode: "allowlist", + }, + }, + redirects: { + mode: "follow", + allowedOrigins: ["https://example.com/path"], + }, + }, + }, + }, + }, + }); + assert.equal(proxyResult.status, "invalid"); +}); + +test("rejects credential-bearing public caches and unsupported proxy methods", () => { + const invalidPolicies = [ + { + profile: "trusted-api", + request: { authorization: "preserve" }, + cache: { mode: "public" }, + }, + { + profile: "trusted-api", + request: { + cookies: { mode: "allowlist", names: ["session"] }, + }, + cache: { mode: "public" }, + }, + { + profile: "trusted-api", + request: { methods: ["CONNECT"] }, + }, + { + profile: "trusted-api", + response: { + cookies: { + mode: "strip", + domain: "preserve", + }, + }, + }, + ]; + + for (const proxyPolicy of invalidPolicies) { + const result = validateRedirectConfig({ + Slots: { + Main: { + "/proxy": { + type: "proxy", + target: "https://example.com", + proxyPolicy, + }, + }, + }, + }); + + assert.equal(result.status, "invalid"); + } +}); + test("rejects priorities outside the JavaScript safe integer range", () => { for (const priority of [Number.MAX_SAFE_INTEGER + 1, "9007199254740992"]) { const result = validateRedirectConfig({ diff --git a/packages/config/README.md b/packages/config/README.md index 60edde1..d394aa7 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -4,7 +4,7 @@ Shared data contracts and bootstrap package for Runtime and WebUI configuration. Normal instance settings live in `config.json` on the `data` branch and are validated by [config.schema.json](config.schema.json) plus the edge-compatible validator in [src/validation.ts](src/validation.ts). Runtime and WebUI fetch the document remotely, so valid updates do not require rebuilding either application. -Redirect rules live beside it in `redirects.json` and are described by [redirects.schema.json](redirects.schema.json). Keeping both data document schemas here avoids coupling the WebUI editor to the Runtime application package. +Redirect rules live beside it in `redirects.json` and are described by [redirects.schema.json](redirects.schema.json), including the shared `proxyPolicy` contract used by Runtime and WebUI. Keeping both data document schemas here avoids coupling the WebUI editor to the Runtime application package. [src/defaults.ts](src/defaults.ts) contains only the safe fallback and bootstrap values needed before remote configuration can be loaded: the GitHub repository, data branch, document paths, and OAuth scope. Change these values only when moving the source itself; bootstrap changes require rebuilding consumers. diff --git a/packages/config/README.zh-CN.md b/packages/config/README.zh-CN.md index 2dad5e9..d2d09da 100644 --- a/packages/config/README.zh-CN.md +++ b/packages/config/README.zh-CN.md @@ -4,7 +4,7 @@ Runtime 与 WebUI 共用的数据契约和启动配置包。 普通实例配置存放在 `data` 分支的 `config.json`,并由 [config.schema.json](config.schema.json) 与 [src/validation.ts](src/validation.ts) 中兼容边缘环境的校验器共同约束。Runtime 与 WebUI 会远程读取该文档,因此有效更新不需要重新构建应用。 -路由规则存放在同一分支的 `redirects.json`,其结构由 [redirects.schema.json](redirects.schema.json) 描述。两份 data 文档的 Schema 统一归入此包,可避免 WebUI 编辑器反向依赖 Runtime 应用包。 +路由规则存放在同一分支的 `redirects.json`,其结构由 [redirects.schema.json](redirects.schema.json) 描述,其中也包含 Runtime 与 WebUI 共用的 `proxyPolicy` 契约。两份 data 文档的 Schema 统一归入此包,可避免 WebUI 编辑器反向依赖 Runtime 应用包。 [src/defaults.ts](src/defaults.ts) 只保存远程配置加载前必须存在的安全回退值和启动信息:GitHub 仓库、data 分支、文档路径与 OAuth scope。只有迁移数据源本身时才修改这些值;启动配置变化需要重新构建消费方。 diff --git a/packages/config/redirects.schema.json b/packages/config/redirects.schema.json index 6e1d047..cfee074 100644 --- a/packages/config/redirects.schema.json +++ b/packages/config/redirects.schema.json @@ -48,6 +48,316 @@ "minLength": 1, "description": "Shortcut form equivalent to a prefix redirect rule." }, + "proxyCookiePolicy": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["strip", "allowlist"] + }, + "names": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" + } + } + }, + "required": ["mode"], + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "allowlist" } + }, + "required": ["mode"] + }, + "then": { + "required": ["names"] + }, + "else": { + "not": { "required": ["names"] } + } + } + ], + "additionalProperties": false + }, + "proxyResponseCookiePolicy": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["strip", "allowlist"] + }, + "names": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" + } + }, + "domain": { + "type": "string", + "enum": ["remove", "preserve"] + }, + "path": { + "type": "string", + "enum": ["remove", "preserve", "proxy-base"] + } + }, + "required": ["mode"], + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "allowlist" } + }, + "required": ["mode"] + }, + "then": { + "required": ["names"] + }, + "else": { + "not": { + "anyOf": [ + { "required": ["names"] }, + { "required": ["domain"] }, + { "required": ["path"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "proxyRequestPolicy": { + "type": "object", + "properties": { + "methods": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[!#$%&'*+.^_`|~0-9A-Z-]+$", + "not": { + "enum": ["CONNECT", "TRACE", "TRACK"] + } + } + }, + "cookies": { + "$ref": "#/definitions/proxyCookiePolicy" + }, + "authorization": { + "type": "string", + "enum": ["strip", "preserve"] + }, + "origin": { + "type": "string", + "enum": ["strip", "preserve", "target"] + }, + "referer": { + "type": "string", + "enum": ["strip", "preserve", "target"] + }, + "clientIp": { + "const": "strip" + } + }, + "additionalProperties": false + }, + "proxyResponsePolicy": { + "type": "object", + "properties": { + "cookies": { + "$ref": "#/definitions/proxyResponseCookiePolicy" + }, + "securityHeaders": { + "const": "preserve" + } + }, + "additionalProperties": false + }, + "proxyCachePolicy": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["bypass", "public"] + }, + "edgeTtlSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 31536000 + }, + "browserTtlSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 31536000 + } + }, + "required": ["mode"], + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "bypass" } + }, + "required": ["mode"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["edgeTtlSeconds"] }, + { "required": ["browserTtlSeconds"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "proxyRedirectPolicy": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["manual", "follow"] + }, + "maxHops": { + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "allowedOrigins": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uri", + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?![^/?#]*@)[^/?#]+/?$" + } + } + }, + "required": ["mode"], + "allOf": [ + { + "if": { + "properties": { + "mode": { "const": "manual" } + }, + "required": ["mode"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["maxHops"] }, + { "required": ["allowedOrigins"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "proxyLimitPolicy": { + "type": "object", + "properties": { + "timeoutMs": { + "type": "integer", + "minimum": 100, + "maximum": 60000 + }, + "maxRequestBodyBytes": { + "type": "integer", + "minimum": 0, + "maximum": 10485760 + } + }, + "additionalProperties": false + }, + "proxyPolicy": { + "type": "object", + "properties": { + "profile": { + "type": "string", + "enum": ["isolated", "asset", "trusted-api"] + }, + "request": { + "$ref": "#/definitions/proxyRequestPolicy" + }, + "response": { + "$ref": "#/definitions/proxyResponsePolicy" + }, + "cache": { + "$ref": "#/definitions/proxyCachePolicy" + }, + "redirects": { + "$ref": "#/definitions/proxyRedirectPolicy" + }, + "limits": { + "$ref": "#/definitions/proxyLimitPolicy" + } + }, + "required": ["profile"], + "allOf": [ + { + "if": { + "properties": { + "cache": { + "properties": { + "mode": { "const": "public" } + }, + "required": ["mode"] + } + }, + "required": ["cache"] + }, + "then": { + "not": { + "anyOf": [ + { + "properties": { + "request": { + "properties": { + "authorization": { "const": "preserve" } + }, + "required": ["authorization"] + } + }, + "required": ["request"] + }, + { + "properties": { + "request": { + "properties": { + "cookies": { + "properties": { + "mode": { "const": "allowlist" } + }, + "required": ["mode"] + } + }, + "required": ["cookies"] + } + }, + "required": ["request"] + } + ] + } + } + } + ], + "additionalProperties": false + }, "routeConfig": { "type": "object", "description": "Explicit redirect or proxy configuration.", @@ -111,6 +421,10 @@ "pattern": "^-?[0-9]+$" } ] + }, + "proxyPolicy": { + "$ref": "#/definitions/proxyPolicy", + "description": "Security, credential, redirect, cache, and request-limit policy for proxy routes." } }, "allOf": [ @@ -152,6 +466,17 @@ "then": { "not": { "required": ["appendPath"] } } + }, + { + "if": { + "required": ["proxyPolicy"] + }, + "then": { + "properties": { + "type": { "const": "proxy" } + }, + "required": ["type"] + } } ], "oneOf": [ diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2c58916..da365fc 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,5 +1,16 @@ export { bootstrapConfig, defaultDataConfig } from "./defaults" export { assertBootstrapConfigCompatibility } from "./bootstrap-validation" +export { + proxyCacheModes, + proxyCookieModes, + proxyCredentialModes, + proxyProfiles, + proxyRedirectModes, + proxyResponseCookieAttributeModes, + proxyResponseCookiePathModes, + proxySecurityHeadersModes, + proxySourceHeaderModes, +} from "./proxy-policy" export { DataDocumentNotFoundError, DataRepositoryConflictError, @@ -39,6 +50,23 @@ export type { JsonValue, PluginInstanceConfig, PostgresDataRepositoryBootstrapConfig, + ProxyCacheMode, + ProxyCachePolicy, + ProxyCookieMode, + ProxyCookiePolicy, + ProxyCredentialMode, + ProxyLimitPolicy, + ProxyPolicy, + ProxyProfile, + ProxyRedirectMode, + ProxyRedirectPolicy, + ProxyRequestPolicy, + ProxyResponseCookieAttributeMode, + ProxyResponseCookiePathMode, + ProxyResponseCookiePolicy, + ProxyResponsePolicy, + ProxySecurityHeadersMode, + ProxySourceHeaderMode, RedirectsConfig, RobotsPolicy, RuntimeDataSourceBootstrapConfig, diff --git a/packages/config/src/proxy-policy-validation.ts b/packages/config/src/proxy-policy-validation.ts new file mode 100644 index 0000000..e10ed19 --- /dev/null +++ b/packages/config/src/proxy-policy-validation.ts @@ -0,0 +1,465 @@ +import { + proxyCacheModes, + proxyCookieModes, + proxyCredentialModes, + proxyProfiles, + proxyRedirectModes, + proxyResponseCookieAttributeModes, + proxyResponseCookiePathModes, + proxySecurityHeadersModes, + proxySourceHeaderModes, +} from "./proxy-policy" + +interface ValidationIssue { + message: string + path: string +} + +const proxyPolicyKeys = new Set([ + "profile", + "request", + "response", + "cache", + "redirects", + "limits", +]) +const proxyRequestKeys = new Set([ + "methods", + "cookies", + "authorization", + "origin", + "referer", + "clientIp", +]) +const proxyResponseKeys = new Set(["cookies", "securityHeaders"]) +const proxyCookieKeys = new Set(["mode", "names"]) +const proxyResponseCookieKeys = new Set([ + "mode", + "names", + "domain", + "path", +]) +const proxyCacheKeys = new Set([ + "mode", + "edgeTtlSeconds", + "browserTtlSeconds", +]) +const proxyRedirectKeys = new Set([ + "mode", + "maxHops", + "allowedOrigins", +]) +const proxyLimitKeys = new Set(["timeoutMs", "maxRequestBodyBytes"]) +const httpTokenPattern = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i +const forbiddenFetchMethods = new Set(["CONNECT", "TRACE", "TRACK"]) + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function escapeJsonPointerSegment(value: string): string { + return value.replaceAll("~", "~0").replaceAll("/", "~1") +} + +function addIssue( + issues: ValidationIssue[], + path: string, + message: string, +): void { + issues.push({ path, message }) +} + +function validateAllowedKeys( + value: Record, + allowedKeys: ReadonlySet, + path: string, + issues: ValidationIssue[], +): void { + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + addIssue(issues, `${path}/${escapeJsonPointerSegment(key)}`, "property is not allowed") + } + } +} + +function validateEnumValue( + value: unknown, + allowedValues: readonly string[], + path: string, + issues: ValidationIssue[], +): value is string { + if (typeof value !== "string" || !allowedValues.includes(value)) { + addIssue(issues, path, `must be one of ${allowedValues.join(", ")}`) + return false + } + return true +} + +function validateBoundedInteger( + value: unknown, + minimum: number, + maximum: number, + path: string, + issues: ValidationIssue[], +): void { + if ( + typeof value !== "number" + || !Number.isInteger(value) + || value < minimum + || value > maximum + ) { + addIssue(issues, path, `must be an integer from ${minimum} to ${maximum}`) + } +} + +function validateUniqueStringArray( + value: unknown, + path: string, + issues: ValidationIssue[], + itemValidator: (item: string) => boolean, + itemMessage: string, +): value is string[] { + if (!Array.isArray(value) || value.length === 0) { + addIssue(issues, path, "must be a non-empty array") + return false + } + + const seen = new Set() + value.forEach((item, index) => { + const itemPath = `${path}/${index}` + if (typeof item !== "string" || !itemValidator(item)) { + addIssue(issues, itemPath, itemMessage) + return + } + if (seen.has(item)) { + addIssue(issues, itemPath, "must be unique") + return + } + seen.add(item) + }) + return true +} + +function isAbsoluteHttpOrigin(value: string): boolean { + if (!/^https?:\/\//iu.test(value)) { + return false + } + + try { + const url = new URL(value) + return ( + (url.protocol === "http:" || url.protocol === "https:") + && !url.username + && !url.password + && url.pathname === "/" + && !url.search + && !url.hash + ) + } catch { + return false + } +} + +function validateProxyCookiePolicy( + value: unknown, + path: string, + issues: ValidationIssue[], + isResponse: boolean, +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys( + value, + isResponse ? proxyResponseCookieKeys : proxyCookieKeys, + path, + issues, + ) + + const modeIsValid = validateEnumValue( + value.mode, + proxyCookieModes, + `${path}/mode`, + issues, + ) + if (value.names !== undefined) { + validateUniqueStringArray( + value.names, + `${path}/names`, + issues, + (item) => httpTokenPattern.test(item), + "must be a valid cookie name", + ) + } + if (modeIsValid && value.mode === "allowlist" && value.names === undefined) { + addIssue(issues, `${path}/names`, "is required when mode is allowlist") + } + if (modeIsValid && value.mode === "strip" && value.names !== undefined) { + addIssue(issues, `${path}/names`, "is not allowed when mode is strip") + } + if ( + isResponse + && modeIsValid + && value.mode === "strip" + && (value.domain !== undefined || value.path !== undefined) + ) { + addIssue( + issues, + path, + "domain and path are not allowed when response cookies are stripped", + ) + } + + if (!isResponse) { + return + } + if (value.domain !== undefined) { + validateEnumValue( + value.domain, + proxyResponseCookieAttributeModes, + `${path}/domain`, + issues, + ) + } + if (value.path !== undefined) { + validateEnumValue( + value.path, + proxyResponseCookiePathModes, + `${path}/path`, + issues, + ) + } +} + +function validateProxyRequestPolicy( + value: unknown, + path: string, + issues: ValidationIssue[], +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys(value, proxyRequestKeys, path, issues) + + if (value.methods !== undefined) { + validateUniqueStringArray( + value.methods, + `${path}/methods`, + issues, + (item) => ( + httpTokenPattern.test(item) + && item === item.toUpperCase() + && !forbiddenFetchMethods.has(item) + ), + "must be an uppercase Fetch-compatible HTTP method token", + ) + } + if (value.cookies !== undefined) { + validateProxyCookiePolicy(value.cookies, `${path}/cookies`, issues, false) + } + if (value.authorization !== undefined) { + validateEnumValue( + value.authorization, + proxyCredentialModes, + `${path}/authorization`, + issues, + ) + } + if (value.origin !== undefined) { + validateEnumValue( + value.origin, + proxySourceHeaderModes, + `${path}/origin`, + issues, + ) + } + if (value.referer !== undefined) { + validateEnumValue( + value.referer, + proxySourceHeaderModes, + `${path}/referer`, + issues, + ) + } + if (value.clientIp !== undefined && value.clientIp !== "strip") { + addIssue(issues, `${path}/clientIp`, "must be strip") + } +} + +function validateProxyResponsePolicy( + value: unknown, + path: string, + issues: ValidationIssue[], +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys(value, proxyResponseKeys, path, issues) + + if (value.cookies !== undefined) { + validateProxyCookiePolicy(value.cookies, `${path}/cookies`, issues, true) + } + if (value.securityHeaders !== undefined) { + validateEnumValue( + value.securityHeaders, + proxySecurityHeadersModes, + `${path}/securityHeaders`, + issues, + ) + } +} + +function validateProxyCachePolicy( + value: unknown, + path: string, + issues: ValidationIssue[], +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys(value, proxyCacheKeys, path, issues) + + const modeIsValid = validateEnumValue( + value.mode, + proxyCacheModes, + `${path}/mode`, + issues, + ) + for (const key of ["edgeTtlSeconds", "browserTtlSeconds"] as const) { + if (value[key] !== undefined) { + validateBoundedInteger( + value[key], + 0, + 31_536_000, + `${path}/${key}`, + issues, + ) + } + } + if ( + modeIsValid + && value.mode === "bypass" + && (value.edgeTtlSeconds !== undefined || value.browserTtlSeconds !== undefined) + ) { + addIssue(issues, path, "cache TTLs are not allowed when mode is bypass") + } +} + +function validateProxyRedirectPolicy( + value: unknown, + path: string, + issues: ValidationIssue[], +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys(value, proxyRedirectKeys, path, issues) + + const modeIsValid = validateEnumValue( + value.mode, + proxyRedirectModes, + `${path}/mode`, + issues, + ) + if (value.maxHops !== undefined) { + validateBoundedInteger(value.maxHops, 0, 10, `${path}/maxHops`, issues) + } + if (value.allowedOrigins !== undefined) { + validateUniqueStringArray( + value.allowedOrigins, + `${path}/allowedOrigins`, + issues, + isAbsoluteHttpOrigin, + "must be an absolute HTTP(S) origin without credentials, path, query, or fragment", + ) + } + if ( + modeIsValid + && value.mode === "manual" + && (value.maxHops !== undefined || value.allowedOrigins !== undefined) + ) { + addIssue( + issues, + path, + "maxHops and allowedOrigins are not allowed when mode is manual", + ) + } +} + +function validateProxyLimitPolicy( + value: unknown, + path: string, + issues: ValidationIssue[], +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys(value, proxyLimitKeys, path, issues) + + if (value.timeoutMs !== undefined) { + validateBoundedInteger(value.timeoutMs, 100, 60_000, `${path}/timeoutMs`, issues) + } + if (value.maxRequestBodyBytes !== undefined) { + validateBoundedInteger( + value.maxRequestBodyBytes, + 0, + 10_485_760, + `${path}/maxRequestBodyBytes`, + issues, + ) + } +} + +export function validateProxyPolicy( + value: unknown, + path: string, + issues: ValidationIssue[], +): void { + if (!isRecord(value)) { + addIssue(issues, path, "must be an object") + return + } + validateAllowedKeys(value, proxyPolicyKeys, path, issues) + validateEnumValue(value.profile, proxyProfiles, `${path}/profile`, issues) + + if (value.request !== undefined) { + validateProxyRequestPolicy(value.request, `${path}/request`, issues) + } + if (value.response !== undefined) { + validateProxyResponsePolicy(value.response, `${path}/response`, issues) + } + if (value.cache !== undefined) { + validateProxyCachePolicy(value.cache, `${path}/cache`, issues) + } + if (value.redirects !== undefined) { + validateProxyRedirectPolicy(value.redirects, `${path}/redirects`, issues) + } + if (value.limits !== undefined) { + validateProxyLimitPolicy(value.limits, `${path}/limits`, issues) + } + + const request = isRecord(value.request) ? value.request : null + const requestCookies = request && isRecord(request.cookies) + ? request.cookies + : null + const cache = isRecord(value.cache) ? value.cache : null + if ( + cache?.mode === "public" + && ( + request?.authorization === "preserve" + || requestCookies?.mode === "allowlist" + ) + ) { + addIssue( + issues, + `${path}/cache/mode`, + "public cache requires request cookies and authorization to remain stripped", + ) + } +} diff --git a/packages/config/src/proxy-policy.ts b/packages/config/src/proxy-policy.ts new file mode 100644 index 0000000..244c7b2 --- /dev/null +++ b/packages/config/src/proxy-policy.ts @@ -0,0 +1,58 @@ +import type { + ProxyCacheMode, + ProxyCookieMode, + ProxyCredentialMode, + ProxyProfile, + ProxyRedirectMode, + ProxyResponseCookieAttributeMode, + ProxyResponseCookiePathMode, + ProxySecurityHeadersMode, + ProxySourceHeaderMode, +} from "./types" + +export const proxyProfiles = [ + "isolated", + "asset", + "trusted-api", +] as const satisfies readonly ProxyProfile[] + +export const proxyCookieModes = [ + "strip", + "allowlist", +] as const satisfies readonly ProxyCookieMode[] + +export const proxyCredentialModes = [ + "strip", + "preserve", +] as const satisfies readonly ProxyCredentialMode[] + +export const proxySourceHeaderModes = [ + "strip", + "preserve", + "target", +] as const satisfies readonly ProxySourceHeaderMode[] + +export const proxyResponseCookieAttributeModes = [ + "remove", + "preserve", +] as const satisfies readonly ProxyResponseCookieAttributeMode[] + +export const proxyResponseCookiePathModes = [ + "remove", + "preserve", + "proxy-base", +] as const satisfies readonly ProxyResponseCookiePathMode[] + +export const proxySecurityHeadersModes = [ + "preserve", +] as const satisfies readonly ProxySecurityHeadersMode[] + +export const proxyCacheModes = [ + "bypass", + "public", +] as const satisfies readonly ProxyCacheMode[] + +export const proxyRedirectModes = [ + "manual", + "follow", +] as const satisfies readonly ProxyRedirectMode[] diff --git a/packages/config/src/redirects-validation.ts b/packages/config/src/redirects-validation.ts index 1461509..d45989b 100644 --- a/packages/config/src/redirects-validation.ts +++ b/packages/config/src/redirects-validation.ts @@ -1,3 +1,4 @@ +import { validateProxyPolicy } from "./proxy-policy-validation" import type { RedirectsConfig } from "./types" export interface RedirectsConfigValidationIssue { @@ -26,6 +27,7 @@ const routeConfigKeys = new Set([ "appendPath", "status", "priority", + "proxyPolicy", ]) const routeTypes = new Set(["prefix", "exact", "proxy"]) const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -139,6 +141,14 @@ function validateRouteConfig( addIssue(issues, `${path}/type`, "must be prefix, exact, or proxy") } + if (value.proxyPolicy !== undefined) { + if (value.type !== "proxy") { + addIssue(issues, `${path}/proxyPolicy`, "is only allowed for proxy routes") + } else { + validateProxyPolicy(value.proxyPolicy, `${path}/proxyPolicy`, issues) + } + } + if (value.appendPath !== undefined && typeof value.appendPath !== "boolean") { addIssue(issues, `${path}/appendPath`, "must be a boolean") } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 658d032..6915524 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -1,5 +1,14 @@ export type RobotsPolicy = "allow" | "disallow" export type WebUiAccessMode = "authenticated" | "allowlist" | "public-readonly" +export type ProxyProfile = "isolated" | "asset" | "trusted-api" +export type ProxyCookieMode = "strip" | "allowlist" +export type ProxyCredentialMode = "strip" | "preserve" +export type ProxySourceHeaderMode = "strip" | "preserve" | "target" +export type ProxyResponseCookieAttributeMode = "remove" | "preserve" +export type ProxyResponseCookiePathMode = ProxyResponseCookieAttributeMode | "proxy-base" +export type ProxySecurityHeadersMode = "preserve" +export type ProxyCacheMode = "bypass" | "public" +export type ProxyRedirectMode = "manual" | "follow" export type JsonPrimitive = boolean | number | string | null export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue } @@ -14,6 +23,56 @@ export interface RedirectsConfig { [key: string]: unknown } +export interface ProxyCookiePolicy { + mode: ProxyCookieMode + names?: readonly string[] +} + +export interface ProxyRequestPolicy { + methods?: readonly string[] + cookies?: ProxyCookiePolicy + authorization?: ProxyCredentialMode + origin?: ProxySourceHeaderMode + referer?: ProxySourceHeaderMode + clientIp?: "strip" +} + +export interface ProxyResponseCookiePolicy extends ProxyCookiePolicy { + domain?: ProxyResponseCookieAttributeMode + path?: ProxyResponseCookiePathMode +} + +export interface ProxyResponsePolicy { + cookies?: ProxyResponseCookiePolicy + securityHeaders?: ProxySecurityHeadersMode +} + +export interface ProxyCachePolicy { + mode: ProxyCacheMode + edgeTtlSeconds?: number + browserTtlSeconds?: number +} + +export interface ProxyRedirectPolicy { + mode: ProxyRedirectMode + maxHops?: number + allowedOrigins?: readonly string[] +} + +export interface ProxyLimitPolicy { + timeoutMs?: number + maxRequestBodyBytes?: number +} + +export interface ProxyPolicy { + profile: ProxyProfile + request?: ProxyRequestPolicy + response?: ProxyResponsePolicy + cache?: ProxyCachePolicy + redirects?: ProxyRedirectPolicy + limits?: ProxyLimitPolicy +} + export interface DataSourceTarget { owner: string repository: string