fix(security): enforce active apiAccess on all keyed wm_ API-key routes (#4611)#4612
Conversation
…es (#4611) Cancelled/downgraded API-Starter customers kept full programmatic access via their un-revoked wm_ key: validateUserApiKey checks key existence only, and the apiAccess gate was scoped to PREMIUM_RPC_PATHS, so the whole keyed RPC surface was ungated. Replace it with a route-wide gate keyed on isUserApiKey (the authenticating credential): resolve the owner entitlement once (reused by the #3199 rate-limit block to avoid a duplicate lookup) and 403 before rate-limiting when the entitlement is affirmatively inactive or expired. - Fail-open on an unresolved entitlement (transient Convex/cache failure) so a backend blip never 403s active subscribers fleet-wide. - Public-no-auth routes are intentionally not re-validated: they serve free data to everyone, and validating arbitrary keys on the anonymous surface would add an unauthenticated Convex-lookup amplification vector. Verified against prod (30d): 3 downgraded accounts (apiAccess:false) served ~4k requests, ~96% on keyed/paid routes (all now 403'd); the one public route hit (list-acled-events) is free data. Adds server/__tests__/gateway-user-key-apiaccess.test.ts. Claude-Session: https://claude.ai/code/session_01YG8rgjvzmeYwHsjCtniCnU
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds a route-wide active-subscription gate for
Confidence Score: 5/5Safe to merge — the gate is correctly scoped, the fail-open path is intentional and well-tested, and active subscribers are unaffected. The new subscription gate is logically sound: isUserApiKey is set only when the wm_ key is the authenticating credential (never for enterprise operator keys or public-no-auth routes), sessionUserId is always populated from the key owner's resolved userId for non-tier-gated routes, and the rejection condition fires only on an affirmatively inactive entitlement. The entitlement reuse via userKeyEntitlement !== undefined correctly avoids a duplicate Convex lookup on the hot active-key path, and recordUsageEntitlement is idempotent so the incidental double-call on the active path is harmless. Test coverage maps directly to every meaningful gate state, including the previously-codified bug case. No files require special attention — all three changed files are straightforward and the logic in gateway.ts is well-commented. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant Gateway as server/gateway.ts
participant UserKeyValidator as validateUserApiKey
participant Entitlements as getEntitlements
participant RateLimit as Rate-limit block
participant Handler as Route Handler
Client->>Gateway: Request + X-Api-Key: wm_xxx
alt PUBLIC_NO_AUTH_RPC_PATHS
Gateway-->>Handler: "isUserApiKey=false, gate skipped"
Handler-->>Client: 200
else All other keyed routes
Gateway->>UserKeyValidator: validateUserApiKey(wm_xxx)
UserKeyValidator-->>Gateway: "{userId, keyId, name}"
Gateway->>Entitlements: getEntitlements(sessionUserId)
Entitlements-->>Gateway: userKeyEntitlement
alt null entitlement
Note over Gateway: Fail-OPEN
Gateway->>Handler: proceed
Handler-->>Client: 200
else "apiAccess:false OR validUntil < now"
Gateway-->>Client: 403
else Active subscription
Gateway->>RateLimit: reuse userKeyEntitlement
RateLimit-->>Handler: proceed
Handler-->>Client: 200
end
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant Gateway as server/gateway.ts
participant UserKeyValidator as validateUserApiKey
participant Entitlements as getEntitlements
participant RateLimit as Rate-limit block
participant Handler as Route Handler
Client->>Gateway: Request + X-Api-Key: wm_xxx
alt PUBLIC_NO_AUTH_RPC_PATHS
Gateway-->>Handler: "isUserApiKey=false, gate skipped"
Handler-->>Client: 200
else All other keyed routes
Gateway->>UserKeyValidator: validateUserApiKey(wm_xxx)
UserKeyValidator-->>Gateway: "{userId, keyId, name}"
Gateway->>Entitlements: getEntitlements(sessionUserId)
Entitlements-->>Gateway: userKeyEntitlement
alt null entitlement
Note over Gateway: Fail-OPEN
Gateway->>Handler: proceed
Handler-->>Client: 200
else "apiAccess:false OR validUntil < now"
Gateway-->>Client: 403
else Active subscription
Gateway->>RateLimit: reuse userKeyEntitlement
RateLimit-->>Handler: proceed
Handler-->>Client: 200
end
end
Reviews (1): Last reviewed commit: "fix(security): enforce active apiAccess ..." | Re-trigger Greptile |
Closes #4611.
Problem
Cancelled/downgraded API-Starter customers kept full programmatic access via their un-revoked
wm_key.validateUserApiKeychecks key existence only (never the owner's subscription), and theapiAccessgate was scoped toPREMIUM_RPC_PATHS— so the entire keyed RPC surface was served to lapsed keys.Fix
A route-wide active-subscription gate in
server/gateway.ts, scoped toisUserApiKey(thewm_key is the authenticating credential ⇒sessionUserIdis the resolved owner):!apiAccess || validUntil < now).null) so a backend blip never 403s active subscribers fleet-wide.wm_key per request defeats the negative cache, ahead of any rate limit).Data (verified against prod, 30d)
plan: free,apiAccess: false, keys never revoked) served ~4,086 requests — ~96% on keyed/paid routes (get-fear-greed-index,get-resilience-ranking[premium],list-market-quotes,get-cot-positioning,get-macro-signals,get-vessel-snapshot, …). All now 403'd. The only public route hit waslist-acled-events(166 reqs, free data).plan_key=Noneserved volume (~65k) is active payingapi_startercustomers with a telemetry-attribution gap — the fix correctly keeps serving them.Acceptance criteria (#4611)
wm_key → 403 on all keyed routes (regular + premium + tier-gated). Deliberate scope decision: public-no-auth routes serve as anonymous — free data, and to avoid the amplification vector above (see thread).apiAccessowner unaffected (fail-open on transient null protects them further).Tests
server/__tests__/gateway-user-key-apiaccess.test.ts— downgraded/expired/null(fail-open)/active/re-subscribe/enterprise-exempt/public-not-revalidated.gateway-api-rate-limit.test.ts(the case that codified the bug now asserts 403).tsc(default + api) clean.Reviewed
Multi-persona review (correctness/security/adversarial) + independent cross-model (Codex) pass. Findings resolved: fail-closed→active-customer-403 (fixed via fail-open), public-route amplification (fixed via isUserApiKey scoping), redundant
getEntitlements(fixed via reuse), leads/verified-path over-gating (mooted by scoping).Noted (not in this PR)
Active
api_startercustomers emitplan_key=Noneon keyed routes (~60k of ~65k such reqs/30d) — a telemetry-attribution gap that leaves #4572 incomplete. Tracked in #4613.https://claude.ai/code/session_01YG8rgjvzmeYwHsjCtniCnU