All files / src/middleware system-auth.js

94.73% Statements 18/19
91.66% Branches 11/12
100% Functions 1/1
94.73% Lines 18/19

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                                                                      3x 3x   3x     55x   55x       2x     2x     53x         53x       9x   44x 44x                   44x 44x 44x 10x     34x     3x  
/**
 * System-endpoint authentication.
 *
 * Protects `/api/system/*` endpoints called by external schedulers
 * (GitHub Actions scheduled workflows replacing the in-process node-cron
 * jobs). The endpoints are publicly reachable URLs, so they need a
 * shared-secret guard separate from the Firebase Auth flow used by
 * end-users.
 *
 * The secret is held in `SYSTEM_SHARED_SECRET` env var. The caller
 * passes it as a bearer token:
 *
 *     Authorization: Bearer <SYSTEM_SHARED_SECRET>
 *
 * Per the same-secret-many-callers model, each scheduled workflow (and
 * any other ops-only tooling) configures the same value via repo
 * secrets / Better Stack monitor headers / etc. Rotation is coordinated
 * by updating the Express API env first, then the callers.
 *
 * Verification uses an HMAC-of-HMAC comparison rather than a direct
 * `timingSafeEqual` of the bytes. Both HMACs are always 32 bytes
 * regardless of the provided token's length, so the work done by the
 * comparison is identical in EVERY rejection path — same-length wrong
 * token, wrong-length token, and matching token all execute the same
 * sequence of allocations + a 32-byte timing-safe compare. This
 * eliminates the byte-length side channel that the naive
 * `if (expectedBuf.length !== providedBuf.length) return 401` pattern
 * leaks (because `Buffer.from(provided, 'utf8')` is O(provided.length),
 * an attacker can binary-search the secret's byte length by measuring
 * the response delta between the fast-path length-mismatch and the
 * slower constant-time compare).
 *
 * Reference: https://www.synopsys.com/blogs/software-security/timing-attacks-explained/
 */
 
const crypto = require('node:crypto');
const log = require('../utils/log');
 
const BEARER_PREFIX = 'Bearer ';
 
function requireSystemAuth(req, res, next) {
  const expected = process.env.SYSTEM_SHARED_SECRET;
 
  if (!expected) {
    // Configuration error: deny by default so a misconfigured deploy
    // can't accidentally expose the sweep endpoints. Logged once per
    // request so ops can spot the gap without flooding.
    log.error('system-auth', 'SYSTEM_SHARED_SECRET not configured — denying request', {
      path: req.path,
    });
    return res.status(503).json({ error: 'System authentication not configured' });
  }
 
  const header = req.get('authorization') || '';
  // Prefix-check + slice avoids regex backtracking on pathological
  // inputs (e.g. `Authorization: Bearer ` + 10kb of whitespace would
  // force `/^Bearer\s+(.+)$/i` to backtrack). The slice + trimStart
  // accepts RFC-6750-compliant whitespace after `Bearer`.
  if (
    header.length <= BEARER_PREFIX.length ||
    header.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX.toLowerCase()
  ) {
    return res.status(401).json({ error: 'Missing bearer token' });
  }
  const provided = header.slice(BEARER_PREFIX.length).trimStart();
  Iif (!provided) {
    return res.status(401).json({ error: 'Missing bearer token' });
  }
 
  // HMAC both the expected and the provided string under a shared key
  // (the expected secret itself) so both digests are always 32 bytes
  // regardless of input length. timingSafeEqual then compares two
  // fixed-length buffers in constant time. No length-mismatch branch
  // is needed at all — wrong-length, wrong-content, and right tokens
  // all take the same code path.
  const referenceDigest = crypto.createHmac('sha256', expected).update(expected).digest();
  const providedDigest = crypto.createHmac('sha256', expected).update(provided).digest();
  if (!crypto.timingSafeEqual(referenceDigest, providedDigest)) {
    return res.status(401).json({ error: 'Invalid bearer token' });
  }
 
  next();
}
 
module.exports = { requireSystemAuth };