Skip to content
Open
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
115 changes: 115 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,121 @@ We publicly recognise researchers who help keep Stellar Tipz secure. With your p

---

## Logging Security and PII Protection

The Stellar Tipz backend implements comprehensive personally identifiable information (PII) redaction in structured logs to prevent sensitive data exposure while maintaining operational observability.

### Logging Security Policy

All structured logs generated by the application automatically redact sensitive information according to the following policies:

#### Authentication Data Redaction

The following authentication-related data is **automatically redacted** from all log entries:

- **Authorization headers**: `Authorization`, `Bearer` tokens
- **API keys**: `x-api-key`, `x-auth-token`, `x-access-token` headers
- **Cookies**: All cookie values in `Cookie` and `Set-Cookie` headers
- **Tokens in request/response bodies**: `token`, `accessToken`, `refreshToken`, `apiKey`
- **Private keys and secrets**: `privateKey`, `secret`, `password` fields

**Redaction format**: Sensitive values are replaced with `[REDACTED]`

#### Stellar Address Truncation

Stellar addresses in logs are truncated to prevent full address exposure while maintaining correlation capability:

- **Format**: `GXXX...XXXX` (first 4 and last 4 characters)
- **Applied to**: `publicKey`, `recipientAddress`, `senderAddress`, and similar fields
- **Rationale**: Enables debugging correlation without exposing complete addresses

#### Email Address Protection

Email addresses are truncated to show only domain information:

- **Format**: `***@domain.com`
- **Rationale**: Preserves domain-level debugging info without exposing user identities

#### Message Content Limitations

User-generated message content is limited in logs:

- **Truncation**: Messages > 50 characters are truncated with length indication
- **Format**: `"First 50 chars... (total: 150 chars)"`
- **Rationale**: Prevents logging of potentially sensitive communications

#### Request Body Filtering

Request bodies are **not logged wholesale**. Only an explicit safe subset is included:

**Safe fields logged**:
- `username` (for correlation)
- `email` (truncated as per policy above)
- `amount` (transaction amounts)
- `message` (truncated as per policy above)
- Stellar addresses (truncated as per policy above)
- `_bodyKeys` (field names only, for debugging structure)

**Never logged**:
- Complete request bodies
- Any field containing tokens, keys, or secrets
- Sensitive form data

#### Response Body Protection

Response bodies are **not logged by default** to prevent accidental exposure of sensitive data returned by the API.

### Implementation Details

The PII redaction is implemented using:

1. **Pino redaction configuration** with explicit path-based redaction
2. **Custom serializers** that filter and truncate sensitive data
3. **Utility functions** in `src/common/utils/logRedaction.ts` for consistent data processing
4. **Automated testing** that verifies tokens never appear in log output

### Testing and Validation

The logging security implementation includes comprehensive automated tests (`tests/logging-security.test.ts`) that:

- Capture actual pino log stream output
- Assert that various token formats never appear in logs
- Verify proper redaction of headers, cookies, and request bodies
- Confirm Stellar address and email truncation policies
- Test multiple authentication token patterns (JWT, API keys, etc.)

### Monitoring and Compliance

**For operators**:
- Log redaction is automatic and requires no manual intervention
- Monitor for `[REDACTED]` markers in logs to verify policy enforcement
- Any appearance of actual token values in logs indicates a policy violation

**For developers**:
- All new logging code must use the configured structured logger
- Manual `console.log` statements bypass redaction and are prohibited in production
- Custom logging fields should use the utilities in `logRedaction.ts`

### Emergency Procedures

If sensitive data is discovered in logs:

1. **Immediate**: Rotate any exposed credentials (API keys, tokens)
2. **Short-term**: Purge affected log entries from storage systems
3. **Investigation**: Review how the data bypassed redaction policies
4. **Remediation**: Update redaction rules and add test coverage for the failure case

### Policy Updates

This logging security policy is enforced through:
- Automated tests that must pass before deployment
- Code review requirements for logging-related changes
- Regular security audits of log output in staging environments

Any changes to logging behavior must maintain or strengthen these protections.

