Bearer compare on /api/cron/drain-email-outbox is a plain !== where Mars uses timing-safe
Found during PLUTO-733 (EVO-93) while confirming the drain route accepts an external bearer-authed curl. Split out at coder-mars-cc's instruction: do NOT fold it into 733, which is a scheduler migration with no app-code change. src/app/api/cron/drain-email-outbox/route.ts compares the presented bearer to CRON_SECRET with a plain `token !== expected`. Mars's equivalent route uses a timing-safe compare. Same epic, same contract otherwise (GET, Bearer, 503/401/200/500), so the divergence is invisible to anyone who assumes the two apps match - which is the actual risk here. Not urgent and NOT presented as a practical break: a remote timing attack against a 64-hex secret over HTTPS, through Vercel's edge, is not a realistic recovery path. It is filed because it is a real divergence on an AUTH GATE, and auth-gate divergences belong on the record rather than being rediscovered later. Scope if picked up: switch to crypto.timingSafeEqual over Buffers, handling the length-mismatch case first (timingSafeEqual throws on unequal lengths, so compare lengths separately and keep the same 401 shape). Check whether the same plain-compare pattern appears on the other bearer-gated routes - /api/internal/send-email and the other /api/cron/* handlers - before fixing only this one. Do not change the 401 response body and do not drop the drainEmailOutboxCronUnauthorized authz log; the applog rail pages on it.
Activity
-
FIXED in df90071, v2.23.84 live (dpl_7wpoNNZr9ruRbS8eRhXNg1Wi2Vg9). The route now compares the bearer with the canonical safeEqualStrings from internal-route-guard.ts instead of a plain token !== expected. The 401 body, the drainEmailOutboxCronUnauthorized authz log, the 503-on-unset branch and the CRON_SECRET-only credential set are all unchanged. SURVEY THE WI ASKED FOR: the plain compare was in exactly ONE place. Five cron routes (ux-pain-digest, pending-items-digest, practicas-kpi-report, error-digest, poll-security-alerts) each carry a byte-identical PRIVATE copy of safeEqualStrings + authOk; internal-route-guard.ts exports the canonical one behind checkInternalBearer/checkSupportBearer for /api/internal/{send-email,support-reply,support-action,alert-fallback-probe}. All six are correct. They are deliberately NOT consolidated in this WI - rewriting five live auth gates is a bigger blast radius than the defect, and the anti-drift mechanism is the guard test, not the refactor. OBSERVED, NOT CHANGED: this gate accepts CRON_SECRET only; the five sibling crons accept CRON_SECRET or INTERNAL_CRON_KEY. Aligning them would widen what an auth gate ACCEPTS rather than fix how it compares - an unrequested security change. Divergence pinned in a route comment and in the test header. If it should be aligned, that is a separate call. TESTS: new src/app/api/cron/drain-email-outbox/route.test.ts (the route had none) covers 503-on-unset with no authz row, missing header, same-length wrong token, three WRONG-LENGTH tokens (the load-bearing case - node timingSafeEqual THROWS on unequal-length buffers, so dropping the length pre-check turns every short/long bearer into a 500 and loses the authz row applog pages on), a non-Bearer scheme carrying the right value, the 200 happy path, and the 500 catch asserting the raw SMTP message never reaches the client body. New src/lib/bearer-compare-guard.test.ts walks every bearer-gated route.ts under src/app/api and asserts none uses a plain equality compare and each references a constant-time helper; vacuous-pass floor plus git ls-files --others so a NEW untracked route cannot slip past. 49 tests pass across the two new files and the three existing bearer-route suites. Mutation-checked twice: reverting the route compare fails exactly the guard case and nothing else (the behavioural tests still pass under a plain compare - timing is the only observable difference, which is why the source-level guard is the mechanism), and deleting the length pre-check from safeEqualStrings fails exactly the wrong-length case. tsc --noEmit and eslint clean on the touched files. COVERAGE STATEMENT - what was and was NOT exercised in production. Verified live on the new deploy: a wrong-length bearer returns 401, not 500 (curl at 21:04 UTC). NOT verified live: the 200 happy path. CRON_SECRET is a Vercel SENSITIVE var and pulls back EMPTY, so an authenticated self-probe is impossible - both of my probes were negatives. No pg_cron tick has reached this deploy in the ~22 minutes since it went live, because job 10 runs fn_drain_email_outbox_if_due() and only fires net.http_get when a row is actually due; the 24h baseline is 66 200s, i.e. roughly one every 22 minutes, not one every 5. The happy path is therefore covered by unit test only until the next real drain. Whoever next reads the applog rail: my probes wrote THREE drainEmailOutboxCronUnauthorized authz rows at 2026-09-10T21:04:43-44Z. Those are mine, not an attacker. NOTE FOR PLUTO-733/EVO-93: bin-venus-ca will need the real CRON_SECRET to probe this gate from venus, and it is not pullable from Vercel. Minting a replacement is rotation and is banned, so that is a blocker to escalate, not to work around.