All files / src/middleware requestLogger.js

100% Statements 42/42
93.54% Branches 29/31
100% Functions 5/5
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119                  1x                           1x             1x                 53x 52x 48x 47x 31x 31x 53x 24x   31x                   15x   15x 1x     14x     14x 14x     14x 14x   14x     14x 11x 11x 11x   11x 11x 10x   11x 11x 11x   11x                                             14x       1x  
/**
 * Request/response logging middleware with trace ID propagation.
 *
 * Usage:
 *   const logger = require('../utils/loggerInstance');
 *   const { createRequestLogger } = require('./requestLogger');
 *   app.use(createRequestLogger(logger));
 */
 
const crypto = require('node:crypto');
 
// Substring-match denylist. The previous shallow + exact-match list missed
// nested credentials (`{ user: { password } }`, `{ data: { idToken } }`)
// and credential field names not on the explicit list (`passcode`, `otp`,
// `totp`, `verifier`, `clientSecret`, `apiKey`, `recoveryCode`,
// `appleSignedPayload`, `firebaseIdToken`, etc.). Logs are searchable by
// uid; an admin reviewing one user's logs could grab another user's
// still-valid credentials. Phase 2H finding #6.
//
// Pattern matches whole-key substrings — `pin`, `pinHash`, `oldPin`,
// `userPasscode`, `idToken`, `accessToken`, `apiKey`, `clientSecret`,
// `recoveryCode`, `appleSignedPayload`, etc. all redact correctly.
const SENSITIVE_KEY_PATTERN =
  /token|secret|password|passcode|pin|otp|totp|code|credential|verifier|signature|recovery|apple.*payload|hash|apikey/i;
 
// Cap recursion depth defensively. Express body-parser has its own
// `parameterLimit` and `depth` defaults that bound the inbound payload,
// but a custom express.raw() route could feed in a deeper structure.
// 8 is plenty for any legitimate API DTO and is far below Node's stack
// limit on a stock Mac/Linux build.
const SANITIZE_DEPTH_LIMIT = 8;
 
/**
 * Strip sensitive fields from request body. Recurses into nested objects
 * and arrays so credentials nested under DTO wrappers (`{ user: { password } }`,
 * `{ data: { idToken } }`) are also removed. Sensitive keys are deleted
 * (not present in output) to match the existing log-consumer contract.
 */
function sanitizeBody(body, depth = 0) {
  if (depth > SANITIZE_DEPTH_LIMIT) return null;
  if (body === null || body === undefined) return body;
  if (Array.isArray(body)) return body.map((v) => sanitizeBody(v, depth + 1));
  if (typeof body !== 'object') return body;
  const clean = {};
  for (const [key, value] of Object.entries(body)) {
    if (SENSITIVE_KEY_PATTERN.test(key)) continue;
    clean[key] = sanitizeBody(value, depth + 1);
  }
  return clean;
}
 
/**
 * Create Express middleware that logs every request/response.
 *
 * @param {object} logger - Logger instance with a `log(entry)` method.
 * @returns {Function} Express middleware
 */
function createRequestLogger(logger) {
  return function requestLoggerMiddleware(req, res, next) {
    // Skip logging for health checks — they add ~1,440 writes/day for no value
    if (req.path === '/api/health') {
      return next();
    }
 
    const startTime = Date.now();
 
    // Generate request trace ID
    const requestTraceId = crypto.randomBytes(16).toString('hex');
    const sessionTraceId = req.headers['x-session-trace-id'] || null;
 
    // Attach to request for downstream use
    req.requestTraceId = requestTraceId;
    req.sessionTraceId = sessionTraceId;
 
    res.setHeader('x-request-trace-id', requestTraceId);
 
    // Log after response completes
    res.on('finish', () => {
      try {
        const durationMs = Date.now() - startTime;
        const statusCode = res.statusCode;
 
        let level = 'INFO';
        if (statusCode >= 500) level = 'ERROR';
        else if (statusCode >= 400) level = 'WARN';
 
        const method = req.method;
        const path = req.originalUrl || req.url;
        const message = `${method} ${path} ${statusCode} ${durationMs}ms`;
 
        logger.log({
          level,
          source: 'http',
          message,
          requestTraceId,
          sessionTraceId,
          userId: req.auth?.uid || null,
          context: {
            method,
            path,
            statusCode,
            durationMs,
            requestBody:
              req.body !== null && req.body !== undefined ? sanitizeBody(req.body) : null,
            userAgent: req.headers['user-agent'] || null,
          },
        });
      } catch {
        // Intentionally swallowed — request logging must never throw to avoid disrupting HTTP responses
      }
    });
 
    // Never block the request
    next();
  };
}
 
module.exports = { createRequestLogger, sanitizeBody };