fix(reports): trust verified proxy origin
Staging Backend Quality Gate / validate (push) Successful in 12m13s
Staging Backend Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-09 00:54:55 +08:00
parent 32b683ebef
commit 17430a1f21
12 changed files with 148 additions and 45 deletions
@@ -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 ?? {
+1
View File
@@ -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,
+2 -3
View File
@@ -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); }
-5
View File
@@ -3111,11 +3111,6 @@ export default function Home() {
<SidebarTrigger placement="inset" />
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading
? (consultationPhase === "undo" ? "即将发送,可撤回" : consultationPhase === "recovering" ? "正在恢复后台回答" : activeStreamingText ? "正在回答" : "正在核对星盘信息")
: rectificationSurfaceOpen || (!profileComplete && onboardingStep === "rectification")
? "正在校正出生时间"
: personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}</span>
</div>
<div className="chat-header-actions">
{reportEntryVisible && activeSession && (
@@ -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<string | null>(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 (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted disabled:pointer-events-none disabled:opacity-50"
onClick={() => void handleGenerate()}
disabled={submitting}
title={title}
aria-describedby="personal-report-entry-note"
>
<FileText aria-hidden="true" className="size-4" />
{submitting ? "正在生成…" : "生成个人报告"}
</button>
{notice !== null ? (
<span id="personal-report-entry-note" role="status" className="max-w-56 text-xs text-warning">
{notice}
</span>
) : evidenceState === "unknown" ? (
<span id="personal-report-entry-note" className="max-w-56 text-xs text-ink-tertiary">
</span>
) : (
<span id="personal-report-entry-note" className="sr-only">
</span>
)}
</div>
<button
type="button"
className="inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg border border-border bg-canvas px-3 py-1.5 text-sm text-ink transition-colors hover:bg-canvas-muted disabled:pointer-events-none disabled:opacity-50"
onClick={() => void handleGenerate()}
disabled={submitting}
title={title}
>
<FileText aria-hidden="true" className="size-4" />
{submitting ? "正在生成…" : "生成个人报告"}
</button>
);
}
@@ -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<Headers, "get" | "has">;
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" };
}
@@ -144,6 +144,7 @@ export type ReportCreateCoreDeps = Readonly<{
requestUrl: string;
origin: string | null;
allowedOrigins: readonly string[];
requestHeaders?: Pick<Headers, "get" | "has">;
userId: string | null;
rawBody: unknown;
profile: unknown | null;
@@ -165,7 +166,7 @@ export type ReportCreateCoreDeps = Readonly<{
export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<ReportRouteResponse> {
// 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<Headers, "get" | "has">;
userId: string | null;
reportId: string;
persistence: Pick<PersonalReportService, "getOwnedById">;
@@ -430,7 +432,7 @@ export type ReportReadCoreDeps = Readonly<{
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
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<Headers, "get" | "has">;
userId: string | null;
reportId: string;
persistence: Pick<PersonalReportService, "getOwnedById" | "deleteOwned">;
}>;
export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise<ReportRouteResponse> {
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,
@@ -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/);
@@ -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",
@@ -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/);
+5
View File
@@ -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('<header className="chat-header">'), page.indexOf("</header>", page.indexOf('<header className="chat-header">')));
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, /<strong>\{activeSession\?\.title \|\| "新对话"\}<\/strong>/);
assert.doesNotMatch(header, /status-loading|基于星盘证据回答|回答一般占星知识|正在校正出生时间/);
});
test("anchors the account footer to the bottom edge without trailing sidebar padding", () => {
+1 -1
View File
@@ -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/);
});