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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"multer": "^2.1.1",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"sanitize-html": "^2.13.0",
"sqlite3": "^5.1.7",
"stellar-sdk": "^11.2.0",
"typeorm": "^0.3.17",
Expand All @@ -48,6 +49,7 @@
"@types/jsonwebtoken": "^9.0.5",
"@types/multer": "^2.1.0",
"@types/node": "^22.0.0",
"@types/sanitize-html": "^2.13.0",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
Expand Down
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import express, { Request } from "express";
import { createErrorMiddleware, notFoundMiddleware } from "./middleware/error.middleware";
import { applyRateLimiters } from "./middleware/rate-limit.middleware";
import { createRequestObservabilityMiddleware } from "./middleware/request-observability.middleware";
import { sanitizeInputMiddleware } from "./middleware/sanitize-input.middleware";

import { logger, type AppLogger } from "./observability/logger";
import { getMetricsContentType, MetricsRegistry } from "./observability/metrics";
Expand Down Expand Up @@ -108,6 +109,8 @@ export function createApp({

app.use(express.json());

app.use(sanitizeInputMiddleware);

// FORCE RATE LIMITER (tests depend on it)
if (http?.rateLimit?.enabled !== false) {
applyRateLimiters(app, appLogger, {
Expand Down
52 changes: 52 additions & 0 deletions src/middleware/sanitize-input.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Request, Response, NextFunction } from "express";
import sanitizeHtml from "sanitize-html";

export function sanitizeString(input: string): string {
return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} }).trim();
}

function isBuffer(obj: unknown): obj is Buffer {
return Buffer.isBuffer(obj);
}

function sanitizeValue(value: unknown): unknown {
if (typeof value === "string") {
return sanitizeString(value);
}

if (Array.isArray(value)) {
return value.map(sanitizeValue);
}

if (value !== null && typeof value === "object" && !isBuffer(value)) {
return sanitizeObject(value as Record<string, unknown>);
}

return value;
}

function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};

for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeValue(value);
}

return sanitized;
}

export function sanitizeInputMiddleware(
req: Request,
_res: Response,
next: NextFunction,
): void {
if (req.body && typeof req.body === "object" && !isBuffer(req.body)) {
req.body = sanitizeObject(req.body);
}

if (req.query && typeof req.query === "object") {
req.query = sanitizeObject(req.query as Record<string, unknown>);
}

next();
}
2 changes: 2 additions & 0 deletions src/observability/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import winston from "winston";
import { redactionFormat } from "./redaction-formatter";

export type LogMetadata = Record<string, unknown>;

Expand Down Expand Up @@ -43,6 +44,7 @@ function createBaseLogger(): winston.Logger {
service: "stellarsettle-api",
},
format: winston.format.combine(
redactionFormat(),
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
Expand Down
92 changes: 92 additions & 0 deletions src/observability/redaction-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import winston from "winston";

const STELLAR_SECRET_KEY_PATTERN = /S[A-Z0-9]{55}/g;

const STELLAR_SECRET_KEY_REDACTED =
"S*******************************************************";

const JWT_PATTERN =
/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g;

const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9_\-.~+\/]+=*/g;

const SENSITIVE_KEY_NAMES = new Set([
"password",
"secret",
"secretKey",
"secret_key",
"privateKey",
"private_key",
"platformSecretKey",
"PLATFORM_SECRET_KEY",
"jwt",
"token",
"accessToken",
"access_token",
"refreshToken",
"refresh_token",
"authorization",
"auth",
"credential",
"credentials",
"apiKey",
"api_key",
"seed",
]);

function redactStringValue(value: string): string {
let result = value;
result = result.replace(STELLAR_SECRET_KEY_PATTERN, STELLAR_SECRET_KEY_REDACTED);
result = result.replace(BEARER_PATTERN, "Bearer ***");
result = result.replace(JWT_PATTERN, "eyJ***.eyJ***.***");
return result;
}

function redactObjectValues(obj: Record<string, unknown>): Record<string, unknown> {
const redacted: Record<string, unknown> = {};

for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_KEY_NAMES.has(key)) {
redacted[key] = "[REDACTED]";
} else if (typeof value === "string") {
redacted[key] = redactStringValue(value);
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
redacted[key] = redactObjectValues(value as Record<string, unknown>);
} else if (Array.isArray(value)) {
redacted[key] = value.map((item) => {
if (typeof item === "string") return redactStringValue(item);
if (item !== null && typeof item === "object")
return redactObjectValues(item as Record<string, unknown>);
return item;
});
} else {
redacted[key] = value;
}
}

return redacted;
}

export function redactionFormat(): winston.Logform.Format {
return winston.format((info) => {
if (info.message && typeof info.message === "string") {
info.message = redactStringValue(info.message);
}

const { level, message, timestamp, stack, ...rest } = info as Record<string, unknown>;

const redactedMeta = redactObjectValues(rest);

return {
level,
message,
timestamp,
stack,
...redactedMeta,
} as winston.Logform.TransformableInfo;
})();
}

export function redactString(input: string): string {
return redactStringValue(input);
}
Loading
Loading