Skip to content

Commit 8367235

Browse files
Security/error response pii guard (#924)
* feat: vault lifecycle audit timeline endpoint (#723) * security: sanitize PII and internals from error responses (#879) --------- Co-authored-by: whitezaddy <austinihueze@gmail.com>
1 parent 20ef0e7 commit 8367235

3 files changed

Lines changed: 185 additions & 29 deletions

File tree

docs/error-contract.md

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,48 @@ All API errors return a consistent JSON envelope with the following structure:
1919

2020
### Field Descriptions
2121

22-
| Field | Type | Required | Description |
23-
|-------|------|----------|-------------|
24-
| `code` | string | Yes | Machine-readable error code for programmatic handling |
25-
| `message` | string | Yes | Human-readable error description |
26-
| `details` | object | No | Additional context (e.g., validation field errors). Only present for validation errors. |
27-
| `requestId` | string | No | Echoed from `x-request-id` header for request correlation |
22+
| Field | Type | Required | Description |
23+
| ----------- | ------ | -------- | --------------------------------------------------------------------------------------- |
24+
| `code` | string | Yes | Machine-readable error code for programmatic handling |
25+
| `message` | string | Yes | Human-readable error description |
26+
| `details` | object | No | Additional context (e.g., validation field errors). Only present for validation errors. |
27+
| `requestId` | string | No | Echoed from `x-request-id` header for request correlation |
2828

2929
## Error Codes
3030

3131
The following stable error codes are used across the API:
3232

3333
### 400 Bad Request
34+
3435
- `BAD_REQUEST` - Generic bad request (malformed syntax, invalid parameters)
3536
- `VALIDATION_ERROR` - Request validation failed (includes field-level details)
3637

3738
### 401 Unauthorized
39+
3840
- `UNAUTHORIZED` - Authentication required or failed
3941

4042
### 403 Forbidden
43+
4144
- `FORBIDDEN` - Authenticated but not authorized for this resource
4245

4346
### 404 Not Found
47+
4448
- `NOT_FOUND` - Resource does not exist
4549

4650
### 409 Conflict
51+
4752
- `CONFLICT` - Resource conflict (e.g., duplicate entry)
4853

4954
### 422 Unprocessable Entity
55+
5056
- `UNPROCESSABLE` - Business logic violation (e.g., cannot delete last admin)
5157

5258
### 429 Too Many Requests
59+
5360
- `RATE_LIMITED` - Rate limit exceeded
5461

5562
### 500 Internal Server Error
63+
5664
- `INTERNAL_ERROR` - Unexpected server error (safe message, no stack traces)
5765

5866
## Example Error Responses
@@ -171,16 +179,26 @@ Examples:
171179

172180
## Security Considerations
173181

182+
### PII and Internal Detail Sanitization
183+
184+
In production environments (`NODE_ENV=production`), all error responses are automatically sanitized to prevent the leakage of sensitive information. This includes:
185+
186+
- **Internal Error Details**: For `500 Internal Server Error` responses, the original error message and stack trace are logged internally but are **never** included in the JSON response body. The client receives a generic "Internal server error" message.
187+
- **PII Redaction**: For validation errors (HTTP 400) that might echo back parts of the request payload in the `details` field, any values matching the PII taxonomy (e.g., email addresses, wallet addresses, sensitive keys) are automatically redacted.
188+
- **Correlation ID**: The `requestId` is always preserved, allowing for secure error correlation between the client and server-side logs.
189+
190+
In non-production environments, error messages may contain more detail to aid in debugging.
191+
174192
1. **No Stack Traces**: Stack traces are never exposed in production error responses
175193
2. **No Secrets**: Error messages never include tokens, API keys, database credentials, or raw SQL
176194
3. **Safe Messages**: Internal errors return generic "Internal server error" messages to prevent information leakage
177-
4. **Structured Logging**: Errors are logged server-side with full context for debugging
178195

179196
## Client Integration Guidelines
180197

181198
### Handling Errors
182199

183200
Clients should:
201+
184202
1. Check the HTTP status code first
185203
2. Use the `code` field for programmatic error handling (not the message)
186204
3. Display the `message` field to users
@@ -196,40 +214,40 @@ interface ApiError {
196214
message: string;
197215
details?: Record<string, unknown>;
198216
requestId?: string;
199-
}
217+
};
200218
}
201219

