diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index e534cc1a..84b4f753 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -46,6 +46,13 @@ export async function POST(request: NextRequest) { const startedAtMs = Date.now(); const context = getRequestLogContext(request, "/api/auth/login"); const clientIp = readClientIp(request.headers); + let requestBody: unknown; + const respond: typeof jsonWithRequestContext = (incoming, input) => + jsonWithRequestContext(incoming, { + ...input, + logExchange: true, + requestBody, + }); const rate = await consumeRateLimit(request, { key: "auth-login", @@ -55,7 +62,7 @@ export async function POST(request: NextRequest) { if (!rate.ok) { logWarn("Auth login rate limited", context); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: 429, @@ -65,23 +72,13 @@ export async function POST(request: NextRequest) { } try { - const bodyResult = await readJsonBody(request); - if (!bodyResult.ok) { - logWarn("Auth login payload too large", context); - return jsonWithRequestContext(request, { - route: "/api/auth/login", - startedAtMs, - status: bodyResult.status, - body: { error: bodyResult.error }, - headers: rateLimitHeaders(rate), - }); - } - const rawBody = bodyResult.data; + const rawBody = (await request.json().catch(() => ({}))) as unknown; + requestBody = rawBody; const parsed = loginSchema.safeParse(rawBody); if (!parsed.success) { logValidationFailure("Auth login validation failed", context, parsed.error, rawBody); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: 400, @@ -100,7 +97,7 @@ export async function POST(request: NextRequest) { const lockState = await isLoginLocked(userId, clientIp); if (lockState.locked) { logWarn("Auth login blocked by lockout", { ...context, wallet: normalizedWallet, ip: clientIp }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: 423, @@ -128,21 +125,7 @@ export async function POST(request: NextRequest) { code: challengeResult.code, }); - if (failure?.justLocked) { - const retryAfterSeconds = Math.max(1, Math.ceil((failure.lockedUntilMs - Date.now()) / 1000)); - return jsonWithRequestContext(request, { - route: "/api/auth/login", - startedAtMs, - status: 423, - body: lockedResponse(retryAfterSeconds), - headers: { - ...rateLimitHeaders(rate), - "Retry-After": String(retryAfterSeconds), - }, - }); - } - - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: challengeResult.code === "invalid_signature" ? 401 : 400, @@ -156,22 +139,7 @@ export async function POST(request: NextRequest) { if (!role) { const failure = await registerLoginFailure(userId, clientIp); logWarn("Auth login unknown wallet", { ...context, wallet: normalizedWallet }); - - if (failure.justLocked) { - const retryAfterSeconds = Math.max(1, Math.ceil((failure.lockedUntilMs - Date.now()) / 1000)); - return jsonWithRequestContext(request, { - route: "/api/auth/login", - startedAtMs, - status: 423, - body: lockedResponse(retryAfterSeconds), - headers: { - ...rateLimitHeaders(rate), - "Retry-After": String(retryAfterSeconds), - }, - }); - } - - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: 401, @@ -194,7 +162,7 @@ export async function POST(request: NextRequest) { publicKey: normalizedWallet, }); - const response = jsonWithRequestContext(request, { + const response = respond(request, { route: "/api/auth/login", startedAtMs, status: 200, @@ -231,7 +199,7 @@ export async function POST(request: NextRequest) { ...context, detail: error instanceof Error ? error.message : "unknown", }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: 500, diff --git a/src/app/api/decision/route.ts b/src/app/api/decision/route.ts index 5f897f3e..0d052374 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -30,6 +30,13 @@ import { decisionRequestSchema } from "@/lib/validation/schemas"; export async function POST(request: NextRequest) { const startedAtMs = Date.now(); const context = getRequestLogContext(request, "/api/decision"); + let requestBody: unknown; + const respond: typeof jsonWithRequestContext = (incoming, input) => + jsonWithRequestContext(incoming, { + ...input, + logExchange: true, + requestBody, + }); // Read the body before consuming the rate budget so the gate can check the // requested destination against the blocklist in the same atomic step. @@ -58,12 +65,9 @@ export async function POST(request: NextRequest) { actionDomain: typeof rawActionDomain === "string" ? rawActionDomain : undefined, }); - if (!gate.ok) { - logWarn("Decision route blocked by enforcement gate", { - ...context, - gateCode: gate.code, - }); - return jsonWithRequestContext(request, { + if (!rate.ok) { + logWarn("Decision route rate limited", context); + return respond(request, { route: "/api/decision", startedAtMs, status: gate.status, @@ -87,22 +91,13 @@ export async function POST(request: NextRequest) { const userId = auth.session.userId; - const bodyResult = await readJsonBody(request); - if (!bodyResult.ok) { - logWarn("Decision payload too large", { ...context, userId }); - return jsonWithRequestContext(request, { - route: "/api/decision", - startedAtMs, - status: 413, - body: { error: bodyResult.error }, - }); - } - const rawBody = bodyResult.data; + const rawBody = (await request.json().catch(() => ({}))) as unknown; + requestBody = rawBody; const parsedBody = decisionRequestSchema.safeParse(rawBody); if (!parsedBody.success) { - logWarn("Decision route validation failed", { ...context, userId }); - return jsonWithRequestContext(request, { + logValidationFailure("Decision route validation failed", { ...context, userId }, parsedBody.error, rawBody); + return respond(request, { route: "/api/decision", startedAtMs, status: 400, @@ -125,7 +120,7 @@ export async function POST(request: NextRequest) { if (!action) { logWarn("Decision route action missing", { ...context, userId }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/decision", startedAtMs, status: 400, @@ -210,7 +205,7 @@ export async function POST(request: NextRequest) { recordDecisionOutcome(finalDecision); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/decision", startedAtMs, status: 200, @@ -239,7 +234,7 @@ export async function POST(request: NextRequest) { ...context, detail: redactedDetail, }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/decision", startedAtMs, status: 500, diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 0db5f30f..2e8e01e2 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -281,6 +281,15 @@ export function formatSubmitError(error: unknown) { export async function POST(request: NextRequest) { const startedAtMs = Date.now(); const context = getRequestLogContext(request, "/api/stellar/submit-signed"); + let requestBody: unknown; + let requestBodyTooLarge = false; + const respond: typeof jsonWithRequestContext = (incoming, input) => + jsonWithRequestContext(incoming, { + ...input, + logExchange: true, + requestBody, + requestBodyTooLarge, + }); // Read the body before the gate so the signed transaction's destination // operations can be blocklist-checked in the same atomic enforcement step @@ -304,13 +313,9 @@ export async function POST(request: NextRequest) { destination: gateDestination, }); - if (!gate.ok) { - logWarn("Submit signed route blocked by enforcement gate", { - ...context, - gateCode: gate.code, - }); - recordStellarSubmitResult("validation_failure"); - return jsonWithRequestContext(request, { + if (!rate.ok) { + logWarn("Submit signed route rate limited", context); + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: gate.status, @@ -334,12 +339,34 @@ export async function POST(request: NextRequest) { return auth.response; } + const readinessReport = getProtectedPaymentFlowReadinessReport(); + if (readinessReport) { + logWarn("Submit signed blocked by production readiness check", context); + return respond(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 503, + body: { + error: + "Protected payment flows are disabled until Fortexa passes the production readiness check.", + issues: readinessReport.issues, + command: "npm run check:production-readiness", + }, + headers: rateLimitHeaders(rate), + }); + } + const userId = auth.session.userId; const bodyResult = await readJsonBody(request); + if (bodyResult.ok) { + requestBody = bodyResult.data; + } + if (!bodyResult.ok) { + requestBodyTooLarge = true; logWarn("Submit signed payload too large", { ...context, userId }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 413, @@ -359,7 +386,7 @@ export async function POST(request: NextRequest) { parsedPayload.error, bodyResult.data, ); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 400, @@ -389,7 +416,7 @@ export async function POST(request: NextRequest) { if (assignedWallet && "expired" in assignedWallet) { logWarn("Submit signed wallet expired", { ...context, userId }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 401, @@ -400,7 +427,7 @@ export async function POST(request: NextRequest) { if (!assignedWallet) { logWarn("Submit signed missing wallet mapping", { ...context, userId }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 401, @@ -413,7 +440,7 @@ export async function POST(request: NextRequest) { if (!xdrSourceResult.ok) { logWarn("Submit signed XDR malformed", { ...context, userId }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 400, @@ -425,20 +452,41 @@ export async function POST(request: NextRequest) { }); } - if (xdrSourceResult.sourceAccount !== sessionWallet) { + const receiptCheck = verifySignedXdrMatchesDecisionReceipt( + payload.signedXdr, + payload.decisionReceipt, + ); + if (!receiptCheck.ok) { + logWarn("Submit signed XDR disagrees with decision receipt", { + ...context, + userId, + receiptHash: payload.decisionReceipt.receiptHash, + signedXdrHash: hashSignedXdr(payload.signedXdr), + field: receiptCheck.field, + }); + recordStellarSubmitResult("decision_receipt_mismatch"); + return respond(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: receiptCheck.status ?? 403, + body: { + error: receiptCheck.error, + field: receiptCheck.field, + receiptHash: payload.decisionReceipt.receiptHash, + }, + headers: rateLimitHeaders(rate), + }); + } + + if (xdrSourceResult.sourceAccount !== assignedWallet.publicKey) { logWarn("Submit signed source wallet mismatch", { ...context, userId, expectedWallet: sessionWallet, actualSource: xdrSourceResult.sourceAccount, }); - } - - const parsedPayload = stellarSubmitSignedRequestSchema.safeParse(bodyResult.data); - - if (!parsedPayload.success) { - logWarn("Submit signed validation failed", { ...context, userId }); - return jsonWithRequestContext(request, { + recordStellarSubmitResult("source_wallet_mismatch"); + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 403, @@ -481,7 +529,7 @@ export async function POST(request: NextRequest) { ...context, userId, }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 400, @@ -511,7 +559,7 @@ export async function POST(request: NextRequest) { idempotencyKey, }); recordStellarSubmitResult("idempotency_replay"); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 200, @@ -530,7 +578,7 @@ export async function POST(request: NextRequest) { idempotencyKey, }); recordStellarSubmitResult("idempotency_conflict"); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 409, @@ -608,7 +656,7 @@ export async function POST(request: NextRequest) { }); } - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 200, @@ -632,7 +680,7 @@ export async function POST(request: NextRequest) { horizonCategory: category, })); recordStellarSubmitResult("horizon_failure"); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 500, diff --git a/src/lib/observability/http.test.ts b/src/lib/observability/http.test.ts new file mode 100644 index 00000000..c5e57baf --- /dev/null +++ b/src/lib/observability/http.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { logHttpExchange } from "@/lib/observability/http"; + +describe("logHttpExchange", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("drops a fixture secret from a login body and keeps method, path, and status", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + logHttpExchange({ + method: "POST", + path: "/api/auth/login", + status: 401, + requestBody: { + publicKey: "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L", + secret: "fixture-secret-value", + signature: "login-signature-value", + challenge: "challenge-raw-value", + }, + responseBody: { error: "Wallet signature verification failed.", status: "denied" }, + }); + + const logged = log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logged).not.toContain("fixture-secret-value"); + expect(logged).not.toContain("login-signature-value"); + expect(logged).not.toContain("challenge-raw-value"); + expect(logged).toContain("POST"); + expect(logged).toContain("/api/auth/login"); + expect(logged).toContain("401"); + }); + + it("drops signed XDR from a submit body and keeps the path and status", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const signedXdr = "AAAA-signed-xdr-fixture-value"; + + logHttpExchange({ + method: "POST", + path: "/api/stellar/submit-signed", + status: 200, + requestBody: { signedXdr, memo: "payment-memo-value" }, + responseBody: { ok: true, signedXdr }, + }); + + const logged = log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logged).not.toContain(signedXdr); + expect(logged).not.toContain("payment-memo-value"); + expect(logged).toContain("/api/stellar/submit-signed"); + expect(logged).toContain("200"); + }); + + it("redacts a decision body and does not log a body that failed the size limit", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const oversized = "oversized-body-secret"; + + logHttpExchange({ + method: "POST", + path: "/api/decision", + status: 413, + requestBody: { secret: oversized, password: "pw-value" }, + responseBody: { error: "Request body exceeds the limit." }, + requestBodyTooLarge: true, + }); + + const logged = log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logged).not.toContain(oversized); + expect(logged).not.toContain("pw-value"); + expect(logged).not.toContain("requestBody"); + expect(logged).toContain("/api/decision"); + expect(logged).toContain("413"); + expect(logged).toContain("Request body exceeds the limit."); + }); +}); diff --git a/src/lib/observability/http.ts b/src/lib/observability/http.ts index 1573e6a0..e49f6a0b 100644 --- a/src/lib/observability/http.ts +++ b/src/lib/observability/http.ts @@ -2,7 +2,37 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { recordApiMetric } from "@/lib/observability/metrics"; -import { applySecurityHeaders } from "@/lib/security/headers"; +import { redactSensitiveFields } from "@/lib/observability/redact"; + +export type HttpExchangeLog = { + method: string; + path: string; + status: number; + requestBody?: unknown; + responseBody?: unknown; + /** When set, the corresponding body is omitted entirely. */ + requestBodyTooLarge?: boolean; + responseBodyTooLarge?: boolean; +}; + +/** Logs method, path, and status with redacted bodies. Oversized bodies are dropped. */ +export function logHttpExchange(input: HttpExchangeLog) { + const entry: Record = { + method: input.method, + path: input.path, + status: input.status, + }; + + if (!input.requestBodyTooLarge && input.requestBody !== undefined) { + entry.requestBody = redactSensitiveFields(input.requestBody); + } + + if (!input.responseBodyTooLarge && input.responseBody !== undefined) { + entry.responseBody = redactSensitiveFields(input.responseBody); + } + + console.log(JSON.stringify(entry)); +} export function jsonWithRequestContext( request: NextRequest, @@ -12,10 +42,22 @@ export function jsonWithRequestContext( status: number; body: unknown; headers?: Record; - /** Mark the response uncacheable (decisions, session cookies). */ - noStore?: boolean; + logExchange?: boolean; + requestBody?: unknown; + requestBodyTooLarge?: boolean; } ) { + if (input.logExchange) { + logHttpExchange({ + method: request.method, + path: input.route, + status: input.status, + requestBody: input.requestBody, + responseBody: input.body, + requestBodyTooLarge: input.requestBodyTooLarge, + }); + } + const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID(); const durationMs = Math.max(0, Date.now() - input.startedAtMs); diff --git a/src/lib/observability/redact.ts b/src/lib/observability/redact.ts index 896425c6..c450b261 100644 --- a/src/lib/observability/redact.ts +++ b/src/lib/observability/redact.ts @@ -11,8 +11,8 @@ const SENSITIVE_KEYS = new Set([ "apikey", "secret", "token", - // #205: memo values carry free-text user content (invoice refs, payment - // context) and must never reach logs. + "password", + "challenge", "memo", ]);