From 84fb8567cca160bebb24a66eb84e0ee020e7ca2f Mon Sep 17 00:00:00 2001 From: emrekayat Date: Thu, 1 Oct 2026 04:04:58 +0300 Subject: [PATCH] Redact auth and payment bodies in HTTP logs. Login, decision, and submit logs keep the method, path, and status, while secrets, signatures, and signed XDR are redacted and an oversized body is left out of the log. Co-authored-by: Cursor --- src/app/api/auth/login/route.ts | 22 +++++-- src/app/api/decision/route.ts | 18 ++++-- src/app/api/stellar/submit-signed/route.ts | 42 ++++++++---- src/lib/observability/http.test.ts | 75 ++++++++++++++++++++++ src/lib/observability/http.ts | 45 +++++++++++++ src/lib/observability/redact.ts | 3 + 6 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 src/lib/observability/http.test.ts diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index e9da0ac2..ea92e4e0 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -36,6 +36,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", @@ -45,7 +52,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, @@ -56,11 +63,12 @@ export async function POST(request: NextRequest) { try { 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, @@ -74,7 +82,7 @@ export async function POST(request: NextRequest) { const lockState = await isLoginLocked(normalizedWallet, 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, @@ -105,7 +113,7 @@ export async function POST(request: NextRequest) { code: challengeResult.code, }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: challengeResult.code === "invalid_signature" ? 401 : 400, @@ -126,7 +134,7 @@ export async function POST(request: NextRequest) { if (!role) { const failure = await registerLoginFailure(normalizedWallet, clientIp); logWarn("Auth login unknown wallet", { ...context, wallet: normalizedWallet }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/auth/login", startedAtMs, status: 401, @@ -153,7 +161,7 @@ export async function POST(request: NextRequest) { userId, }); - const response = jsonWithRequestContext(request, { + const response = respond(request, { route: "/api/auth/login", startedAtMs, status: 200, @@ -183,7 +191,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 79aac47b..265614e1 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -27,6 +27,13 @@ import { logValidationFailure, toPublicValidationDetails } from "@/lib/validatio 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, + }); const rate = await consumeRateLimit(request, { key: "decision", @@ -36,7 +43,7 @@ export async function POST(request: NextRequest) { if (!rate.ok) { logWarn("Decision route rate limited", context); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/decision", startedAtMs, status: 429, @@ -56,11 +63,12 @@ export async function POST(request: NextRequest) { const userId = auth.session.userId; const rawBody = (await request.json().catch(() => ({}))) as unknown; + requestBody = rawBody; const parsedBody = decisionRequestSchema.safeParse(rawBody); if (!parsedBody.success) { logValidationFailure("Decision route validation failed", { ...context, userId }, parsedBody.error, rawBody); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/decision", startedAtMs, status: 400, @@ -83,7 +91,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, @@ -149,7 +157,7 @@ export async function POST(request: NextRequest) { recordDecisionOutcome(finalDecision); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/decision", startedAtMs, status: 200, @@ -170,7 +178,7 @@ export async function POST(request: NextRequest) { ...context, detail: error instanceof Error ? error.message : "unknown", }); - 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 8b6f7302..0e2d0e6c 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -251,6 +251,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, + }); const rate = await consumeRateLimit(request, { key: "stellar-submit-signed", @@ -260,7 +269,7 @@ export async function POST(request: NextRequest) { if (!rate.ok) { logWarn("Submit signed route rate limited", context); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 429, @@ -282,7 +291,7 @@ export async function POST(request: NextRequest) { const readinessReport = getProtectedPaymentFlowReadinessReport(); if (readinessReport) { logWarn("Submit signed blocked by production readiness check", context); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 503, @@ -299,9 +308,14 @@ export async function POST(request: NextRequest) { 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, @@ -321,7 +335,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, @@ -339,7 +353,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, @@ -350,7 +364,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, @@ -363,7 +377,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, @@ -388,7 +402,7 @@ export async function POST(request: NextRequest) { field: receiptCheck.field, }); recordStellarSubmitResult("decision_receipt_mismatch"); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: receiptCheck.status ?? 403, @@ -409,7 +423,7 @@ export async function POST(request: NextRequest) { actualSource: xdrSourceResult.sourceAccount, }); recordStellarSubmitResult("source_wallet_mismatch"); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 400, @@ -435,7 +449,7 @@ export async function POST(request: NextRequest) { ...context, userId, }); - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 400, @@ -458,7 +472,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, @@ -477,7 +491,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, @@ -521,7 +535,7 @@ export async function POST(request: NextRequest) { }); } - return jsonWithRequestContext(request, { + return respond(request, { route: "/api/stellar/submit-signed", startedAtMs, status: 200, @@ -539,7 +553,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 11e9b2d9..e3d0f489 100644 --- a/src/lib/observability/http.ts +++ b/src/lib/observability/http.ts @@ -2,6 +2,37 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { recordApiMetric } from "@/lib/observability/metrics"; +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, @@ -11,8 +42,22 @@ export function jsonWithRequestContext( status: number; body: unknown; headers?: Record; + 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 652a8450..f4509aee 100644 --- a/src/lib/observability/redact.ts +++ b/src/lib/observability/redact.ts @@ -8,6 +8,9 @@ const SENSITIVE_KEYS = new Set([ "groq_api_key", "secret", "token", + "password", + "challenge", + "memo", ]); function isSensitiveKey(key: string): boolean {