202220
async function apiCall(): Promise<void> {
203-
const response = await fetch('/api/resource', {
221+
const response = await fetch("/api/resource", {
204222
headers: {
205-
'x-request-id': generateRequestId() // For traceability
206-
}
223+
"x-request-id": generateRequestId(), // For traceability
224+
},
207225
});
208-
226+
209227
if (!response.ok) {
210228
const error: ApiError = await response.json();
211-
229+
212230
// Handle by code, not message
213231
switch (error.error.code) {
214-
case 'VALIDATION_ERROR':
232+
case "VALIDATION_ERROR":
215233
// Show field-level errors
216234
showValidationErrors(error.error.details);
217235
break;
218-
case 'UNAUTHORIZED':
236+
case "UNAUTHORIZED":
219237
// Redirect to login
220238
redirectToLogin();
221239
break;
222-
case 'NOT_FOUND':
240+
case "NOT_FOUND":
223241
// Show 404 page
224242
showNotFound();
225243
break;
226244
default:
227245
// Generic error display
228246
showError(error.error.message);
229247
}
230-
248+
231249
// Log requestId for support
232-
console.error('Request ID:', error.error.requestId);
250+
console.error("Request ID:", error.error.requestId);
233251
}
234252
}
235253
```
@@ -239,28 +257,28 @@ async function apiCall(): Promise<void> {
239257
Route handlers use `AppError` factory methods for consistent errors:
240258

241259
```typescript
242-
import { AppError } from '../middleware/errorHandler.js';
260+
import { AppError } from "../middleware/errorHandler.js";
243261

244262
// Validation error with details
245-
return next(AppError.validation('Invalid input', { field: 'email' }));
263+
return next(AppError.validation("Invalid input", { field: "email" }));
246264

247265
// Simple bad request
248-
return next(AppError.badRequest('Missing required field'));
266+
return next(AppError.badRequest("Missing required field"));
249267

250268
// Authentication required
251-
return next(AppError.unauthorized('Invalid token'));
269+
return next(AppError.unauthorized("Invalid token"));
252270

253271
// Permission denied
254-
return next(AppError.forbidden('Admin access required'));
272+
return next(AppError.forbidden("Admin access required"));
255273

256274
// Resource not found
257-
return next(AppError.notFound('User not found'));
275+
return next(AppError.notFound("User not found"));
258276

259277
// Conflict
260-
return next(AppError.conflict('Email already registered'));
278+
return next(AppError.conflict("Email already registered"));
261279

262280
// Business logic violation
263-
return next(AppError.unprocessable('Cannot delete last admin'));
281+
return next(AppError.unprocessable("Cannot delete last admin"));
264282

265283
// Internal error (rarely used directly)
266284
return next(AppError.internal());
@@ -269,6 +287,7 @@ return next(AppError.internal());
269287
## Testing
270288

271289
Error responses are thoroughly tested in `src/tests/errorHandler.test.ts` with >95% coverage for:
290+
272291
- All AppError factory methods
273292
- Error envelope structure validation
274293
- requestId echo behavior
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'
2+
import request from 'supertest'
3+
import express, { type Request, type Response, type NextFunction } from 'express'
4+
import { AppError, errorHandler } from '../middleware/errorHandler.js'
5+
6+
function buildApp(thrower: (req: Request, res: Response, next: NextFunction) => void) {
7+
const app = express()
8+
app.use(express.json())
9+
app.get('/test', thrower)
10+
app.use(errorHandler)
11+
return app
12+
}
13+
14+
describe('errorHandler PII Guard', () => {
15+
let originalNodeEnv: string | undefined
16+
17+
beforeEach(() => {
18+
originalNodeEnv = process.env.NODE_ENV
19+
})
20+
21+
afterEach(() => {
22+
process.env.NODE_ENV = originalNodeEnv
23+
})
24+
25+
describe('in production mode (NODE_ENV=production)', () => {
26+
beforeEach(() => {
27+
process.env.NODE_ENV = 'production'
28+
})
29+
30+
it('redacts PII from AppError details', async () => {
31+
const app = buildApp((_req, _res, next) => {
32+
next(AppError.validation('Invalid data', {
33+
email: 'leaked-user@example.com',
34+
creator: 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ',
35+
safeField: 'is-ok'
36+
}))
37+
})
38+
39+
const res = await request(app).get('/test')
40+
expect(res.status).toBe(400)
41+
const details = res.body.error.details
42+
expect(details.email).toMatch(/^[a-f0-9]{8}$/);
43+
expect(details.creator).toMatch(/^[a-f0-9]{8}$/);
44+
expect(details.safeField).toBe('is-ok')
45+
})
46+
47+
it('strips internal details from generic Error messages', async () => {
48+
const app = buildApp((_req, _res, next) => {
49+
next(new Error('FATAL: connection to "db-prod-internal" failed for user "admin"'))
50+
})
51+
52+
const res = await request(app).get('/test')
53+
expect(res.status).toBe(500)
54+
expect(res.body.error.message).toBe('Internal server error')
55+
expect(res.body.error.message).not.toContain('db-prod-internal')
56+
})
57+
58+
it('strips stack traces from generic Error messages', async () => {
59+
const app = buildApp((_req, _res, next) => {
60+
const err = new Error('Something broke')
61+
err.stack = 'Error: Something broke\n at /app/src/services/critical.js:123:45'
62+
next(err)
63+
})
64+
65+
const res = await request(app).get('/test')
66+
expect(res.status).toBe(500)
67+
expect(res.body.error.message).toBe('Internal server error')
68+
expect(res.body.error.message).not.toContain('critical.js')
69+
})
70+
71+
it('preserves correlation ID while sanitizing', async () => {
72+
const app = buildApp((_req, _res, next) => {
73+
next(new Error('Internal failure'))
74+
})
75+
76+
const res = await request(app).get('/test').set('x-request-id', 'trace-me-123')
77+
expect(res.status).toBe(500)
78+
expect(res.body.error.message).toBe('Internal server error')
79+
expect(res.body.error.requestId).toBe('trace-me-123')
80+
})
81+
})
82+
83+
describe('in development mode (NODE_ENV!=production)', () => {
84+
beforeEach(() => {
85+
process.env.NODE_ENV = 'development'
86+
})
87+
88+
it('does NOT redact PII from AppError details', async () => {
89+
const app = buildApp((_req, _res, next) => {
90+
next(AppError.validation('Invalid data', {
91+
email: 'dev-user@example.com',
92+
creator: 'GDEVADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
93+
}))
94+
})
95+
96+
const res = await request(app).get('/test')
97+
expect(res.status).toBe(400)
98+
const details = res.body.error.details
99+
expect(details.email).toBe('dev-user@example.com')
100+
expect(details.creator).toBe('GDEVADDRESSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX')
101+
})
102+
103+
it('preserves the original message for generic Errors', async () => {
104+
const app = buildApp((_req, _res, next) => {
105+
next(new Error('SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "users_email_key"'))
106+
})
107+
108+
const res = await request(app).get('/test')
109+
expect(res.status).toBe(500)
110+
expect(res.body.error.message).toContain('duplicate key value')
111+
})
112+
113+
it('preserves stack traces in the original message for generic Errors', async () => {
114+
const app = buildApp((_req, _res, next) => {
115+
const err = new Error('Dev mode error')
116+
err.stack = 'Error: Dev mode error\n at /app/src/dev.js:10:20'
117+
next(err)
118+
})
119+
120+
const res = await request(app).get('/test')
121+
expect(res.status).toBe(500)
122+
// The default behavior is to use the message, not the stack, but we ensure it's not the generic prod message.
123+
expect(res.body.error.message).toBe('Dev mode error')
124+
})
125+
})
126+
})

src/middleware/errorHandler.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NextFunction, Request, Response } from 'express'
2+
import { sanitizePrivacyPayload } from '../utils/privacy.js'
23

