From a440f3d2b594c2d954de25dd704dc3a7a2045595 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 3 Jun 2026 01:58:19 +0800 Subject: [PATCH] fix: hide upstream html payment errors --- deploy.sh | 34 +++++++++++++ .../intents/[intentId]/submit/route.ts | 36 +++++++++++--- .../account/__tests__/paymentSecurity.test.ts | 34 +++++++++++++ frontend/components/account/payment-utils.ts | 39 ++++++++++++--- frontend/components/account/usePaymentFlow.ts | 49 ++++++++++++++++--- .../__tests__/deployStability.test.ts | 7 +++ frontend/lib/api-proxy.ts | 30 +++++++++++- 7 files changed, 207 insertions(+), 22 deletions(-) diff --git a/deploy.sh b/deploy.sh index 992e4ae1..f72e151a 100644 --- a/deploy.sh +++ b/deploy.sh @@ -151,6 +151,38 @@ warm_public_route() { return 0 } +read_env_file_value() { + local key="$1" + if [ ! -f ".env" ]; then + return 0 + fi + awk -F= -v key="$key" ' + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { + value=$0 + sub("^[^=]*=", "", value) + gsub("^[[:space:]]+|[[:space:]]+$", "", value) + gsub(/^["'"'"']|["'"'"']$/, "", value) + print value + } + ' .env | tail -n 1 +} + +validate_frontend_api_base_url() { + local api_base="${POLYWEATHER_API_BASE_URL:-}" + if [ -z "$api_base" ]; then + api_base="$(read_env_file_value "POLYWEATHER_API_BASE_URL")" + fi + local normalized + normalized="$(printf '%s' "$api_base" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g; s#/*$##')" + case "$normalized" in + http://polyweather.top|https://polyweather.top|http://www.polyweather.top|https://www.polyweather.top) + echo "❌ POLYWEATHER_API_BASE_URL must not point at the frontend site: $api_base" + echo " Use the internal backend URL http://polyweather_web:8000 or the backend API host https://api.polyweather.top." + exit 1 + ;; + esac +} + PUBLIC_SMOKE_RECHECK_DELAY_SEC="${POLYWEATHER_PUBLIC_SMOKE_RECHECK_DELAY_SEC:-20}" run_public_smoke_checks() { @@ -170,6 +202,8 @@ run_public_smoke_checks() { return "$failed" } +validate_frontend_api_base_url + echo "Updating Redis dependency..." compose_up_retry "redis" -d polyweather_redis diff --git a/frontend/app/api/payments/intents/[intentId]/submit/route.ts b/frontend/app/api/payments/intents/[intentId]/submit/route.ts index 3f47ef34..c3092db0 100644 --- a/frontend/app/api/payments/intents/[intentId]/submit/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/submit/route.ts @@ -11,6 +11,35 @@ import { const API_BASE = process.env.POLYWEATHER_API_BASE_URL; +function looksLikeHtmlDocument(value: string) { + const text = String(value || "").trim().toLowerCase(); + return ( + text.startsWith("[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || "")) + ); +} + +function submitErrorMessage(raw: string) { + try { + const parsed = JSON.parse(String(raw || "")) as { + detail?: unknown; + error?: unknown; + message?: unknown; + }; + const message = [parsed.detail, parsed.error, parsed.message].find( + (item) => typeof item === "string" && item.trim(), + ); + if (typeof message === "string") { + const trimmed = message.trim(); + if (!looksLikeHtmlDocument(trimmed)) return trimmed.slice(0, 350); + } + } catch { + // Non-JSON upstream errors are commonly HTML 50x pages; do not expose them. + } + return "Payment submit upstream failed"; +} + export async function POST( req: NextRequest, context: { params: Promise<{ intentId: string }> }, @@ -40,14 +69,9 @@ export async function POST( ); if (!res.ok) { const raw = await res.text(); - let detail = raw.slice(0, 350); - try { - const parsed = JSON.parse(raw); - if (parsed.detail) detail = String(parsed.detail).slice(0, 350); - } catch {} const response = buildUpstreamErrorResponse(res.status, raw, { detailLimit: 350, - error: detail || undefined, + error: submitErrorMessage(raw), }); return applyAuthResponseCookies(response, auth.response); } diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 10761acf..cc551703 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -66,6 +66,10 @@ export function runTests() { const paymentFlowSource = fs.existsSync(paymentFlowPath) ? fs.readFileSync(paymentFlowPath, "utf8") : ""; + const paymentUtilsSource = fs.readFileSync( + path.join(projectRoot, "components", "account", "payment-utils.ts"), + "utf8", + ); // The receiver validation now lives in the extracted hook file (called // from createManualPaymentIntent and createIntentAndPay). @@ -246,4 +250,34 @@ export function runTests() { walletBindSource.includes("readPaymentApiErrorMessage"), "wallet binding errors must show the API error message instead of raw JSON", ); + assert( + paymentUtilsSource.includes("looksLikeHtmlDocument") && + paymentUtilsSource.includes("Payment service is temporarily unavailable") && + paymentUtilsSource.includes("支付服务暂时不可用"), + "payment API error parsing must collapse upstream HTML/Cloudflare 50x pages into a user-safe message", + ); + assert( + paymentFlowSource.includes("readPaymentApiErrorMessage") && + !paymentFlowSource.includes("(await submitRes.text()).slice(0, 350)") && + !paymentFlowSource.includes("(await confirmRes.text()).slice(0, 350)"), + "payment submit/confirm failures must use sanitized API error messages instead of raw response text", + ); + const submitRouteSource = fs.readFileSync( + path.join( + projectRoot, + "app", + "api", + "payments", + "intents", + "[intentId]", + "submit", + "route.ts", + ), + "utf8", + ); + assert( + submitRouteSource.includes("Payment submit upstream failed") && + !submitRouteSource.includes("error: detail || undefined"), + "payment submit proxy must not copy raw upstream HTML into the public error field", + ); } diff --git a/frontend/components/account/payment-utils.ts b/frontend/components/account/payment-utils.ts index cc02dbc1..08061d26 100644 --- a/frontend/components/account/payment-utils.ts +++ b/frontend/components/account/payment-utils.ts @@ -68,27 +68,52 @@ export type NormalizedPaymentError = { userRejected: boolean; }; +export function looksLikeHtmlDocument(value: string) { + const text = String(value || "").trim().toLowerCase(); + return ( + text.startsWith("[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || "")) + ); +} + +function containsCjk(value: string) { + return /[\u3400-\u9fff]/.test(value); +} + +function safePaymentServiceUnavailableMessage(fallback: string) { + return containsCjk(fallback) + ? "支付服务暂时不可用,请稍后重试;如果已经付款,请保存 Tx Hash 联系管理员。" + : "Payment service is temporarily unavailable. Please retry shortly; if you already paid, keep the Tx Hash and contact support."; +} + export async function readPaymentApiErrorMessage( response: Response, fallback = "Request failed", limit = 300, ) { - const raw = (await response.text()).slice(0, limit); - if (!raw) return fallback; + const raw = await response.text(); + const trimmed = String(raw || "").trim(); + if (!trimmed) return fallback; + let message = ""; try { - const parsed = JSON.parse(raw) as { + const parsed = JSON.parse(trimmed) as { error?: unknown; detail?: unknown; message?: unknown; }; - const message = [parsed.error, parsed.detail, parsed.message].find( + const parsedMessage = [parsed.error, parsed.detail, parsed.message].find( (item) => typeof item === "string" && item.trim(), ); - if (typeof message === "string") return message.trim(); + if (typeof parsedMessage === "string") message = parsedMessage.trim(); } catch { - // Fall back to the raw response body below. + message = trimmed; } - return raw; + const candidate = message || trimmed; + if (looksLikeHtmlDocument(candidate)) { + return safePaymentServiceUnavailableMessage(fallback); + } + return candidate.slice(0, limit); } export function normalizePaymentError(error: unknown): NormalizedPaymentError { diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 6c655b10..db15dbad 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -24,6 +24,7 @@ import { buildBalanceOfCalldata, formatTokenUnits, normalizePaymentError, + readPaymentApiErrorMessage, requestWalletWithTimeout, } from "./payment-utils"; import { trackAppEvent } from "@/lib/app-analytics"; @@ -304,7 +305,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { const headers = authHeaders || (await buildAuthedHeaders(false)); const configRes = await fetch("/api/payments/config", { cache: "no-store", headers }); if (!configRes.ok) { - const raw = (await configRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + configRes, + isEn ? "Payment config request failed." : "支付配置请求失败。", + 350, + ); throw new Error(copy.loadConfigFailed.replace("{raw}", raw)); } const configJson = (await configRes.json()) as PaymentConfig; @@ -373,7 +378,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { await new Promise((resolve) => setTimeout(resolve, pollMs)); continue; } - const raw = (await statusRes.text()).slice(0, 260); + const raw = await readPaymentApiErrorMessage( + statusRes, + isEn ? "Payment status request failed." : "支付状态请求失败。", + 260, + ); throw new Error(copy.queryIntentFailed.replace("{raw}", raw)); } const statusJson = (await statusRes.json()) as IntentStatusResponse; @@ -505,7 +514,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { }), }); if (!createRes.ok) { - const raw = (await createRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + createRes, + isEn ? "Payment order request failed." : "支付订单请求失败。", + 350, + ); throw new Error(copy.createIntentFailed.replace("{raw}", raw)); } @@ -584,7 +597,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), }); if (!submitRes.ok) { - const raw = (await submitRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + submitRes, + isEn ? "Payment submit request failed." : "支付提交请求失败。", + 350, + ); if (submitRes.status === 409) { if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw); return; @@ -596,7 +613,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), }); if (!confirmRes.ok) { - const raw = (await confirmRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + confirmRes, + isEn ? "Payment confirm request failed." : "支付确认请求失败。", + 350, + ); const lowerRaw = raw.toLowerCase(); const maybePending = (confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) || @@ -682,7 +703,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { }), }); if (!createRes.ok) { - const raw = (await createRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + createRes, + isEn ? "Manual payment order request failed." : "手动转账订单请求失败。", + 350, + ); throw new Error(copy.createManualIntentFailed.replace("{raw}", raw)); } @@ -740,7 +765,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), }); if (!submitRes.ok) { - const raw = (await submitRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + submitRes, + isEn ? "Payment submit request failed." : "支付提交请求失败。", + 350, + ); if (submitRes.status === 409) { if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw); return; @@ -751,7 +780,11 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), }); if (!confirmRes.ok) { - const raw = (await confirmRes.text()).slice(0, 350); + const raw = await readPaymentApiErrorMessage( + confirmRes, + isEn ? "Payment confirm request failed." : "支付确认请求失败。", + 350, + ); const lowerRaw = raw.toLowerCase(); const maybePending = confirmRes.status === 408 || diff --git a/frontend/components/dashboard/scan-terminal/__tests__/deployStability.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/deployStability.test.ts index c060719c..608632dd 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/deployStability.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/deployStability.test.ts @@ -34,4 +34,11 @@ export function runTests() { deployScript.includes("https://polyweather.top/api/auth/me?prefer_snapshot=1"), "deploy script must warm terminal and auth snapshot routes after container replacement", ); + assert( + deployScript.includes("validate_frontend_api_base_url") && + deployScript.includes("POLYWEATHER_API_BASE_URL must not point at the frontend site") && + deployScript.indexOf("validate_frontend_api_base_url") < + deployScript.indexOf('echo "Updating Redis dependency..."'), + "deploy script must reject frontend POLYWEATHER_API_BASE_URL values that point at polyweather.top and would recurse through the frontend proxy", + ); } diff --git a/frontend/lib/api-proxy.ts b/frontend/lib/api-proxy.ts index c0b09ac9..bcb47596 100644 --- a/frontend/lib/api-proxy.ts +++ b/frontend/lib/api-proxy.ts @@ -28,6 +28,33 @@ function shouldExposeProxyErrorDetail() { ); } +function looksLikeHtmlDocument(value: string) { + const text = String(value || "").trim().toLowerCase(); + return ( + text.startsWith("[^<]*(50\d|cloudflare|polyweather\.top)/i.test(String(value || "")) + ); +} + +function extractSafeUpstreamJsonMessage(rawDetail: string) { + try { + const parsed = JSON.parse(String(rawDetail || "")) as { + detail?: unknown; + error?: unknown; + message?: unknown; + }; + const message = [parsed.detail, parsed.error, parsed.message].find( + (item) => typeof item === "string" && item.trim(), + ); + if (typeof message !== "string") return ""; + const trimmed = message.trim(); + return looksLikeHtmlDocument(trimmed) ? "" : trimmed; + } catch { + return ""; + } +} + export function clientStatusFromUpstream(status: number) { if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) { return status; @@ -44,8 +71,9 @@ export function buildUpstreamErrorResponse( extraDebug?: Record; }, ) { + const safeJsonMessage = extractSafeUpstreamJsonMessage(rawDetail); const body: Record = { - error: options?.error || "Upstream request failed", + error: options?.error || safeJsonMessage || "Upstream request failed", upstream_status: upstreamStatus, };