diff --git a/app/backend/src/common/exceptions/index.js b/app/backend/src/common/exceptions/index.js new file mode 100644 index 000000000..0831fd709 --- /dev/null +++ b/app/backend/src/common/exceptions/index.js @@ -0,0 +1,4 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var soroban_domain_exception_1 = require("./soroban-domain.exception"); +Object.defineProperty(exports, "SorobanDomainException", { enumerable: true, get: function () { return soroban_domain_exception_1.SorobanDomainException; } }); diff --git a/app/backend/src/common/exceptions/index.ts b/app/backend/src/common/exceptions/index.ts new file mode 100644 index 000000000..5b5b05437 --- /dev/null +++ b/app/backend/src/common/exceptions/index.ts @@ -0,0 +1 @@ +export { SorobanDomainException } from './soroban-domain.exception'; diff --git a/app/backend/src/common/exceptions/soroban-domain.exception.js b/app/backend/src/common/exceptions/soroban-domain.exception.js new file mode 100644 index 000000000..68f3fcaf7 --- /dev/null +++ b/app/backend/src/common/exceptions/soroban-domain.exception.js @@ -0,0 +1,43 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SorobanDomainException = void 0; +const common_1 = require("@nestjs/common"); +const soroban_error_codes_1 = require("../soroban-errors/soroban-error.codes"); + +/** Maps stable domain error codes to appropriate HTTP status codes. */ +const HTTP_STATUS_MAP = { + [soroban_error_codes_1.SorobanErrorCode.UNAUTHORIZED]: common_1.HttpStatus.FORBIDDEN, + [soroban_error_codes_1.SorobanErrorCode.AUTH_MISSING]: common_1.HttpStatus.UNAUTHORIZED, + [soroban_error_codes_1.SorobanErrorCode.NOT_FOUND]: common_1.HttpStatus.NOT_FOUND, + [soroban_error_codes_1.SorobanErrorCode.ESCROW_NOT_FOUND]: common_1.HttpStatus.NOT_FOUND, + [soroban_error_codes_1.SorobanErrorCode.CONTRACT_PAUSED]: common_1.HttpStatus.SERVICE_UNAVAILABLE, + [soroban_error_codes_1.SorobanErrorCode.CONTRACT_WRITES_DISABLED]: common_1.HttpStatus.SERVICE_UNAVAILABLE, + [soroban_error_codes_1.SorobanErrorCode.INDEXER_LAGGING]: common_1.HttpStatus.SERVICE_UNAVAILABLE, + [soroban_error_codes_1.SorobanErrorCode.RESTORE_REQUIRED]: common_1.HttpStatus.CONFLICT, + [soroban_error_codes_1.SorobanErrorCode.ESCROW_ALREADY_SETTLED]: common_1.HttpStatus.CONFLICT, + [soroban_error_codes_1.SorobanErrorCode.REFUND_DUPLICATE]: common_1.HttpStatus.CONFLICT, + [soroban_error_codes_1.SorobanErrorCode.BUDGET_EXCEEDED]: common_1.HttpStatus.UNPROCESSABLE_ENTITY, + [soroban_error_codes_1.SorobanErrorCode.INVALID_INPUT]: common_1.HttpStatus.BAD_REQUEST, + [soroban_error_codes_1.SorobanErrorCode.INVALID_AMOUNT]: common_1.HttpStatus.BAD_REQUEST, + [soroban_error_codes_1.SorobanErrorCode.INVALID_ADMIN]: common_1.HttpStatus.BAD_REQUEST, + [soroban_error_codes_1.SorobanErrorCode.INVALID_WASM_HASH]: common_1.HttpStatus.BAD_REQUEST, + [soroban_error_codes_1.SorobanErrorCode.INSUFFICIENT_BALANCE]: common_1.HttpStatus.UNPROCESSABLE_ENTITY, + [soroban_error_codes_1.SorobanErrorCode.VERSION_MISMATCH]: common_1.HttpStatus.UNPROCESSABLE_ENTITY, +}; + +class SorobanDomainException extends common_1.HttpException { + constructor(mapped) { + const status = HTTP_STATUS_MAP[mapped.code] ?? common_1.HttpStatus.UNPROCESSABLE_ENTITY; + super( + Object.assign( + { code: mapped.code, message: mapped.message }, + mapped.details ? { details: mapped.details } : {} + ), + status + ); + this.domainCode = mapped.code; + this.technicalError = mapped.technicalError; + this.details = mapped.details; + } +} +exports.SorobanDomainException = SorobanDomainException; diff --git a/app/backend/src/common/exceptions/soroban-domain.exception.ts b/app/backend/src/common/exceptions/soroban-domain.exception.ts new file mode 100644 index 000000000..fdf4702bc --- /dev/null +++ b/app/backend/src/common/exceptions/soroban-domain.exception.ts @@ -0,0 +1,56 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import type { MappedSorobanError } from '../soroban-errors'; +import { SorobanErrorCode } from '../soroban-errors'; + +/** Maps stable domain error codes to appropriate HTTP status codes. */ +const HTTP_STATUS_MAP: Partial> = { + [SorobanErrorCode.UNAUTHORIZED]: HttpStatus.FORBIDDEN, + [SorobanErrorCode.AUTH_MISSING]: HttpStatus.UNAUTHORIZED, + [SorobanErrorCode.NOT_FOUND]: HttpStatus.NOT_FOUND, + [SorobanErrorCode.ESCROW_NOT_FOUND]: HttpStatus.NOT_FOUND, + [SorobanErrorCode.CONTRACT_PAUSED]: HttpStatus.SERVICE_UNAVAILABLE, + [SorobanErrorCode.CONTRACT_WRITES_DISABLED]: HttpStatus.SERVICE_UNAVAILABLE, + [SorobanErrorCode.INDEXER_LAGGING]: HttpStatus.SERVICE_UNAVAILABLE, + [SorobanErrorCode.RESTORE_REQUIRED]: HttpStatus.CONFLICT, + [SorobanErrorCode.ESCROW_ALREADY_SETTLED]: HttpStatus.CONFLICT, + [SorobanErrorCode.REFUND_DUPLICATE]: HttpStatus.CONFLICT, + [SorobanErrorCode.BUDGET_EXCEEDED]: HttpStatus.UNPROCESSABLE_ENTITY, + [SorobanErrorCode.INVALID_INPUT]: HttpStatus.BAD_REQUEST, + [SorobanErrorCode.INVALID_AMOUNT]: HttpStatus.BAD_REQUEST, + [SorobanErrorCode.INVALID_ADMIN]: HttpStatus.BAD_REQUEST, + [SorobanErrorCode.INVALID_WASM_HASH]: HttpStatus.BAD_REQUEST, + [SorobanErrorCode.INSUFFICIENT_BALANCE]: HttpStatus.UNPROCESSABLE_ENTITY, + [SorobanErrorCode.VERSION_MISMATCH]: HttpStatus.UNPROCESSABLE_ENTITY, +}; + +/** + * Typed domain exception for Soroban contract errors. + * + * Wraps a {@link MappedSorobanError} and exposes a stable `code` and `message` + * suitable for API responses. The raw `technicalError` from the mapper is held + * internally for server-side logging only and is never included in the HTTP body. + */ +export class SorobanDomainException extends HttpException { + readonly domainCode: SorobanErrorCode; + /** Raw Soroban error — for server-side logging only, never sent to clients. */ + readonly technicalError: string; + readonly details?: Record; + + constructor(mapped: MappedSorobanError) { + const status = + HTTP_STATUS_MAP[mapped.code] ?? HttpStatus.UNPROCESSABLE_ENTITY; + + super( + { + code: mapped.code, + message: mapped.message, + ...(mapped.details ? { details: mapped.details } : {}), + }, + status, + ); + + this.domainCode = mapped.code; + this.technicalError = mapped.technicalError; + this.details = mapped.details; + } +} diff --git a/app/backend/src/common/filters/global-http-exception.filter.js b/app/backend/src/common/filters/global-http-exception.filter.js index ec968d37d..9bd2e3f8b 100644 --- a/app/backend/src/common/filters/global-http-exception.filter.js +++ b/app/backend/src/common/filters/global-http-exception.filter.js @@ -52,6 +52,8 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.GlobalHttpExceptionFilter = void 0; var common_1 = require("@nestjs/common"); var throttler_1 = require("@nestjs/throttler"); +var soroban_domain_exception_1 = require("../exceptions/soroban-domain.exception"); +var redaction_util_1 = require("../utils/redaction.util"); var GlobalHttpExceptionFilter = function () { var _classDecorators = [(0, common_1.Catch)()]; var _classDescriptor; @@ -91,6 +93,16 @@ var GlobalHttpExceptionFilter = function () { var route = this.resolveRoute(request); (_b = this.metricsService) === null || _b === void 0 ? void 0 : _b.recordRateLimitedRequest(request.method, route, (_c = rateLimitContext.group) !== null && _c !== void 0 ? _c : "public", (_d = rateLimitContext.keyType) !== null && _d !== void 0 ? _d : "ip"); } + else if (exception instanceof soroban_domain_exception_1.SorobanDomainException) { + // Typed domain exception: code and message are already safe; technicalError is logged only. + status = exception.getStatus(); + var domainBody = exception.getResponse(); + this.logger.warn("[SorobanDomainException] ".concat(domainBody.code, ": ").concat(exception.technicalError)); + return response.status(status).json({ + success: false, + error: __assign(__assign({ code: domainBody.code, message: domainBody.message }, (correlationId ? { request_id: correlationId, correlationId: correlationId } : {})), (domainBody.details && !isProduction ? { details: domainBody.details } : {})), + }); + } else if (exception instanceof common_1.HttpException) { status = exception.getStatus(); var res = exception.getResponse(); @@ -116,9 +128,9 @@ var GlobalHttpExceptionFilter = function () { } } else if (exception instanceof Error) { - message = isProduction ? "Internal server error" : exception.message; - // Log the full stack for server errors + // Log full error server-side; sanitize before sending to client. this.logger.error("Unhandled exception: ".concat(exception.message), exception.stack); + message = isProduction ? "Internal server error" : (0, redaction_util_1.sanitizeErrorMessage)(exception.message); } var body = { success: false, diff --git a/app/backend/src/common/filters/global-http-exception.filter.ts b/app/backend/src/common/filters/global-http-exception.filter.ts index d9c17a9cd..d4fb7ac50 100644 --- a/app/backend/src/common/filters/global-http-exception.filter.ts +++ b/app/backend/src/common/filters/global-http-exception.filter.ts @@ -10,6 +10,8 @@ import { ThrottlerException } from "@nestjs/throttler"; import { Request, Response } from "express"; import { AppConfigService } from "../../config"; import { MetricsService } from "../../metrics/metrics.service"; +import { SorobanDomainException } from "../exceptions/soroban-domain.exception"; +import { sanitizeErrorMessage } from "../utils/redaction.util"; interface ErrorResponseBody { success: false; @@ -94,6 +96,22 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { rateLimitContext.group ?? "public", rateLimitContext.keyType ?? "ip", ); + } else if (exception instanceof SorobanDomainException) { + // Typed domain exception: code and message are already safe; technicalError is logged only. + status = exception.getStatus(); + const body = exception.getResponse() as { code: string; message: string; details?: unknown }; + this.logger.warn( + `[SorobanDomainException] ${body.code}: ${exception.technicalError}`, + ); + return response.status(status).json({ + success: false, + error: { + code: body.code, + message: body.message, + ...(correlationId ? { request_id: correlationId, correlationId } : {}), + ...(body.details && !isProduction ? { details: body.details } : {}), + }, + }); } else if (exception instanceof HttpException) { status = exception.getStatus(); const res = exception.getResponse() as HttpExceptionResponse; @@ -127,13 +145,14 @@ export class GlobalHttpExceptionFilter implements ExceptionFilter { } } } else if (exception instanceof Error) { - message = isProduction ? "Internal server error" : exception.message; - - // Log the full stack for server errors + // Log full error server-side; sanitize before sending to client. this.logger.error( `Unhandled exception: ${exception.message}`, exception.stack, ); + message = isProduction + ? "Internal server error" + : sanitizeErrorMessage(exception.message); } const body: ErrorResponseBody = { diff --git a/app/backend/src/common/utils/redaction.util.js b/app/backend/src/common/utils/redaction.util.js index 91f706a23..3746a0120 100644 --- a/app/backend/src/common/utils/redaction.util.js +++ b/app/backend/src/common/utils/redaction.util.js @@ -7,6 +7,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.redactSensitiveValues = redactSensitiveValues; exports.redactValue = redactValue; exports.sanitizeErrorMessage = sanitizeErrorMessage; +exports.omitTechnicalError = omitTechnicalError; exports.createConfigSummary = createConfigSummary; /** * List of environment variable keys that contain sensitive information @@ -71,22 +72,34 @@ function isSensitiveKey(key) { } /** * Sanitize an error message to prevent leaking sensitive configuration. - * Removes any values that look like keys, tokens, or secrets. + * Removes any values that look like keys, tokens, secrets, or raw provider payloads. * * @param message - Error message to sanitize * @returns Sanitized error message */ function sanitizeErrorMessage(message) { - // Remove anything that looks like a secret key (S followed by 55 base64 chars) - var sanitized = message.replace(/S[A-Za-z0-9+/=]{55}/g, '[REDACTED_SECRET_KEY]'); - // Remove anything that looks like a public key (G followed by 55 base64 chars) - sanitized = sanitized.replace(/G[A-Za-z0-9+/=]{55}/g, '[REDACTED_PUBLIC_KEY]'); + // Remove Supabase keys (typically start with 'eyJ') before JWT pattern + var sanitized = message.replace(/eyJ[A-Za-z0-9+/=]{30,}/g, '[REDACTED_JWT]'); // Remove JWT-like tokens (three base64 segments separated by dots) sanitized = sanitized.replace(/[A-Za-z0-9+/=]{20,}\.[A-Za-z0-9+/=]{20,}\.[A-Za-z0-9+/=]{20,}/g, '[REDACTED_TOKEN]'); - // Remove Supabase keys (typically start with 'eyJ') - sanitized = sanitized.replace(/eyJ[A-Za-z0-9+/=]{30,}/g, '[REDACTED_JWT]'); + // Remove anything that looks like a secret key (S followed by 55 base64 chars) + sanitized = sanitized.replace(/S[A-Za-z0-9+/=]{55}/g, '[REDACTED_SECRET_KEY]'); + // Remove anything that looks like a Stellar account/public key (G followed by 55 base64 chars) + sanitized = sanitized.replace(/G[A-Za-z0-9+/=]{55}/g, '[REDACTED_ACCOUNT_ID]'); + // Strip raw Soroban HostError payloads — keep the error type/code but drop opaque detail blobs + sanitized = sanitized.replace(/HostError:\s*Error\([^)]+\)[^\n]*/g, '[REDACTED_HOST_ERROR]'); + // Remove raw Supabase error bodies (JSON-like or URL-encoded provider payloads) + sanitized = sanitized.replace(/"message"\s*:\s*"[^"]{40,}"/g, '"message":"[REDACTED]"'); return sanitized; } +/** + * Strip the `technicalError` field from a mapped error before it reaches an API response. + */ +function omitTechnicalError(obj) { + const { technicalError: _, ...safe } = obj; + void _; + return safe; +} /** * Create a safe config summary for logging at startup. * Shows which required configs are loaded without exposing values. diff --git a/app/backend/src/common/utils/redaction.util.ts b/app/backend/src/common/utils/redaction.util.ts index bf56d5ded..962b9ab85 100644 --- a/app/backend/src/common/utils/redaction.util.ts +++ b/app/backend/src/common/utils/redaction.util.ts @@ -72,27 +72,44 @@ function isSensitiveKey(key: string): boolean { /** * Sanitize an error message to prevent leaking sensitive configuration. - * Removes any values that look like keys, tokens, or secrets. - * + * Removes any values that look like keys, tokens, secrets, or raw provider payloads. + * * @param message - Error message to sanitize * @returns Sanitized error message */ export function sanitizeErrorMessage(message: string): string { - // Remove anything that looks like a secret key (S followed by 55 base64 chars) - let sanitized = message.replace(/S[A-Za-z0-9+/=]{55}/g, '[REDACTED_SECRET_KEY]'); - - // Remove anything that looks like a public key (G followed by 55 base64 chars) - sanitized = sanitized.replace(/G[A-Za-z0-9+/=]{55}/g, '[REDACTED_PUBLIC_KEY]'); - + // Remove Supabase keys (typically start with 'eyJ') before JWT pattern + let sanitized = message.replace(/eyJ[A-Za-z0-9+/=]{30,}/g, '[REDACTED_JWT]'); + // Remove JWT-like tokens (three base64 segments separated by dots) sanitized = sanitized.replace(/[A-Za-z0-9+/=]{20,}\.[A-Za-z0-9+/=]{20,}\.[A-Za-z0-9+/=]{20,}/g, '[REDACTED_TOKEN]'); - - // Remove Supabase keys (typically start with 'eyJ') - sanitized = sanitized.replace(/eyJ[A-Za-z0-9+/=]{30,}/g, '[REDACTED_JWT]'); + + // Remove anything that looks like a secret key (S followed by 55 base64 chars) + sanitized = sanitized.replace(/S[A-Za-z0-9+/=]{55}/g, '[REDACTED_SECRET_KEY]'); + + // Remove anything that looks like a Stellar account/public key (G followed by 55 base64 chars) + sanitized = sanitized.replace(/G[A-Za-z0-9+/=]{55}/g, '[REDACTED_ACCOUNT_ID]'); + + // Strip raw Soroban HostError payloads — keep the error type/code but drop opaque detail blobs + sanitized = sanitized.replace(/HostError:\s*Error\([^)]+\)[^\n]*/g, '[REDACTED_HOST_ERROR]'); + + // Remove raw Supabase error bodies (JSON-like or URL-encoded provider payloads) + sanitized = sanitized.replace(/"message"\s*:\s*"[^"]{40,}"/g, '"message":"[REDACTED]"'); return sanitized; } +/** + * Strip the `technicalError` field from a mapped error before it reaches an API response. + * Returns a copy of `obj` with `technicalError` omitted, whether it's a direct property + * or nested inside a `details` sub-object. + */ +export function omitTechnicalError>(obj: T): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { technicalError: _, ...safe } = obj; + return safe as Omit; +} + /** * Create a safe config summary for logging at startup. * Shows which required configs are loaded without exposing values. diff --git a/app/backend/src/common/utils/redaction.util.unit.spec.ts b/app/backend/src/common/utils/redaction.util.unit.spec.ts index 53183b1e2..6c3e55b32 100644 --- a/app/backend/src/common/utils/redaction.util.unit.spec.ts +++ b/app/backend/src/common/utils/redaction.util.unit.spec.ts @@ -3,6 +3,7 @@ import { redactValue, sanitizeErrorMessage, createConfigSummary, + omitTechnicalError, } from './redaction.util'; describe('Redaction Utilities', () => { @@ -84,14 +85,28 @@ describe('Redaction Utilities', () => { expect(result).not.toContain('SABC'); }); - it('should redact Stellar public keys', () => { - // The regex requires exactly 55 chars after G for Stellar public keys + it('should redact Stellar public keys (account IDs)', () => { + // The regex requires exactly 55 chars after G for Stellar public keys / account IDs const message = 'Invalid address GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEF'; const result = sanitizeErrorMessage(message); - expect(result).toContain('[REDACTED_PUBLIC_KEY]'); + expect(result).toContain('[REDACTED_ACCOUNT_ID]'); expect(result).not.toContain('GABC'); }); + it('should redact Soroban HostError raw payloads', () => { + const message = 'Simulation failed: HostError: Error(Auth, NotAuthorized) with context XYZ'; + const result = sanitizeErrorMessage(message); + expect(result).toContain('[REDACTED_HOST_ERROR]'); + expect(result).not.toContain('NotAuthorized'); + expect(result).not.toContain('HostError'); + }); + + it('should redact Soroban HostError with Storage type', () => { + const message = 'HostError: Error(Storage, MissingValue)'; + const result = sanitizeErrorMessage(message); + expect(result).toBe('[REDACTED_HOST_ERROR]'); + }); + it('should redact JWT tokens', () => { const message = 'Auth failed: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdef123456'; @@ -153,4 +168,32 @@ describe('Redaction Utilities', () => { expect(result).not.toContain('Missing'); }); }); + + describe('omitTechnicalError', () => { + it('removes technicalError from a mapped error object', () => { + const mapped = { code: 'ERR', message: 'msg', technicalError: 'raw host error' }; + const safe = omitTechnicalError(mapped); + expect(safe).not.toHaveProperty('technicalError'); + expect(safe.code).toBe('ERR'); + expect(safe.message).toBe('msg'); + }); + + it('preserves other fields', () => { + const mapped = { + code: 'CONTRACT_NOT_FOUND', + message: 'Not found', + technicalError: 'raw', + details: { errorType: 'Foo' }, + }; + const safe = omitTechnicalError(mapped); + expect(safe.details).toEqual({ errorType: 'Foo' }); + expect(safe).not.toHaveProperty('technicalError'); + }); + + it('is a no-op when technicalError is not present', () => { + const obj = { code: 'OK', message: 'fine' } as Record; + const safe = omitTechnicalError(obj); + expect(safe).toEqual({ code: 'OK', message: 'fine' }); + }); + }); });