34
// ─── Error Codes ─────────────────────────────────────────────────────────────
45
// Machine-readable codes clients can branch on without parsing message strings.
@@ -166,6 +167,13 @@ export const errorHandler = (
166167
// PII is not logged: we only record method, path, and a sanitised message.
167168
const requestId = (req.headers['x-request-id'] as string | undefined) ?? undefined
168169

170+
// Determine if we are in a production environment
171+
const isProduction = process.env.NODE_ENV === 'production'
172+
173+
// Sanitize any echoed PII from error details in production
174+
const sanitizeDetails = (details: unknown) =>
175+
isProduction ? sanitizePrivacyPayload(details) : details
176+
169177
// Sanitize and convert express body-parser size limit errors
170178
if (err && typeof err === 'object' && 'status' in err && err.status === 413 && 'type' in err && (err as any).type === 'entity.too.large') {
171179
err = new AppError(413, ErrorCode.PAYLOAD_TOO_LARGE, 'Payload too large')
@@ -191,7 +199,7 @@ export const errorHandler = (
191199
error: {
192200
code: err.code,
193201
message: err.message,
194-
...(err.details !== undefined && { details: err.details }),
202+
...(err.details !== undefined && { details: sanitizeDetails(err.details) }),
195203
...(requestId && { requestId }),
196204
},
197205
}
@@ -201,7 +209,10 @@ export const errorHandler = (
201209
}
202210

203211
// Unknown / unexpected errors – never leak internals to the client.
204-
const message = err instanceof Error ? err.message : 'Internal server error'
212+
// In production, always use a generic message. In dev, show the real error.
213+
const message = isProduction
214+
? 'Internal server error'
215+
: err instanceof Error ? err.message : 'Internal server error'
205216

206217
console.error(
207218
JSON.stringify({
@@ -221,7 +232,7 @@ export const errorHandler = (
221232
const body: ErrorResponse = {
222233
error: {
223234
code: ErrorCode.INTERNAL_ERROR,
224-
message: 'Internal server error',
235+
message,
225236
...(requestId && { requestId }),
226237
},
227238
}

0 commit comments

Comments
 (0)