Explorar o código

fix(proxy): deduplicate getFake200ReasonKey and strengthen client-facing sanitization

Extract duplicated getFake200ReasonKey() from SummaryTab and
ProviderChainPopover into a shared fake200-reason.ts utility,
eliminating the risk of silent drift when new FAKE_200_* codes are added.

Replace the 3-pattern manual sanitization in getClientSafeMessage()
with the existing sanitizeErrorTextForDetail() (6 patterns), closing
a gap where JWT tokens, emails, and password/config paths could leak
to clients via the FAKE_200 error detail path.

Add unit tests verifying JWT, email, and password sanitization.
ding113 hai 6 días
pai
achega
3db096dc0f

+ 2 - 20
src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx

@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
 import { Link } from "@/i18n/routing";
 import { cn, formatTokenAmount } from "@/lib/utils";
 import { formatCurrency } from "@/lib/utils/currency";
+import { getFake200ReasonKey } from "../../fake200-reason";
 import {
   calculateOutputRate,
   isInProgressStatus,
@@ -28,25 +29,6 @@ import {
   shouldHideOutputRate,
 } from "../types";
 
-// UI 仅用于“解释”内部的 FAKE_200_* 错误码,不参与判定逻辑。
-// 这些 code 代表:上游返回了 2xx(看起来成功),但响应体内容更像错误页/错误 JSON。
-function getFake200ReasonKey(code: string): string {
-  switch (code) {
-    case "FAKE_200_EMPTY_BODY":
-      return "fake200Reasons.emptyBody";
-    case "FAKE_200_HTML_BODY":
-      return "fake200Reasons.htmlBody";
-    case "FAKE_200_JSON_ERROR_NON_EMPTY":
-      return "fake200Reasons.jsonErrorNonEmpty";
-    case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY":
-      return "fake200Reasons.jsonErrorMessageNonEmpty";
-    case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH":
-      return "fake200Reasons.jsonMessageKeywordMatch";
-    default:
-      return "fake200Reasons.unknown";
-  }
-}
-
 export function SummaryTab({
   statusCode,
   errorMessage,
@@ -88,7 +70,7 @@ export function SummaryTab({
     typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_");
   const fake200Reason =
     isFake200PostStreamFailure && typeof errorMessage === "string"
-      ? t(getFake200ReasonKey(errorMessage))
+      ? t(getFake200ReasonKey(errorMessage, "fake200Reasons"))
       : null;
 
   return (

+ 15 - 0
src/app/[locale]/dashboard/logs/_components/fake200-reason.ts

@@ -0,0 +1,15 @@
+// Shared mapping from internal FAKE_200_* error codes to i18n suffix keys.
+// These codes represent: upstream returned 2xx but the body looks like an error page / error JSON.
+// UI-only: does not participate in detection logic.
+
+const FAKE_200_REASON_KEYS: Record<string, string> = {
+  FAKE_200_EMPTY_BODY: "emptyBody",
+  FAKE_200_HTML_BODY: "htmlBody",
+  FAKE_200_JSON_ERROR_NON_EMPTY: "jsonErrorNonEmpty",
+  FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY: "jsonErrorMessageNonEmpty",
+  FAKE_200_JSON_MESSAGE_KEYWORD_MATCH: "jsonMessageKeywordMatch",
+};
+
+export function getFake200ReasonKey(code: string, prefix: string): string {
+  return `${prefix}.${FAKE_200_REASON_KEYS[code] ?? "unknown"}`;
+}

+ 13 - 21
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx

@@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
 import { cn } from "@/lib/utils";
 import { formatProbabilityCompact } from "@/lib/utils/provider-chain-formatter";
 import type { ProviderChainItem } from "@/types/message";
+import { getFake200ReasonKey } from "./fake200-reason";
 
 interface ProviderChainPopoverProps {
   chain: ProviderChainItem[];
@@ -58,25 +59,6 @@ function parseGroupTags(groupTag?: string | null): string[] {
   return groups;
 }
 
-// UI 仅用于“解释”内部的 FAKE_200_* 错误码,不参与判定逻辑。
-// 这些 code 代表:上游返回了 2xx(看起来成功),但响应体内容更像错误页/错误 JSON。
-function getFake200ReasonKey(code: string): string {
-  switch (code) {
-    case "FAKE_200_EMPTY_BODY":
-      return "logs.details.fake200Reasons.emptyBody";
-    case "FAKE_200_HTML_BODY":
-      return "logs.details.fake200Reasons.htmlBody";
-    case "FAKE_200_JSON_ERROR_NON_EMPTY":
-      return "logs.details.fake200Reasons.jsonErrorNonEmpty";
-    case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY":
-      return "logs.details.fake200Reasons.jsonErrorMessageNonEmpty";
-    case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH":
-      return "logs.details.fake200Reasons.jsonMessageKeywordMatch";
-    default:
-      return "logs.details.fake200Reasons.unknown";
-  }
-}
-
 /**
  * Get status icon and color for a provider chain item
  */
@@ -223,7 +205,12 @@ export function ProviderChainPopover({
                       {typeof fake200CodeForDisplay === "string" && (
                         <div>
                           {t("logs.details.fake200DetectedReason", {
-                            reason: t(getFake200ReasonKey(fake200CodeForDisplay)),
+                            reason: t(
+                              getFake200ReasonKey(
+                                fake200CodeForDisplay,
+                                "logs.details.fake200Reasons"
+                              )
+                            ),
                           })}
                         </div>
                       )}
@@ -539,7 +526,12 @@ export function ProviderChainPopover({
                         item.errorMessage.startsWith("FAKE_200_") && (
                           <p className="text-[10px] text-amber-700 dark:text-amber-300 mt-0.5 line-clamp-2">
                             {t("logs.details.fake200DetectedReason", {
-                              reason: t(getFake200ReasonKey(item.errorMessage)),
+                              reason: t(
+                                getFake200ReasonKey(
+                                  item.errorMessage,
+                                  "logs.details.fake200Reasons"
+                                )
+                              ),
                             })}
                           </p>
                         )}

+ 2 - 4
src/app/v1/_lib/proxy/errors.ts

@@ -9,6 +9,7 @@
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { type ErrorDetectionResult, errorRuleDetector } from "@/lib/error-rule-detector";
 import { redactJsonString } from "@/lib/utils/message-redaction";
+import { sanitizeErrorTextForDetail } from "@/lib/utils/upstream-error-detection";
 import type { ErrorOverrideResponse } from "@/repository/error-rules";
 import type { ProviderChainItem } from "@/types/message";
 import type { ProxySession } from "./session";
@@ -519,10 +520,7 @@ export class ProxyError extends Error {
         const maxChars = 200;
         const clipped =
           normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized;
-        const safe = clipped
-          .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]")
-          .replace(/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/giu, "[REDACTED_KEY]")
-          .replace(/\bAIza[0-9A-Za-z_-]{16,}\b/g, "[REDACTED_KEY]");
+        const safe = sanitizeErrorTextForDetail(clipped);
         return `${reason}${inferredNote} Upstream detail: ${safe}`;
       }
 

+ 37 - 0
tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts

@@ -534,3 +534,40 @@ describe("ProxyForwarder - fake 200 HTML body", () => {
     expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
   });
 });
+
+describe("ProxyError.getClientSafeMessage - FAKE_200 sanitization", () => {
+  test("upstream body 包含 JWT 和 email 时应被脱敏为 [JWT] / [EMAIL]", () => {
+    const jwtToken =
+      "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
+    const email = "[email protected]";
+    const body = `Authentication failed for ${email} with token ${jwtToken}`;
+
+    const error = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, {
+      body,
+      providerId: 1,
+      providerName: "p1",
+    });
+
+    const msg = error.getClientSafeMessage();
+    expect(msg).toContain("[JWT]");
+    expect(msg).toContain("[EMAIL]");
+    expect(msg).not.toContain(jwtToken);
+    expect(msg).not.toContain(email);
+    expect(msg).toContain("Upstream detail:");
+  });
+
+  test("upstream body 包含 password=xxx 时应被脱敏", () => {
+    const body = "Config error: password=s3cretValue in /etc/app.json";
+
+    const error = new ProxyError("FAKE_200_HTML_BODY", 502, {
+      body,
+      providerId: 1,
+      providerName: "p1",
+    });
+
+    const msg = error.getClientSafeMessage();
+    expect(msg).not.toContain("s3cretValue");
+    expect(msg).toContain("[PATH]");
+    expect(msg).toContain("Upstream detail:");
+  });
+});