---

## Resources

- [GitHub Security Advisories for this repo](../../security/advisories)
Expand Down
97 changes: 80 additions & 17 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { globalRateLimiter } from './common/middleware/rateLimiter.js';
import { metricsController, metricsMiddleware } from './common/observability/metrics.js';
import { getSentryRequestHandler, getSentryErrorHandler } from './common/observability/sentry.js';
import { logger } from './common/utils/logger.js';
import { truncateStellarAddress, truncateEmail, truncateMessage } from './common/utils/logRedaction.js';
import { openApiDocument } from './docs/openapi.js';
import { requestId } from './common/middleware/requestId.js';
import { requestTimeoutAndSignal } from './common/middleware/requestTimeout.js';
import { healthRouter } from './modules/health/health.routes.js';
Expand Down Expand Up @@ -169,23 +171,84 @@ export function createApp(): Express {
// Server-level timeout + client-disconnect AbortSignal (issue #090)
app.use(requestTimeoutAndSignal);
app.use(metricsMiddleware);
/**
* Right-sized JSON limits (issue #077).
* Default is tight (100kb, configurable via JSON_BODY_LIMIT) β€” far below the old 1mb blanket.
* Routes that legitimately need more (tip/profile writes) get a larger explicit limit via
* adaptiveJsonLimit below. Oversized bodies are mapped to 413 PAYLOAD_TOO_LARGE in errorHandler.
*/
const defaultJsonLimit = (config as unknown as { payload?: { jsonLimit: string } })?.payload?.jsonLimit ?? '100kb'; // e.g. '100kb'
const largeJsonLimit = '500kb';
// Paths that need larger JSON bodies (documented per-route override)
const largeJsonPrefixes = [`${(config as unknown as { server?: { apiBasePath: string } })?.server?.apiBasePath ?? '/api/v1'}/tips`, `${(config as unknown as { server?: { apiBasePath: string } })?.server?.apiBasePath ?? '/api/v1'}/profiles`, `${(config as unknown as { server?: { apiBasePath: string } })?.server?.apiBasePath ?? '/api/v1'}/auth`];
const adaptiveJsonLimit = (req: express.Request, _res: express.Response, next: express.NextFunction) => {
const needsLarge = largeJsonPrefixes.some((prefix) => req.path.startsWith(prefix) || req.originalUrl.startsWith(prefix));
const limit = needsLarge ? largeJsonLimit : defaultJsonLimit;
return express.json({ limit, type: ['application/json', 'application/csp-report'] })(req, _res, next);
};
app.use(adaptiveJsonLimit);
app.use(pinoHttp({ logger }));
app.use(express.json({ limit: '1mb' }));
app.use(
pinoHttp({
logger,
redact: {
paths: [
// Auth headers and tokens
'req.headers.authorization',
'req.headers.cookie',
'req.headers["x-api-key"]',
'req.headers["x-auth-token"]',
'req.headers["x-access-token"]',
'req.headers.bearer',
// Request body tokens and keys
'req.body.token',
'req.body.accessToken',
'req.body.refreshToken',
'req.body.apiKey',
'req.body.privateKey',
'req.body.secret',
'req.body.password',
// Response body sensitive data
'res.body.token',
'res.body.accessToken',
'res.body.refreshToken',
'res.body.privateKey',
'res.body.secret',
],
censor: '[REDACTED]',
},
serializers: {
req: (req) => {
// Only log safe subset of request body
const safeBody = req.body ? {
// Safe fields that can be logged
...(req.body.username && { username: req.body.username }),
...(req.body.email && { email: truncateEmail(req.body.email) }),
...(req.body.amount && { amount: req.body.amount }),
...(req.body.message && { message: truncateMessage(req.body.message) }),
...(req.body.publicKey && { publicKey: truncateStellarAddress(req.body.publicKey) }),
...(req.body.recipientAddress && { recipientAddress: truncateStellarAddress(req.body.recipientAddress) }),
...(req.body.senderAddress && { senderAddress: truncateStellarAddress(req.body.senderAddress) }),
// Add type information for debugging
_bodyKeys: req.body ? Object.keys(req.body) : [],
} : undefined;

return {
id: req.id,
method: req.method,
url: req.url,
query: req.query,
params: req.params,
headers: {
...req.headers,
// Explicitly redact sensitive headers
authorization: req.headers.authorization ? '[REDACTED]' : undefined,
cookie: req.headers.cookie ? '[REDACTED]' : undefined,
'x-api-key': req.headers['x-api-key'] ? '[REDACTED]' : undefined,
'x-auth-token': req.headers['x-auth-token'] ? '[REDACTED]' : undefined,
'x-access-token': req.headers['x-access-token'] ? '[REDACTED]' : undefined,
},
body: safeBody,
remoteAddress: req.connection?.remoteAddress,
remotePort: req.connection?.remotePort,
};
},
res: (res) => ({
statusCode: res.statusCode,
headers: {
...res.getHeaders(),
// Ensure no sensitive headers are logged in response
'set-cookie': res.getHeaders()['set-cookie'] ? '[REDACTED]' : undefined,
},
// Don't log response body by default for security
}),
},
}),
);

app.get('/metrics', metricsController);

Expand Down
114 changes: 114 additions & 0 deletions backend/src/common/utils/logRedaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Utilities for redacting personally identifiable information (PII) from logs.
*
* This module implements the logging security policy documented in SECURITY.md.
* All functions here should be used consistently across the application to ensure
* sensitive data is not exposed in structured logs.
*/

/**
* Truncates Stellar addresses to show only first/last 4 characters for logging.
*
* Policy: Stellar addresses are truncated to prevent full address exposure while
* maintaining enough information for debugging correlation.
*
* @param address - The Stellar address to truncate
* @returns Truncated address in format "GXXX...XXXX" or original if not a valid address
*/
export function truncateStellarAddress(address: string | undefined | null): string | undefined {
if (!address || typeof address !== 'string') {
return undefined;
}

// Stellar addresses are typically 56 characters starting with G
if (address.length === 56 && address.startsWith('G')) {
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}

// For other formats, still truncate for safety
if (address.length > 8) {
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}

return address;
}

/**
* Truncates email addresses to show only domain for logging.
*
* Policy: Email addresses are truncated to show only the domain portion
* to prevent PII exposure while maintaining useful debugging information.
*
* @param email - The email address to truncate
* @returns Domain portion only (e.g., "***@example.com")
*/
export function truncateEmail(email: string | undefined | null): string | undefined {
if (!email || typeof email !== 'string') {
return undefined;
}

const atIndex = email.indexOf('@');
if (atIndex === -1) {
return '***@unknown';
}

return `***${email.slice(atIndex)}`;
}

/**
* Truncates message content for logging while preserving length information.
*
* Policy: Message content is truncated to prevent logging of potentially sensitive
* user communications while preserving metadata useful for debugging.
*
* @param message - The message to truncate
* @returns Truncated message with length info
*/
export function truncateMessage(message: string | undefined | null): string | undefined {
if (!message || typeof message !== 'string') {
return undefined;
}

// Log only first 50 characters and indicate full length
if (message.length > 50) {
return `${message.slice(0, 50)}... (total: ${message.length} chars)`;
}

return message;
}

/**
* Sanitizes an object by removing or redacting sensitive fields.
*
* This is a general-purpose function for cleaning objects before logging.
*
* @param obj - Object to sanitize
* @returns Sanitized object with sensitive fields redacted
*/
export function sanitizeForLogging(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj;
}

const sensitiveKeys = [
'password', 'token', 'accessToken', 'refreshToken', 'apiKey',
'privateKey', 'secret', 'authorization', 'cookie', 'signature'
];

const sanitized = { ...obj };

for (const key of sensitiveKeys) {
if (key in sanitized) {
sanitized[key] = '[REDACTED]';
}
}

// Handle nested objects
for (const [key, value] of Object.entries(sanitized)) {
if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeForLogging(value);
}
}

return sanitized;
}
Loading