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
12 changes: 11 additions & 1 deletion src/bin/www.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getLoggers } from '../utils/loggingHelpers';
import { PORT } from '../utils/process';

const server = http.createServer(app);
const { localLogger } = getLoggers();
const { localLogger, errorLogger } = getLoggers();
const processTraceId = { traceId: 'process', uidTraceId: 'process' };

function isHttpError(error: Error): error is Error & { syscall: string; code: string } {
return Object.prototype.hasOwnProperty.call(error, 'syscall') && Object.prototype.hasOwnProperty.call(error, 'code');
Expand All @@ -15,6 +16,15 @@ process.on('SIGINT', () => {
process.exit();
});

// Diagnostic only: per-handler try/catch is the real defence. This listener exists
// so any rejection that still escapes gets logged to the monitoring pipeline before
// Node's default behaviour takes over. We deliberately do NOT add an uncaughtException
// handler — Node's docs are explicit that resuming after one leaves the process in
// an undefined state; the pod supervisor will restart us cleanly instead.
process.on('unhandledRejection', (reason) => {
errorLogger.error(`Unhandled rejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`, processTraceId);
});

/**
* Event listener for HTTP server "error" event.
*/
Expand Down
52 changes: 33 additions & 19 deletions src/routes/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,45 @@ export async function decrypt(input: string): Promise<string> {
}

const iv = Uint8Array.from(Buffer.from(parts[0], 'base64'));
// aes-128-cbc requires a 16-byte IV; reject a bad length up front. This throw is
// synchronous within the async function, so it surfaces as a promise rejection
// the caller can catch (unlike a throw inside the scrypt callback below).
if (iv.length !== 16) {
throw new Error('Invalid Enrypted payload');
}
return new Promise((resolve, reject) => {
crypto.scrypt(SYSTEM_SECRET, SYSTEM_SALT, 16, (err, key) => {
if (err) {
reject(err);
return;
}

const decipher = crypto.createDecipheriv(algo, key, iv);

let decrypted = '';
decipher.on('readable', () => {
let chunk = decipher.read();
while (chunk) {
decrypted += chunk.toString('utf8');
chunk = decipher.read();
}
});

decipher.on('end', () => {
resolve(decrypted);
});

decipher.write(parts[1], 'base64');
decipher.end();


// createDecipheriv and the stream write/end run inside this native callback;
// a throw here escapes the Promise and becomes an uncaught exception. Wrap them
// and route every failure (including the decipher 'error' event for bad padding)
// through reject so the caller's try/catch handles it.
try {
const decipher = crypto.createDecipheriv(algo, key, iv);
decipher.on('error', reject);

let decrypted = '';
decipher.on('readable', () => {
let chunk = decipher.read();
while (chunk) {
decrypted += chunk.toString('utf8');
chunk = decipher.read();
}
});

decipher.on('end', () => {
resolve(decrypted);
});

decipher.write(parts[1], 'base64');
decipher.end();
} catch (e) {
reject(e);
}
});
});

Expand Down
15 changes: 13 additions & 2 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,20 @@ const OptoutSubmitRequest = z.object({
encrypted: z.string(),
});

const handleOptoutSubmit: RequestHandler<{}, { message: string } | { error: string }, z.infer<typeof OptoutSubmitRequest>> = async (req, res, _next) => {
const { encrypted } = OptoutSubmitRequest.parse(req.body);
const handleOptoutSubmit: RequestHandler<{}, { message: string } | { error: string }, z.infer<typeof OptoutSubmitRequest>> = async (req, res, next) => {
const traceId = getTraceId(req);
// Validate inside try/catch: a malformed body (e.g. `encrypted` not a string)
// makes .parse() throw a ZodError. In an async handler an uncaught throw becomes
// an unhandled rejection that crashes the process rather than reaching the Express
// error handler, so reject it explicitly with a 400.
let encrypted: string;
try {
({ encrypted } = OptoutSubmitRequest.parse(req.body));
} catch (e) {
errorLogger.error('error while parsing optout submit request', traceId);
next(createError(400));
return;
}
const instanceId = SERVICE_INSTANCE_ID_PREFIX;
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() || req.ip || 'unknown';
try {
Expand Down
Loading