Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/backend/src/common/exceptions/index.js
Original file line number Diff line number Diff line change
@@ -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; } });
1 change: 1 addition & 0 deletions app/backend/src/common/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SorobanDomainException } from './soroban-domain.exception';
43 changes: 43 additions & 0 deletions app/backend/src/common/exceptions/soroban-domain.exception.js
Original file line number Diff line number Diff line change
@@ -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;
56 changes: 56 additions & 0 deletions app/backend/src/common/exceptions/soroban-domain.exception.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SorobanErrorCode, HttpStatus>> = {
[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<string, unknown>;

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;
}
}
16 changes: 14 additions & 2 deletions app/backend/src/common/filters/global-http-exception.filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down
25 changes: 22 additions & 3 deletions app/backend/src/common/filters/global-http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down
27 changes: 20 additions & 7 deletions app/backend/src/common/utils/redaction.util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 28 additions & 11 deletions app/backend/src/common/utils/redaction.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Record<string, unknown>>(obj: T): Omit<T, 'technicalError'> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { technicalError: _, ...safe } = obj;
return safe as Omit<T, 'technicalError'>;
}

/**
* Create a safe config summary for logging at startup.
* Shows which required configs are loaded without exposing values.
Expand Down
49 changes: 46 additions & 3 deletions app/backend/src/common/utils/redaction.util.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
redactValue,
sanitizeErrorMessage,
createConfigSummary,
omitTechnicalError,
} from './redaction.util';

describe('Redaction Utilities', () => {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
const safe = omitTechnicalError(obj);
expect(safe).toEqual({ code: 'OK', message: 'fine' });
});
});
});
Loading