Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Internal authentication middleware (Session 10).
|
||||
*
|
||||
* Protects internal-only endpoints — the grading pipeline, the
|
||||
* resolution poll-back, the corrections sweep — that are called by
|
||||
* pollers, n8n workflows, and cron jobs but NEVER by browser users.
|
||||
* Uses a shared secret in `VYNDR_INTERNAL_KEY` checked against the
|
||||
* request header.
|
||||
*
|
||||
* Deliberately separate from `requireAuth` (Supabase JWT). Internal
|
||||
* callers don't have user sessions.
|
||||
*
|
||||
* Header compatibility:
|
||||
* `x-internal-key` — Session 10 short form (n8n + new callers)
|
||||
* `X-VYNDR-Internal-Key` — legacy form, kept for backwards
|
||||
* compatibility with the poller and
|
||||
* the existing test suite. The
|
||||
* middleware accepts either; callers
|
||||
* should prefer the short form.
|
||||
*
|
||||
* Options:
|
||||
* loopbackOnly (default false) — additionally enforce that the
|
||||
* request originated from 127.0.0.1 / ::1. Use for endpoints that
|
||||
* should ONLY be reachable from co-located processes (the poller
|
||||
* pulling box scores). Endpoints called from n8n or other
|
||||
* containers MUST omit this option.
|
||||
*
|
||||
* Responses:
|
||||
* 503 — VYNDR_INTERNAL_KEY env var not set (misconfigured)
|
||||
* 401 — header missing OR key mismatch
|
||||
* 403 — loopbackOnly=true and the request came from off-host
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
// Timing-safe string compare. crypto.timingSafeEqual throws on
|
||||
// length mismatch, so we pad to a fixed length first to keep the
|
||||
// comparison constant-time regardless of input length.
|
||||
function timingSafeStringEqual(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||
const len = Math.max(a.length, b.length);
|
||||
// Pad with a NUL byte (will never appear in a real key) so the
|
||||
// shorter side still has the same length.
|
||||
const ba = Buffer.alloc(len, 0);
|
||||
const bb = Buffer.alloc(len, 0);
|
||||
ba.write(a, 'utf8');
|
||||
bb.write(b, 'utf8');
|
||||
// Even on length mismatch the compare runs to completion; we just
|
||||
// also require lengths match to count as equal.
|
||||
const equal = crypto.timingSafeEqual(ba, bb);
|
||||
return equal && a.length === b.length;
|
||||
}
|
||||
|
||||
function readHeader(req) {
|
||||
// Express normalizes header names to lowercase. Try the short form
|
||||
// first (the documented one), then the legacy long form.
|
||||
return req.get('x-internal-key') || req.get('X-VYNDR-Internal-Key') || null;
|
||||
}
|
||||
|
||||
function requireInternalAuth(options = {}) {
|
||||
const loopbackOnly = !!options.loopbackOnly;
|
||||
return function internalAuthMiddleware(req, res, next) {
|
||||
const expected = process.env.VYNDR_INTERNAL_KEY;
|
||||
if (!expected) {
|
||||
// Refuse to serve when the secret is unset — better than
|
||||
// accidentally exposing the endpoint with a default empty
|
||||
// value. n8n uses this to distinguish "misconfigured" (503)
|
||||
// from "wrong key" (401).
|
||||
return res.status(503).json({ error: 'Internal auth not configured' });
|
||||
}
|
||||
|
||||
const provided = readHeader(req);
|
||||
if (!provided || !timingSafeStringEqual(provided, expected)) {
|
||||
return res.status(401).json({ error: 'Invalid internal key' });
|
||||
}
|
||||
|
||||
if (loopbackOnly) {
|
||||
const remoteIp = req.ip || req.socket?.remoteAddress;
|
||||
if (!LOOPBACK_IPS.has(remoteIp)) {
|
||||
return res.status(403).json({ error: 'Origin not permitted' });
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireInternalAuth,
|
||||
__internals: {
|
||||
LOOPBACK_IPS,
|
||||
timingSafeStringEqual,
|
||||
readHeader,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user