fix(reports): trust verified proxy origin
This commit is contained in:
@@ -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 ?? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user