From fd72c58c5f2b8d44c57503490df67cbc266a9f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=94=E6=80=9D=E5=85=94?= Date: Wed, 29 Jul 2026 02:24:41 +0800 Subject: [PATCH 1/3] feat(redirects): simplify proxy and rule editing - Restore transparent proxy forwarding with rollback-compatible policy parsing - Save database-backed rule edits from dialogs while retaining Git manual saves - Present routes as descriptive cards with target favicons and actions --- apps/runtime/README.md | 50 +- apps/runtime/README.zh-CN.md | 44 +- .../src/lib/handlers/analytics/events.ts | 11 +- .../src/lib/handlers/analytics/index.ts | 2 +- apps/runtime/src/lib/handlers/core/types.ts | 31 +- apps/runtime/src/lib/handlers/core/utils.ts | 2 +- .../src/lib/handlers/routing/dispatcher.ts | 6 +- .../src/lib/handlers/routing/matcher.ts | 68 +-- .../src/lib/handlers/routing/proxy/headers.ts | 362 ------------ .../src/lib/handlers/routing/proxy/policy.ts | 302 ---------- .../src/lib/handlers/routing/proxy/request.ts | 383 ------------ .../src/lib/handlers/routing/proxy/safety.ts | 97 --- .../src/lib/handlers/routing/response.ts | 351 ++++++++++- .../analytics/analytics-orchestration.test.ts | 14 +- .../analytics/analytics-resilience.test.ts | 14 +- .../runtime/tests/handlers/dispatcher.test.ts | 6 +- apps/runtime/tests/handlers/matcher.test.ts | 14 +- .../tests/handlers/proxy-policy.test.ts | 421 -------------- .../tests/handlers/response-security.test.ts | 125 +++- apps/webui/README.md | 6 +- apps/webui/README.zh-CN.md | 6 +- apps/webui/messages/en/redirects.json | 93 +-- apps/webui/messages/zh-CN/redirects.json | 93 +-- .../editor/group-entries-editor.tsx | 163 +++--- .../editor/new-route-entry-dialog.tsx | 102 +++- .../src/components/editor/right-panel.tsx | 88 +-- .../route-entry/edit-route-entry-dialog.tsx | 233 ++++++++ .../editor/route-entry/object-editor.tsx | 20 +- .../route-entry/proxy-policy-editor.tsx | 550 ------------------ .../editor/route-entry/quick-editor.tsx | 4 - .../editor/route-entry/route-entry-card.tsx | 155 +++++ .../editor/route-entry/route-entry-editor.tsx | 2 - .../editor/route-entry/target-favicon.tsx | 40 ++ .../src/components/redirects-groups/index.tsx | 121 +++- .../manager-sidebar-catalog.tsx | 35 +- .../redirects-groups-page.tsx | 1 + .../ui/controls/dropdown-select.tsx | 3 - .../components/ui/controls/form-control.ts | 3 +- .../src/components/ui/feedback/app-dialog.tsx | 9 +- .../ui/feedback/confirmation-dialog.tsx | 25 +- .../src/composables/editor/route-utils.ts | 90 ++- .../redirects-groups/config-file.ts | 20 +- .../redirects-groups/editor-state.ts | 21 +- .../src/composables/redirects-groups/index.ts | 160 +++-- .../webui/src/lib/data/editor-capabilities.ts | 4 + apps/webui/tests/analytics-id.test.ts | 46 -- .../tests/redirect-config-validation.test.ts | 183 ++---- .../tests/redirects-editor-state.test.ts | 46 ++ .../repository-editor-capabilities.test.ts | 11 + apps/webui/tests/route-description.test.ts | 84 +++ packages/config/README.md | 2 +- packages/config/README.zh-CN.md | 2 +- packages/config/redirects.schema.json | 335 +---------- packages/config/src/index.ts | 28 - .../config/src/proxy-policy-validation.ts | 465 --------------- packages/config/src/proxy-policy.ts | 58 -- packages/config/src/redirects-validation.ts | 22 +- packages/config/src/types.ts | 59 -- plugins/source/github/src/manifest.ts | 1 + 59 files changed, 1774 insertions(+), 3918 deletions(-) delete mode 100644 apps/runtime/src/lib/handlers/routing/proxy/headers.ts delete mode 100644 apps/runtime/src/lib/handlers/routing/proxy/policy.ts delete mode 100644 apps/runtime/src/lib/handlers/routing/proxy/request.ts delete mode 100644 apps/runtime/src/lib/handlers/routing/proxy/safety.ts delete mode 100644 apps/runtime/tests/handlers/proxy-policy.test.ts create mode 100644 apps/webui/src/components/editor/route-entry/edit-route-entry-dialog.tsx delete mode 100644 apps/webui/src/components/editor/route-entry/proxy-policy-editor.tsx create mode 100644 apps/webui/src/components/editor/route-entry/route-entry-card.tsx create mode 100644 apps/webui/src/components/editor/route-entry/target-favicon.tsx create mode 100644 apps/webui/tests/redirects-editor-state.test.ts create mode 100644 apps/webui/tests/route-description.test.ts delete mode 100644 packages/config/src/proxy-policy-validation.ts delete mode 100644 packages/config/src/proxy-policy.ts diff --git a/apps/runtime/README.md b/apps/runtime/README.md index e153b37..db08f9b 100644 --- a/apps/runtime/README.md +++ b/apps/runtime/README.md @@ -120,36 +120,21 @@ Provide a `Slots` object in `redirects.json` to define routing rules. The table | Field | Type | Default | Description | |-------|------|---------|-------------| | `analyticsId` | UUID string | generated or derived | Stable analytics identity. Keep it unchanged when editing the path or destination. | +| `description` | string | omitted | Optional management note of up to 500 characters. Runtime ignores it when matching and responding. | | `type` | string | `prefix` | Route mode: `prefix` for prefix redirects, `exact` for exact matches, `proxy` for reverse proxying. | | `target` | string | `""` | Destination URL. Use exactly one of `target`, `to`, or `url`. | | `to` / `url` | string | `""` | Alias fields. Use exactly one of `target`, `to`, or `url`. | | `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. +- Proxy requests preserve application headers such as `Cookie`, `Authorization`, `Origin`, and `Referer`. The Runtime removes hop-by-hop headers, replaces `X-Forwarded-Host` and `X-Forwarded-Proto`, and drops credentials when an upstream redirect changes origin. +- Upstream response headers, including security headers and separate `Set-Cookie` fields, are preserved. Cookie `Domain` attributes are removed so cookies bind to the public proxy hostname. - 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 @@ -197,45 +182,18 @@ 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", - "proxyPolicy": { - "profile": "asset" - } + "target": "https://cdn.example.com/$1" }, "/admin": { "type": "prefix", diff --git a/apps/runtime/README.zh-CN.md b/apps/runtime/README.zh-CN.md index 42e7477..8543f53 100644 --- a/apps/runtime/README.zh-CN.md +++ b/apps/runtime/README.zh-CN.md @@ -120,30 +120,21 @@ pnpm runtime:build:nf | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `analyticsId` | UUID 字符串 | 自动生成或推导 | 稳定的统计身份。修改路径或目标地址时应保持不变。 | +| `description` | string | 不设置 | 最多 500 字符的可选管理备注;Runtime 匹配和响应时会忽略它。 | | `type` | string | `prefix` | 路由模式:`prefix` 前缀重定向,`exact` 精确匹配,`proxy` 反向代理。 | | `target` | string | `""` | 目标地址,`target`、`to`、`url` 三选一。 | | `to` / `url` | string | `""` | `target` 的别名字段,`target`、`to`、`url` 三选一。 | | `appendPath` | boolean | `true` | `prefix` 或 `proxy` 模式下是否拼接剩余路径,`exact` 不适用。 | | `status` | number | `302` | 非 `proxy` 响应使用 200 到 599 的状态码,`proxy` 不要设置。 | | `priority` | number | 按顺序 | 同一路径存在多条规则时用于排序,数字越小越先匹配。 | -| `proxyPolicy` | object | 旧版兼容行为 | 显式控制请求、响应、跳转、缓存和资源限制,仅适用于 `proxy`。 | - 键名需要以 `/` 开头,可以使用冒号参数,例如 `:id`,也可以使用 `*` 通配符。匹配结果可以在目标地址中用 `$1`、`:id` 等占位符引用。 - 多个路径模式同时匹配时,字面量片段优先于冒号参数,参数优先于 `*`;共享片段特异性相同时,层级更深的模式优先。 - `proxy` 类型会把请求转发到目标地址并返回上游响应,其他类型返回 `Location` 重定向。 +- 代理请求会保留 `Cookie`、`Authorization`、`Origin`、`Referer` 等应用层请求头。Runtime 会删除逐跳头,重设 `X-Forwarded-Host` 与 `X-Forwarded-Proto`,并在上游跳转到不同源时停止转发凭据。 +- 上游安全响应头和多个独立的 `Set-Cookie` 等响应头会得到保留。Cookie 的 `Domain` 属性会被移除,使其绑定到公开代理域名。 - 如果需要为同一路径配置多条规则,可以把值写成数组。数组顺序决定默认优先级,也可以通过 `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 @@ -191,45 +182,18 @@ WebUI 新建的代理规则会使用显式 `isolated` 策略。没有 `proxyPoli "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", - "proxyPolicy": { - "profile": "asset" - } + "target": "https://cdn.example.com/$1" }, "/admin": { "type": "prefix", diff --git a/apps/runtime/src/lib/handlers/analytics/events.ts b/apps/runtime/src/lib/handlers/analytics/events.ts index e3581f7..440e7b1 100644 --- a/apps/runtime/src/lib/handlers/analytics/events.ts +++ b/apps/runtime/src/lib/handlers/analytics/events.ts @@ -14,7 +14,6 @@ import { normalizeAnalyticsHostname } from "./attribution"; import type { VerifiedAttributionToken } from "./attribution"; -import { DEFAULT_STATUS } from "../core/constants"; import type { AnalyticsBotCategory, AnalyticsBotConfidence, @@ -150,7 +149,7 @@ export async function createMatchedAnalyticsEvent( eventKind: "link", analyticsId, routePath: input.routePath, - linkType: input.rule.action.type, + linkType: input.rule.type === "proxy" ? "proxy" : "redirect", matchKind: input.matchKind, matchOutcome: "matched", ...resolveReferrerField(input.request), @@ -289,10 +288,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, compiled.isParam); + const rule = normaliseRule(entry, fallbackPriority); if (!rule) { continue; } + const compiled = compilePattern(base); list.push({ base, rule, ...compiled, order: sequence }); sequence += 1; } @@ -131,29 +131,15 @@ export function getCompiledList(source: SlotBranch): CompiledEntry[] { return compiled; } -function normaliseRule( - value: RouteValue, - fallbackPriority: number, - isParam: boolean -): NormalizedRule | null { +function normaliseRule(value: RouteValue, fallbackPriority: number): NormalizedRule | null { if (typeof value === "string") { return value - ? { - match: { type: isParam ? "pattern" : "prefix" }, - action: { - type: "redirect", - target: value, - appendPath: true, - status: DEFAULT_STATUS - }, - priority: fallbackPriority, - sourceType: "prefix" - } + ? { type: "prefix", target: value, appendPath: true, status: DEFAULT_STATUS, priority: fallbackPriority } : null; } if (value && typeof value === "object") { - const sourceType: RouteType = value.type === "exact" ? "exact" : value.type === "proxy" ? "proxy" : "prefix"; + const type: 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; @@ -170,31 +156,7 @@ function normaliseRule( ? value.analyticsId.trim() : undefined; - 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 { analyticsId, type, target: targetValue, appendPath, status, priority }; } return null; @@ -247,18 +209,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 = rule.action.target; + const targetBase = String(rule.target); if (base === "/") { const rest = pathname === "/" ? "" : pathname; - const resolved = rule.action.appendPath ? appendTargetPath(targetBase, rest) : targetBase; + const resolved = rule.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.action.appendPath ? appendTargetPath(targetBase, rest) : targetBase; + const resolved = rule.appendPath ? appendTargetPath(targetBase, rest) : targetBase; return appendOriginalQuery(resolved, search); } @@ -303,9 +265,9 @@ export function resolveCompiledTarget( let targetUrl: string | null = null; if (match) { - const resolved = applyTemplate(entry.rule.action.target, match, entry.names); + const resolved = applyTemplate(entry.rule.target, match, entry.names); targetUrl = appendOriginalQuery(resolved, search); - } else if (entry.rule.match.type === "prefix") { + } else if ((entry.rule.type === "prefix" || entry.rule.type === "proxy") && !entry.isParam) { targetUrl = resolvePrefixTarget(pathname, search, entry.rule, entry.base); } @@ -334,12 +296,12 @@ export function collectProxyRaceCandidates( const candidates: Array<{ base: string; matchKind: AnalyticsLinkMatchKind; rule: NormalizedRule; targetUrl: string }> = []; const maybeAdd = (entry: CompiledEntry): void => { - if (!entry.rule.action.target) { + if (!entry.rule.target) { return; } const resolved = resolveCompiledTarget(entry, pathname, search); - if (resolved && entry.rule.action.type === "proxy") { + if (resolved && entry.rule.type === "proxy") { candidates.push({ base: entry.base, matchKind: resolved.matchKind, @@ -368,12 +330,12 @@ export function collectProxyRaceCandidates( function resolveMatchKind(entry: CompiledEntry): AnalyticsLinkMatchKind { if ( entry.base.split("/").includes("*") || - (entry.base === "/" && entry.rule.match.type === "prefix") + (entry.base === "/" && (entry.rule.type === "prefix" || entry.rule.type === "proxy")) ) { return "catch_all"; } if (entry.isParam) { return "parameterized"; } - return entry.rule.match.type === "exact" ? "exact" : "prefix"; + return entry.rule.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 deleted file mode 100644 index ec7b483..0000000 --- a/apps/runtime/src/lib/handlers/routing/proxy/headers.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * @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 deleted file mode 100644 index 6ec66a2..0000000 --- a/apps/runtime/src/lib/handlers/routing/proxy/policy.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * @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 deleted file mode 100644 index 591ee96..0000000 --- a/apps/runtime/src/lib/handlers/routing/proxy/request.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * @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 deleted file mode 100644 index 26a8a51..0000000 --- a/apps/runtime/src/lib/handlers/routing/proxy/safety.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @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 7f1b2e2..9239a53 100644 --- a/apps/runtime/src/lib/handlers/routing/response.ts +++ b/apps/runtime/src/lib/handlers/routing/response.ts @@ -1,11 +1,12 @@ /** * @file response.ts * @description - * [EN] Route response factory. - * Dispatches normalized redirect and proxy actions and classifies proxy candidate failures. + * [EN] Response Factory. + * Constructs redirects and transparent proxy responses, including header normalization, + * bounded upstream redirects, and proxy candidate failure classification. * - * [CN] 路由响应工厂。 - * 分发规范化后的重定向与代理动作,并对代理候选失败进行分类。 + * [CN] 响应工厂。 + * 构造重定向与透明代理响应,包括请求头规范化、有界上游跳转以及代理候选失败分类。 * * @see {@link https://github.com/Revaea/i0c.cc} for repository info. */ @@ -13,14 +14,155 @@ import { DEFAULT_STATUS, HSTS_HEADER_VALUE } from "../core/constants"; import type { NormalizedRule, ResolvedRuntime } from "../core/types"; -import { resolveProxyPolicy } from "./proxy/policy"; -import { proxyRequest } from "./proxy/request"; +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; +const MAX_PROXY_REDIRECTS = 5; + +interface ExtendedHeaders { + getAll?(name: string): string[]; + getSetCookie?(): string[]; +} + +function stripHopByHopHeaders(headers: Headers): void { + const connectionHeaders = headers.get("connection") + ?.split(",") + .map((name) => name.trim().toLowerCase()) + .filter((name) => HEADER_NAME_PATTERN.test(name)) ?? []; + + for (const name of HOP_BY_HOP_HEADERS) { + headers.delete(name); + } + for (const name of connectionHeaders) { + headers.delete(name); + } +} + +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 removeCookieDomain(value: string): string { + return value.replace(/;\s*domain=[^;]*/ig, ""); +} + +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}`; +} 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"; } @@ -38,18 +180,191 @@ export async function respondUsingRule( basePath?: string, signal?: AbortSignal ): Promise { - if (rule.action.type === "proxy") { - return proxyRequest( - request, - targetUrl, - runtime, - resolveProxyPolicy(rule.action.policy), - basePath, + 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 }); + } + + headers.delete("host"); + stripHopByHopHeaders(headers); + if (shouldDropBodyHeaders) { + for (const name of REQUEST_BODY_HEADERS) { + headers.delete(name); + } + } + if (currentUrlObj.origin !== targetUrlObj.origin) { + headers.delete("authorization"); + headers.delete("cookie"); + } + + headers.set("x-forwarded-host", originalHost); + headers.set("x-forwarded-proto", originalUrl.protocol.slice(0, -1)); + + 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", 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); + stripHopByHopHeaders(responseHeaders); + + 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.set("Strict-Transport-Security", HSTS_HEADER_VALUE); + + const setCookies = getSetCookieValues(lastResponse.headers); + if (setCookies.length > 0) { + responseHeaders.delete("set-cookie"); + for (const setCookie of setCookies) { + responseHeaders.append("set-cookie", removeCookieDomain(setCookie)); + } } - return redirectResponse(targetUrl, rule.action.status); + 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 + }); } 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 42a259b..ecf7093 100644 --- a/apps/runtime/tests/analytics/analytics-orchestration.test.ts +++ b/apps/runtime/tests/analytics/analytics-orchestration.test.ts @@ -31,15 +31,11 @@ const completedAt = 1_700_000_000_500; const rule: NormalizedRule = { analyticsId: "7ca38115-22d0-4de6-be48-4e7c98010b0d", - match: { type: "exact" }, - action: { - type: "redirect", - target: "https://example.com/", - appendPath: false, - status: 302 - }, - priority: 0, - sourceType: "exact" + type: "exact", + target: "https://example.com/", + appendPath: false, + status: 302, + priority: 0 }; interface CapturedDelivery { diff --git a/apps/runtime/tests/analytics/analytics-resilience.test.ts b/apps/runtime/tests/analytics/analytics-resilience.test.ts index 2e5b1c3..77ad1b7 100644 --- a/apps/runtime/tests/analytics/analytics-resilience.test.ts +++ b/apps/runtime/tests/analytics/analytics-resilience.test.ts @@ -27,15 +27,11 @@ const request = new Request("https://i0c.cc/r", { const rule: NormalizedRule = { analyticsId: "7ca38115-22d0-4de6-be48-4e7c98010b0d", - match: { type: "exact" }, - action: { - type: "redirect", - target: "https://example.com/", - appendPath: false, - status: 302 - }, - priority: 0, - sourceType: "exact" + type: "exact", + target: "https://example.com/", + appendPath: false, + status: 302, + priority: 0 }; function createRuntime(overrides: Partial = {}): ResolvedRuntime { diff --git a/apps/runtime/tests/handlers/dispatcher.test.ts b/apps/runtime/tests/handlers/dispatcher.test.ts index dee8c3f..a9ef2a3 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.action.target, "https://second.example"); + assert.equal(result.match.rule.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.action.target, "https://second.example"); + assert.equal(result.match.rule.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.action.target, "https://first.example"); + assert.equal(result.match?.rule.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 67bdcc9..332edf4 100644 --- a/apps/runtime/tests/handlers/matcher.test.ts +++ b/apps/runtime/tests/handlers/matcher.test.ts @@ -107,13 +107,7 @@ 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.action.type, "redirect"); - assert.equal( - entries[0]?.rule.action.type === "redirect" - ? entries[0].rule.action.status - : undefined, - 302 - ); + assert.equal(entries[0]?.rule.status, 302); }); test("normalizes schema-compatible numeric strings", () => { @@ -127,10 +121,6 @@ test("normalizes schema-compatible numeric strings", () => { }); assert.ok(entry); - assert.equal(entry.rule.action.type, "redirect"); - assert.equal( - entry.rule.action.type === "redirect" ? entry.rule.action.status : undefined, - 307 - ); + assert.equal(entry.rule.status, 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 deleted file mode 100644 index e1f6415..0000000 --- a/apps/runtime/tests/handlers/proxy-policy.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -/** - * @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 adfb7c9..c515f07 100644 --- a/apps/runtime/tests/handlers/response-security.test.ts +++ b/apps/runtime/tests/handlers/response-security.test.ts @@ -2,10 +2,10 @@ * @file response-security.test.ts * @description * [EN] Proxy response security regression tests. - * Verifies proxy host validation and bounded, failure-safe upstream redirect handling. + * Verifies transparent header forwarding, proxy host validation, and bounded upstream redirects. * * [CN] 代理响应安全回归测试。 - * 验证代理主机校验以及有界且可安全失败的上游重定向处理。 + * 验证透明请求头转发、代理主机校验以及有界的上游重定向处理。 * * @see {@link https://github.com/Revaea/i0c.cc} for repository info. */ @@ -18,14 +18,11 @@ import { respondUsingRule } from "../../src/lib/handlers/routing/response"; import type { NormalizedRule, ResolvedRuntime } from "../../src/lib/handlers/core/types"; const proxyRule: NormalizedRule = { - match: { type: "prefix" }, - action: { - type: "proxy", - target: "https://example.com", - appendPath: true - }, - priority: 0, - sourceType: "proxy" + type: "proxy", + target: "https://example.com", + appendPath: true, + status: 302, + priority: 0 }; function createRuntime(fetchImpl: typeof fetch): ResolvedRuntime { @@ -49,7 +46,6 @@ 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/", @@ -115,7 +111,7 @@ test("keeps public IPv6 and ordinary hostnames available", async () => { ]); }); -test("strips credentials and platform client metadata before proxying", async () => { +test("preserves application headers while removing hop-by-hop headers", async () => { let forwarded: Request | undefined; const runtime = createRuntime(async (input) => { forwarded = input instanceof Request ? input : new Request(input); @@ -131,7 +127,9 @@ test("strips credentials and platform client metadata before proxying", async () Forwarded: "for=203.0.113.10", Host: "attacker.example", "Keep-Alive": "timeout=5", + Origin: "https://app.example", "Proxy-Authorization": "Basic secret", + Referer: "https://app.example/page", "True-Client-IP": "203.0.113.10", "X-Nf-Client-Connection-IP": "203.0.113.10", "X-Real-IP": "203.0.113.10", @@ -155,28 +153,111 @@ test("strips credentials and platform client metadata before proxying", async () assert.equal(response.status, 204); assert.ok(forwarded); for (const name of [ - "authorization", - "cf-connecting-ip", - "cf-ipcountry", "connection", - "cookie", - "forwarded", + "host", "keep-alive", "proxy-authorization", - "true-client-ip", - "x-nf-client-connection-ip", - "x-real-ip", - "x-vercel-ip-country", - "x-forwarded-for", "x-remove-me" ]) { assert.equal(forwarded.headers.get(name), null, name); } + assert.equal(forwarded.headers.get("authorization"), "Bearer secret"); + assert.equal(forwarded.headers.get("cf-connecting-ip"), "203.0.113.10"); + assert.equal(forwarded.headers.get("cf-ipcountry"), "US"); + assert.equal(forwarded.headers.get("cookie"), "session=secret"); + assert.equal(forwarded.headers.get("forwarded"), "for=203.0.113.10"); + assert.equal(forwarded.headers.get("origin"), "https://app.example"); + assert.equal(forwarded.headers.get("referer"), "https://app.example/page"); + assert.equal(forwarded.headers.get("true-client-ip"), "203.0.113.10"); + assert.equal(forwarded.headers.get("x-nf-client-connection-ip"), "203.0.113.10"); + assert.equal(forwarded.headers.get("x-real-ip"), "203.0.113.10"); + assert.equal(forwarded.headers.get("x-vercel-ip-country"), "US"); + assert.equal(forwarded.headers.get("x-forwarded-for"), "203.0.113.10"); assert.equal(forwarded.headers.get("x-forwarded-host"), "i0c.cc"); assert.equal(forwarded.headers.get("x-forwarded-proto"), "https"); assert.equal(forwarded.headers.get("x-request-id"), "request-1"); }); +test("drops credentials when an upstream redirect changes origin", async () => { + const forwarded: Request[] = []; + const runtime = 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://redirected.example/final" } + }) + : new Response(null, { status: 204 }); + }); + + const response = await respondUsingRule( + new Request("https://i0c.cc/proxy", { + headers: { + Authorization: "Bearer secret", + Cookie: "session=secret", + Origin: "https://app.example", + Referer: "https://app.example/page" + } + }), + proxyRule, + "https://example.com/start", + runtime, + "/proxy" + ); + + assert.equal(response.status, 204); + assert.equal(forwarded.length, 2); + assert.equal(forwarded[0].headers.get("authorization"), "Bearer secret"); + 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); + assert.equal(forwarded[1].headers.get("origin"), "https://app.example"); + assert.equal(forwarded[1].headers.get("referer"), "https://app.example/page"); +}); + +test("preserves response security headers and separate cookies", async () => { + const upstreamHeaders = new Headers({ + Connection: "keep-alive, X-Upstream-Hop", + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "SAMEORIGIN", + "X-Upstream-Hop": "remove-me" + }); + upstreamHeaders.append( + "Set-Cookie", + "ri_visitor=0123456789abcdef0123456789abcdef; Domain=api.revaea.com; Path=/; HttpOnly" + ); + upstreamHeaders.append("Set-Cookie", "theme=dark; Path=/"); + const runtime = createRuntime(async () => new Response(null, { + status: 204, + headers: upstreamHeaders + })); + + const response = await respondUsingRule( + new Request("https://i0c.cc/proxy"), + proxyRule, + "https://example.com/start", + runtime, + "/proxy" + ); + const responseHeaders = response.headers as unknown as { + getSetCookie(): string[]; + }; + + assert.equal(response.status, 204); + assert.equal(response.headers.get("connection"), null); + assert.equal(response.headers.get("x-upstream-hop"), null); + assert.equal( + response.headers.get("content-security-policy"), + "default-src 'self'" + ); + assert.equal(response.headers.get("x-frame-options"), "SAMEORIGIN"); + assert.deepEqual(responseHeaders.getSetCookie(), [ + "ri_visitor=0123456789abcdef0123456789abcdef; Path=/; HttpOnly", + "theme=dark; Path=/" + ]); +}); + test("applies Fetch method semantics when following upstream redirects", async () => { const postRequests: Array<{ body: string; contentType: string | null; method: string }> = []; let discardedRedirectResponses = 0; diff --git a/apps/webui/README.md b/apps/webui/README.md index 9407c45..ac8b223 100644 --- a/apps/webui/README.md +++ b/apps/webui/README.md @@ -64,7 +64,7 @@ This project provides two rule-editing modes and a separate settings surface: The checked-in deployment selects PostgreSQL through [../../packages/config/src/defaults.ts](../../packages/config/src/defaults.ts) and uses `DATABASE_URL`. A D1-capable WebUI host may select `provider: "d1"` and inject a `D1Database` binding through `configureAppDataRepositoryBinding` before the Repository is first used. -PostgreSQL and D1 are held to the same shared behavior contract. First-run setup creates both documents atomically and refuses partially initialized databases. Every save creates an immutable revision. Import validates both JSON files and replaces them atomically, while restore copies an old document into a new head revision instead of rewriting history. Managers can export, import, inspect, and restore revisions from **Settings → Data and history**. +PostgreSQL and D1 are held to the same shared behavior contract. First-run setup creates both documents atomically and refuses partially initialized databases. Confirming a visual rule dialog saves that mutation immediately and creates an immutable revision. GitHub Contents instead advertises manual-save capability, so its editor retains the page-level Save action and local undo/redo. Import validates both JSON files and replaces them atomically, while restore copies an old document into a new head revision instead of rewriting history. Managers can export, import, inspect, and restore revisions from **Settings → Data and history**. D1 owns independent migrations in [../../plugins/repository/d1/migrations](../../plugins/repository/d1/migrations). Apply them deliberately to the bound database before opening setup. The current Vercel deployment remains on PostgreSQL because Vercel does not provide a native D1 binding. @@ -141,14 +141,14 @@ 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 forms, and explicit proxy-policy profiles with advanced request and response controls. +- Visual editing of `redirects.json`: group tree management, rule descriptions, and rule form editing. - 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. - Immutable document history with Git-style line diffs, non-destructive rollback, and atomic JSON backup import/export. - Authenticated plugin status reporting for installed manifests, configuration state, capabilities, missing bindings, and selected-Store health. - Form behavior aligned with the schema (specification source: [https://raw.githubusercontent.com/Revaea/i0c.cc/main/packages/config/redirects.schema.json](https://raw.githubusercontent.com/Revaea/i0c.cc/main/packages/config/redirects.schema.json)). -- Supports undo/redo for quick editing rollback. +- GitHub Contents keeps local undo/redo until its explicit page-level save. - Saves through the selected versioned Repository and rejects stale revisions instead of overwriting newer content. - Shows a Repository result link after saves when the selected Repository provides one. diff --git a/apps/webui/README.zh-CN.md b/apps/webui/README.zh-CN.md index 5e06714..ca4b634 100644 --- a/apps/webui/README.zh-CN.md +++ b/apps/webui/README.zh-CN.md @@ -62,7 +62,7 @@ i0c.cc WebUI 是一个基于 Next.js 16 的管理面板,用于通过 GitHub OA 仓库当前通过 [../../packages/config/src/defaults.ts](../../packages/config/src/defaults.ts) 选择 PostgreSQL,并使用 `DATABASE_URL`。支持 D1 的 WebUI 宿主可以选择 `provider: "d1"`,并在首次使用 Repository 前通过 `configureAppDataRepositoryBinding` 注入 `D1Database` binding。 -PostgreSQL 与 D1 由同一套共享行为契约约束。首次初始化会原子创建两份文档,并拒绝只存在其中一份文档的半初始化数据库。每次保存都会创建不可变版本;导入会先校验两份 JSON,再原子替换;恢复则把旧内容复制为新的活动版本,不会改写历史。管理者可以在 **设置 → 数据与历史** 中导出、导入、查看和恢复版本。 +PostgreSQL 与 D1 由同一套共享行为契约约束。首次初始化会原子创建两份文档,并拒绝只存在其中一份文档的半初始化数据库。在可视化规则弹窗中确认后,该次修改会立即保存并创建不可变版本。GitHub Contents 会声明手动保存能力,因此仍保留页面级保存和本地撤销/重做。导入会先校验两份 JSON,再原子替换;恢复则把旧内容复制为新的活动版本,不会改写历史。管理者可以在 **设置 → 数据与历史** 中导出、导入、查看和恢复版本。 D1 使用 [../../plugins/repository/d1/migrations](../../plugins/repository/d1/migrations) 中的独立迁移。打开初始化页面前,需要明确将这些迁移应用到绑定的数据库。当前 Vercel 部署仍使用 PostgreSQL,因为 Vercel 不提供原生 D1 binding。 @@ -135,14 +135,14 @@ WebUI 不会把原有非敏感环境变量作为覆盖值或回退值读取。Ve ## 功能概览 - 通过版本化配置选择任意已登录用户、数字用户 ID 白名单或带指定管理员与可选黑名单的 GitHub 全员只读模式。 -- 可视化编辑 `redirects.json`:分组树、规则表单,以及带高级请求与响应控制的显式代理策略预设。 +- 可视化编辑 `redirects.json`:分组树、规则描述和规则表单编辑。 - GitHub Repository 专属的规则来源切换和 JSON 编辑器,支持当前行高亮与语法校验。 - 可视化并校验 `config.json`;只有当前文档无法安全转换为表单时,才显示原始内容恢复编辑器。 - 数据库首次初始化,无需手写 JSON 或执行 seed。 - 带 Git 风格行差异的不可变文档历史、非破坏性回滚,以及原子 JSON 备份导入导出。 - 通过认证后查看已安装 Manifest、配置状态、能力、缺失绑定和所选 Store 健康状态。 - 表单行为对齐 Schema(规范来源:[https://raw.githubusercontent.com/Revaea/i0c.cc/main/packages/config/redirects.schema.json](https://raw.githubusercontent.com/Revaea/i0c.cc/main/packages/config/redirects.schema.json))。 -- 支持撤销/重做,便于快速回退编辑。 +- GitHub Contents 在页面级显式保存前保留本地撤销/重做。 - 通过所选版本化 Repository 保存;revision 过期时拒绝覆盖较新的内容。 - 所选 Repository 提供结果链接时,保存成功通知会显示该链接。 diff --git a/apps/webui/messages/en/redirects.json b/apps/webui/messages/en/redirects.json index 2b03a5a..39edcd4 100644 --- a/apps/webui/messages/en/redirects.json +++ b/apps/webui/messages/en/redirects.json @@ -55,15 +55,35 @@ "emptyReadOnlyHint": "There are no rules to display in this group.", "pathKey": "Path key", "pathKeyPlaceholder": "/foo or /bar/baz", + "ruleDescription": "Description", + "ruleDescriptionPlaceholder": "Explain what this route is used for", + "ruleDescriptionHint": "Optional. Shown only in management interfaces and does not affect routing.", + "ruleDescriptionMissing": "No description", "newRuleTitle": "New rule", "newRuleDescription": "Fill in the rule for “{group}” first. It will be added to the list after confirmation.", "newRulePathHint": "The path cannot be empty. You can keep editing it in the rule list after saving.", "newRuleCancel": "Cancel", "newRuleCreate": "Add rule", + "saveNewRule": "Save rule", + "editRuleTitle": "Edit path rule", + "viewRuleTitle": "View path rule", + "editRuleDescription": "Configure the complete matching and response behavior for “{path}”.", + "editRuleCancel": "Cancel", + "editRuleApply": "Apply changes", + "saveRuleChanges": "Save changes", + "savingRule": "Saving...", + "saveRuleFail": "The rule could not be saved. Review the fields and try again.", + "closeRule": "Close", + "editRule": "Edit rule", + "viewRule": "View rule", + "rulePathMissing": "Path not set", + "ruleTargetMissing": "Target not set", + "ruleArrayCount": "{count} candidate rules", "deleteRuleTitle": "Delete rule?", "cancelDelete": "Cancel", "confirmDeleteRule": "Delete this rule?", "deleteRule": "Delete rule", + "deletingRule": "Deleting...", "locate": "Locate", "expand": "Expand", "collapse": "Collapse" @@ -326,79 +346,6 @@ "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 57abc62..961ab3c 100644 --- a/apps/webui/messages/zh-CN/redirects.json +++ b/apps/webui/messages/zh-CN/redirects.json @@ -55,15 +55,35 @@ "emptyReadOnlyHint": "这个分组暂无可展示的规则。", "pathKey": "路径", "pathKeyPlaceholder": "/foo 或 /bar/baz", + "ruleDescription": "描述", + "ruleDescriptionPlaceholder": "说明这条路由的用途", + "ruleDescriptionHint": "选填;只在管理界面展示,不影响路由行为。", + "ruleDescriptionMissing": "暂无描述", "newRuleTitle": "新增规则", "newRuleDescription": "先填写要添加到“{group}”的规则,确认后才会加入列表。", "newRulePathHint": "路径不能为空;保存后仍可在规则列表中继续调整。", "newRuleCancel": "取消", "newRuleCreate": "添加规则", + "saveNewRule": "保存规则", + "editRuleTitle": "编辑路径规则", + "viewRuleTitle": "查看路径规则", + "editRuleDescription": "配置路径“{path}”的完整匹配与响应行为。", + "editRuleCancel": "取消", + "editRuleApply": "应用修改", + "saveRuleChanges": "保存修改", + "savingRule": "正在保存…", + "saveRuleFail": "规则未能保存,请检查表单内容后重试。", + "closeRule": "关闭", + "editRule": "编辑规则", + "viewRule": "查看规则", + "rulePathMissing": "未填写路径", + "ruleTargetMissing": "尚未设置目标", + "ruleArrayCount": "{count} 个候选规则", "deleteRuleTitle": "删除规则?", "cancelDelete": "取消", "confirmDeleteRule": "确定要删除这条规则吗?", "deleteRule": "删除规则", + "deletingRule": "正在删除…", "locate": "定位", "expand": "展开", "collapse": "收起" @@ -326,79 +346,6 @@ "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/group-entries-editor.tsx b/apps/webui/src/components/editor/group-entries-editor.tsx index 56e5227..a6f0657 100644 --- a/apps/webui/src/components/editor/group-entries-editor.tsx +++ b/apps/webui/src/components/editor/group-entries-editor.tsx @@ -3,40 +3,71 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; +import { + EditRouteEntryDialog, +} from "@/components/editor/route-entry/edit-route-entry-dialog"; +import { RouteEntryCard } from "@/components/editor/route-entry/route-entry-card"; import { Button } from "@/components/ui/controls/button"; -import { WebUiPluginSlot } from "@/components/plugins/plugin-slot"; -import { fieldLabelClassName, formControlClassName } from "@/components/ui/controls/form-control"; import { ConfirmationDialog } from "@/components/ui/feedback/confirmation-dialog"; -import type { RedirectGroup } from "@/composables/redirects-groups/model"; - -import { RouteEntryEditor } from "@/components/editor/route-entry/route-entry-editor"; +import type { + RedirectEntryDraft, + RedirectGroup, +} from "@/composables/redirects-groups/model"; +import type { RedirectsMutationResult } from "@/composables/redirects-groups"; export type GroupEntriesEditorProps = { group: RedirectGroup; isReadOnly: boolean; + savesImmediately: boolean; onAddEntry: (groupId: string) => void; - onRemoveEntry: (groupId: string, entryId: string) => void; - onUpdateEntryKey: (groupId: string, entryId: string, nextKey: string) => void; - onUpdateEntryValue: (groupId: string, entryId: string, nextValue: unknown) => void; + onRemoveEntry: ( + groupId: string, + entryId: string, + ) => Promise; + onUpdateEntry: ( + groupId: string, + entryId: string, + draft: RedirectEntryDraft, + ) => Promise; }; export function GroupEntriesEditor({ group, isReadOnly, + savesImmediately, onAddEntry, onRemoveEntry, - onUpdateEntryKey, - onUpdateEntryValue, + onUpdateEntry, }: GroupEntriesEditorProps) { const t = useTranslations("entries"); const [pendingDeleteEntryId, setPendingDeleteEntryId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [editingEntryId, setEditingEntryId] = useState(null); + const editingEntry = editingEntryId + ? group.entries.find((entry) => entry.id === editingEntryId) ?? null + : null; - function confirmDeleteEntry() { - if (!pendingDeleteEntryId) { + async function confirmDeleteEntry() { + if (!pendingDeleteEntryId || isDeleting) { return; } - onRemoveEntry(group.id, pendingDeleteEntryId); - setPendingDeleteEntryId(null); + setIsDeleting(true); + setDeleteError(null); + try { + const result = await onRemoveEntry(group.id, pendingDeleteEntryId); + setIsDeleting(false); + if (result.isSuccess) { + setPendingDeleteEntryId(null); + return; + } + setDeleteError(result.errorMessage ?? t("saveRuleFail")); + } catch (error) { + setIsDeleting(false); + setDeleteError( + error instanceof Error ? error.message : t("saveRuleFail"), + ); + } } return ( @@ -76,78 +107,60 @@ export function GroupEntriesEditor({ )} -
- {group.entries.length === 0 ? ( -
-
-
-

{t("emptyTitle")}

-

- {t(isReadOnly ? "emptyReadOnlyHint" : "emptyHint")} -

-
+ {group.entries.length === 0 ? ( +
+
+
+

{t("emptyTitle")}

+

+ {t(isReadOnly ? "emptyReadOnlyHint" : "emptyHint")} +

- ) : null} - {group.entries.map((entry) => ( -
-
-
- - onUpdateEntryKey(group.id, entry.id, e.target.value)} - placeholder={t("pathKeyPlaceholder")} - readOnly={isReadOnly} - className={formControlClassName({ className: "mt-1 w-full" })} - /> -
+
+ ) : ( +
+ {group.entries.map((entry) => ( + setEditingEntryId(entry.id)} + onDelete={() => setPendingDeleteEntryId(entry.id)} + /> + ))} +
+ )} - {isReadOnly ? null : ( - - )} -
+ {editingEntry ? ( + setEditingEntryId(null)} + onApply={(draft) => + onUpdateEntry(group.id, editingEntry.id, draft) + } + /> + ) : null} -
- onUpdateEntryValue(group.id, entry.id, next)} - /> - -
- - ))} -
setPendingDeleteEntryId(null)} - onConfirm={confirmDeleteEntry} + onCancel={() => { + setDeleteError(null); + setPendingDeleteEntryId(null); + }} + onConfirm={() => void confirmDeleteEntry()} />
); diff --git a/apps/webui/src/components/editor/new-route-entry-dialog.tsx b/apps/webui/src/components/editor/new-route-entry-dialog.tsx index 120e687..9e60134 100644 --- a/apps/webui/src/components/editor/new-route-entry-dialog.tsx +++ b/apps/webui/src/components/editor/new-route-entry-dialog.tsx @@ -10,6 +10,11 @@ import { fieldLabelRowClassName, formControlClassName, } from "@/components/ui/controls/form-control"; +import { + setRouteDescription, + stripRetiredProxyPolicy, +} from "@/composables/editor/route-utils"; +import type { RedirectsMutationResult } from "@/composables/redirects-groups"; import type { RedirectEntryDraft } from "@/composables/redirects-groups/model"; import { RouteEntryEditor } from "./route-entry/route-entry-editor"; @@ -18,7 +23,8 @@ interface NewRouteEntryDialogProps { groupName: string; isOpen: boolean; onClose: () => void; - onCreate: (draft: RedirectEntryDraft) => void; + onCreate: (draft: RedirectEntryDraft) => Promise; + savesImmediately: boolean; } export function NewRouteEntryDialog({ @@ -26,36 +32,67 @@ export function NewRouteEntryDialog({ isOpen, onClose, onCreate, + savesImmediately, }: NewRouteEntryDialogProps) { const t = useTranslations("entries"); const [pathKey, setPathKey] = useState(""); + const [description, setDescription] = useState(""); const [value, setValue] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); const normalizedPathKey = pathKey.trim(); const canCreate = normalizedPathKey.length > 0; - function createEntry() { - if (!canCreate) { + async function createEntry() { + if (!canCreate || isSaving) { return; } - onCreate({ - key: normalizedPathKey, - value, - }); + setIsSaving(true); + setSaveError(null); + try { + const result = await onCreate({ + key: normalizedPathKey, + value: setRouteDescription( + stripRetiredProxyPolicy(value), + description, + ), + }); + setIsSaving(false); + if (result.isSuccess) { + onClose(); + return; + } + setSaveError(result.errorMessage ?? t("saveRuleFail")); + } catch (error) { + setIsSaving(false); + setSaveError( + error instanceof Error ? error.message : t("saveRuleFail"), + ); + } } return ( - +
{ event.preventDefault(); - createEntry(); + void createEntry(); }} className="p-5 sm:p-6" >
-

+

{t("newRuleTitle")}

@@ -64,6 +101,7 @@ export function NewRouteEntryDialog({

+
+ +