From 17430a1f218e5302a7fe4dca27ac09cb11567996 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 9 Aug 2026 00:54:55 +0800 Subject: [PATCH] fix(reports): trust verified proxy origin --- .../src/app/api/reports/[reportId]/route.ts | 2 + frontend/src/app/api/reports/route.ts | 1 + frontend/src/app/globals.css | 5 +- frontend/src/app/page.tsx | 5 -- .../generate-personal-report-button.tsx | 45 +++++----------- .../src/lib/personal-report-entitlement.ts | 36 ++++++++++++- .../src/lib/personal-report-route-core.ts | 9 ++-- frontend/tests/personal-report-api.test.ts | 19 +++++++ .../tests/personal-report-entitlement.test.ts | 53 +++++++++++++++++++ frontend/tests/personal-report-entry.test.ts | 11 ++++ frontend/tests/sidebar-contract.test.ts | 5 ++ frontend/tests/starter-questions.test.ts | 2 +- 12 files changed, 148 insertions(+), 45 deletions(-) diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index f1f31de7..08af4663 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -57,6 +57,7 @@ export async function GET(request: Request, context: RouteContext) { requestUrl: request.url, origin: request.headers.get("origin"), allowedOrigins: resolveAllowedReportOrigins(process.env), + requestHeaders: request.headers, userId, reportId, persistence: persistence ?? { @@ -105,6 +106,7 @@ export async function DELETE(request: Request, context: RouteContext) { requestUrl: request.url, origin: request.headers.get("origin"), allowedOrigins: resolveAllowedReportOrigins(process.env), + requestHeaders: request.headers, userId, reportId, persistence: persistence ?? { diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 07bbe006..341b1a87 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -68,6 +68,7 @@ export async function POST(request: Request) { requestUrl: request.url, origin: request.headers.get("origin"), allowedOrigins: resolveAllowedReportOrigins(process.env), + requestHeaders: request.headers, userId, rawBody: await request.json().catch(() => null), profile, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 30e6af0f..dac5b2f8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -374,9 +374,8 @@ button:disabled { cursor: default; opacity: .45; } .chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 20px; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; } .chat-header > div { min-width: 0; } .chat-header strong { max-width: min(560px, 62vw); overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; letter-spacing: -.3px; } -.chat-header span { color: var(--color-ink-secondary); margin-top: var(--space-1); font-size: 12px; } -.status { display: inline-block; margin: 0 6px 1px 0; border-radius: 50%; background: var(--color-success); width: 7px; height: 7px; margin-right: var(--space-2); } -.status-loading { background: var(--color-action); } +.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; } +.chat-header-actions > * { flex: 0 0 auto; } .credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; } .conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index a0a20d3a..84aa845d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3111,11 +3111,6 @@ export default function Home() {
{activeSession?.title || "新对话"} - {isLoading - ? (consultationPhase === "undo" ? "即将发送,可撤回" : consultationPhase === "recovering" ? "正在恢复后台回答" : activeStreamingText ? "正在回答" : "正在核对星盘信息") - : rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification") - ? "正在校正出生时间" - : personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}
{reportEntryVisible && activeSession && ( diff --git a/frontend/src/components/personal-report/generate-personal-report-button.tsx b/frontend/src/components/personal-report/generate-personal-report-button.tsx index b3dba1fe..ffc4d4e4 100644 --- a/frontend/src/components/personal-report/generate-personal-report-button.tsx +++ b/frontend/src/components/personal-report/generate-personal-report-button.tsx @@ -21,6 +21,7 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { FileText } from "lucide-react"; +import { toast } from "sonner"; export const DEFAULT_REPORT_THEMES = ["career", "marriage", "wealth", "timing"] as const; @@ -155,7 +156,6 @@ export function GeneratePersonalReportButton({ }: GeneratePersonalReportButtonProps) { const router = useRouter(); const [submitting, setSubmitting] = useState(false); - const [notice, setNotice] = useState(null); const inFlight = useRef(false); async function handleGenerate() { @@ -164,11 +164,10 @@ export function GeneratePersonalReportButton({ } inFlight.current = true; setSubmitting(true); - setNotice(null); try { const requestId = createReportRequestId(); if (requestId === null) { - setNotice("请使用支持安全请求标识的现代浏览器。"); + toast.error("请使用支持安全请求标识的现代浏览器。"); return; } const body = buildPersonalReportCreateRequest(requestId, sessionId); @@ -189,9 +188,9 @@ export function GeneratePersonalReportButton({ router.push(`/reports/${encodeURIComponent(outcome.reportId)}`); return; } - setNotice(outcome.message); + toast.error(outcome.message); } catch { - setNotice("网络异常,请检查连接后重试。"); + toast.error("网络异常,请检查连接后重试。"); } finally { inFlight.current = false; setSubmitting(false); @@ -203,31 +202,15 @@ export function GeneratePersonalReportButton({ : "生成个人报告;服务端将校验本次咨询是否存在可用证据,无证据时会提示。"; return ( -
- - {notice !== null ? ( - - {notice} - - ) : evidenceState === "unknown" ? ( - - 服务端将校验本次咨询证据。 - - ) : ( - - 本次对话包含工作流证据记录,最终以服务端校验为准。 - - )} -
+ ); } diff --git a/frontend/src/lib/personal-report-entitlement.ts b/frontend/src/lib/personal-report-entitlement.ts index 99c304b0..c06b7e89 100644 --- a/frontend/src/lib/personal-report-entitlement.ts +++ b/frontend/src/lib/personal-report-entitlement.ts @@ -1,3 +1,5 @@ +import { normalizeIdentityHost } from "@/modules/identity/host"; + /** * Personal report export entitlement — independent from the "spend 1 credit" * consultation RPC. Capability key: report.export.personal. @@ -45,17 +47,46 @@ export type SameOriginDecision = Readonly< { ok: true } | { ok: false; code: "cross_origin_forbidden" } >; +type HeaderReader = Pick; + +function singleForwardedValue(value: string | null): string | null { + const normalized = value?.trim(); + return normalized && !normalized.includes(",") ? normalized : null; +} + +function matchesForwardedOrigin(origin: string, headers?: HeaderReader): boolean { + if (!headers?.has("x-forwarded-host") || !headers.has("x-forwarded-proto")) return false; + + const host = normalizeIdentityHost(headers.get("host")); + const forwardedHost = normalizeIdentityHost(singleForwardedValue(headers.get("x-forwarded-host"))); + const forwardedProto = singleForwardedValue(headers.get("x-forwarded-proto"))?.toLowerCase(); + if (!host || !forwardedHost || host !== forwardedHost + || (forwardedProto !== "http" && forwardedProto !== "https")) { + return false; + } + + try { + const originUrl = new URL(origin); + return origin === originUrl.origin + && originUrl.host.toLowerCase() === forwardedHost + && originUrl.protocol === `${forwardedProto}:`; + } catch { + return false; + } +} + /** * Same-origin check for report APIs. An absent Origin header (curl, server * tests, same-origin fetch from the browser never sends Origin for GET but * does for POST) is accepted; a matching request origin is accepted; a - * configured trusted-proxy/test allowlist is accepted; anything else is - * rejected. + * configured allowlist or consistent trusted-proxy host/protocol is accepted; + * anything else is rejected. */ export function checkSameOrigin( requestUrl: string | URL, originHeader: string | null, allowedOrigins: readonly string[], + requestHeaders?: HeaderReader, ): SameOriginDecision { const origin = originHeader?.trim(); if (!origin) return { ok: true }; @@ -67,6 +98,7 @@ export function checkSameOrigin( } if (origin === requestOrigin) return { ok: true }; if (allowedOrigins.includes(origin)) return { ok: true }; + if (matchesForwardedOrigin(origin, requestHeaders)) return { ok: true }; return { ok: false, code: "cross_origin_forbidden" }; } diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index cbb1962c..9023143b 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -144,6 +144,7 @@ export type ReportCreateCoreDeps = Readonly<{ requestUrl: string; origin: string | null; allowedOrigins: readonly string[]; + requestHeaders?: Pick; userId: string | null; rawBody: unknown; profile: unknown | null; @@ -165,7 +166,7 @@ export type ReportCreateCoreDeps = Readonly<{ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise { // Same-origin gate first (CSRF), then auth. - const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders); if (!originDecision.ok) { return { status: 403, @@ -416,6 +417,7 @@ export type ReportReadCoreDeps = Readonly<{ requestUrl: string; origin: string | null; allowedOrigins: readonly string[]; + requestHeaders?: Pick; userId: string | null; reportId: string; persistence: Pick; @@ -430,7 +432,7 @@ export type ReportReadCoreDeps = Readonly<{ }>; export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { - const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders); if (!originDecision.ok) { return { status: 403, @@ -474,13 +476,14 @@ export type ReportDeleteCoreDeps = Readonly<{ requestUrl: string; origin: string | null; allowedOrigins: readonly string[]; + requestHeaders?: Pick; userId: string | null; reportId: string; persistence: Pick; }>; export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise { - const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders); if (!originDecision.ok) { return { status: 403, diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index b1ebbf7e..8de4effa 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -270,6 +270,23 @@ test("core create: 403 on cross-origin", async () => { assert.equal(response.body.code, "report_resource_forbidden"); }); +test("core create: accepts staging same-origin POST behind the trusted reverse proxy", async () => { + const response = await resolveReportCreate({ + ...baseDeps({ + requestUrl: "http://staging.jyotisha.chat/api/reports", + origin: "https://staging.jyotisha.chat", + }), + requestHeaders: new Headers({ + host: "staging.jyotisha.chat", + "x-forwarded-host": "staging.jyotisha.chat", + "x-forwarded-proto": "https", + }), + }); + + assert.equal(response.status, 201); + assert.equal(response.body.report && typeof response.body.report, "object"); +}); + test("core create: 400 on invalid payload", async () => { const response = await resolveReportCreate(baseDeps({ rawBody: { requestId: "not-a-uuid" } })); assert.equal(response.status, 400); @@ -742,6 +759,8 @@ test("GET/DELETE use the authenticated client (least privilege) and the core han test("route core enforces same-origin and never leaks raw exception text", () => { assert.match(coreSource, /checkSameOrigin/); assert.match(coreSource, /REPORT_STABLE_CODES\.resourceForbidden/); + assert.match(createRoute, /requestHeaders:\s*request\.headers/); + assert.match(itemRoute, /requestHeaders:\s*request\.headers/); assert.doesNotMatch(createRoute, /error\.message\)/); assert.doesNotMatch(itemRoute, /error\.message\)/); assert.doesNotMatch(createRoute, /\.stack/); diff --git a/frontend/tests/personal-report-entitlement.test.ts b/frontend/tests/personal-report-entitlement.test.ts index 0f3baf36..9b2008ea 100644 --- a/frontend/tests/personal-report-entitlement.test.ts +++ b/frontend/tests/personal-report-entitlement.test.ts @@ -61,6 +61,59 @@ test("same-origin check rejects cross-origin and accepts a trusted allowlist", ( ); }); +test("same-origin check trusts only consistent forwarded host and protocol", () => { + const headers = new Headers({ + host: "staging.jyotisha.chat", + "x-forwarded-host": "staging.jyotisha.chat", + "x-forwarded-proto": "https", + }); + + assert.deepEqual( + checkSameOrigin( + "http://staging.jyotisha.chat/api/reports", + "https://staging.jyotisha.chat", + [], + headers, + ), + { ok: true }, + ); + assert.deepEqual( + checkSameOrigin( + "http://staging.jyotisha.chat/api/reports", + "https://evil.example", + [], + headers, + ), + { ok: false, code: "cross_origin_forbidden" }, + ); + assert.deepEqual( + checkSameOrigin( + "http://staging.jyotisha.chat/api/reports", + "https://staging.jyotisha.chat", + [], + new Headers({ + host: "staging.jyotisha.chat", + "x-forwarded-host": "evil.example", + "x-forwarded-proto": "https", + }), + ), + { ok: false, code: "cross_origin_forbidden" }, + ); + assert.deepEqual( + checkSameOrigin( + "http://staging.jyotisha.chat/api/reports", + "https://staging.jyotisha.chat", + [], + new Headers({ + host: "staging.jyotisha.chat", + "x-forwarded-host": "staging.jyotisha.chat", + "x-forwarded-proto": "https, http", + }), + ), + { ok: false, code: "cross_origin_forbidden" }, + ); +}); + test("entitlement blocks when the feature is disabled", async () => { const result = await checkPersonalReportEntitlement({ userId: "u1", diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index b153fde8..352e901f 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -18,6 +18,7 @@ const componentSource = readFileSync( "utf8", ); const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); test("request shape: POST /api/reports body carries only report identity fields", () => { const body = buildPersonalReportCreateRequest("request-id-1", "session-id-1"); @@ -144,6 +145,16 @@ test("repeat clicks are guarded while a request is in flight", () => { assert.match(componentSource, /正在生成/); }); +test("entry stays on one line and sends failures to the existing toast", () => { + assert.match(componentSource, /shrink-0/); + assert.match(componentSource, /whitespace-nowrap/); + assert.match(componentSource, /toast\.error\(outcome\.message\)/); + assert.match(componentSource, /toast\.error\("网络异常,请检查连接后重试。"\)/); + assert.doesNotMatch(componentSource, /personal-report-entry-note/); + assert.doesNotMatch(componentSource, /flex-wrap/); + assert.match(globalStyles, /\.chat-header-actions \{[^}]*min-width: max-content;[^}]*display: flex;[^}]*white-space: nowrap;/); +}); + test("entry never requests a server PDF and never prints directly", () => { assert.doesNotMatch(componentSource, /api\/report_artifact/); assert.doesNotMatch(componentSource, /window\.print/); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 2a912ff2..d03903e2 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -253,9 +253,14 @@ test("changes sidebar state without transition frames", () => { }); test("keeps the chat title in the flexible left-aligned header column", () => { + const page = readProjectFile("src/app/page.tsx"); + const header = page.slice(page.indexOf('
'), page.indexOf("
", page.indexOf('
'))); + assert.match(cssBlock(".chat-header"), /grid-template-columns:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/); assert.match(cssBlock(".chat-header"), /text-align:\s*left/); assert.doesNotMatch(cssBlock(".chat-header"), /justify-content:\s*space-between/); + assert.match(header, /\{activeSession\?\.title \|\| "新对话"\}<\/strong>/); + assert.doesNotMatch(header, /status-loading|基于星盘证据回答|回答一般占星知识|正在校正出生时间/); }); test("anchors the account footer to the bottom edge without trailing sidebar padding", () => { diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index a06266b5..02e02e5d 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -62,7 +62,7 @@ test("profiles without a usable birth minute only receive general-knowledge home "印度占星中的时间推运通常会看哪些因素?", ]); assert.match(pageSource, /const starterThemes = personalChartAvailable \? themes : generalGuidedJyotishTopics/); - assert.match(pageSource, /personalChartAvailable[\s\S]*?回答一般占星知识/); + assert.doesNotMatch(pageSource, /回答一般占星知识/); assert.match(pageSource, /完成生时校正后,再讨论个人星盘结论/); assert.match(pageSource, /personalChartAvailable \? "daily_starlanguage" : null/); });