fix: hide upstream html payment errors

This commit is contained in:
2569718930@qq.com
2026-06-03 01:58:19 +08:00
parent afffacb529
commit a440f3d2b5
7 changed files with 207 additions and 22 deletions
+34
View File
@@ -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
@@ -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("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(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);
}
@@ -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",
);
}
+32 -7
View File
@@ -68,27 +68,52 @@ export type NormalizedPaymentError = {
userRejected: boolean;
};
export function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(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 {
+41 -8
View File
@@ -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 ||
@@ -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",
);
}
+29 -1
View File
@@ -28,6 +28,33 @@ function shouldExposeProxyErrorDetail() {
);
}
function looksLikeHtmlDocument(value: string) {
const text = String(value || "").trim().toLowerCase();
return (
text.startsWith("<!doctype html") ||
text.startsWith("<html") ||
/<title>[^<]*(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<string, unknown>;
},
) {
const safeJsonMessage = extractSafeUpstreamJsonMessage(rawDetail);
const body: Record<string, unknown> = {
error: options?.error || "Upstream request failed",
error: options?.error || safeJsonMessage || "Upstream request failed",
upstream_status: upstreamStatus,
};