Skip to content

fix(security): enforce active apiAccess on all keyed wm_ API-key routes (#4611)#4612

Merged
koala73 merged 2 commits into
mainfrom
fix/4611-expired-apikey-apiaccess-gate
Jul 1, 2026
Merged

fix(security): enforce active apiAccess on all keyed wm_ API-key routes (#4611)#4612
koala73 merged 2 commits into
mainfrom
fix/4611-expired-apikey-apiaccess-gate

Conversation

@koala73

@koala73 koala73 commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Closes #4611.

Problem

Cancelled/downgraded API-Starter customers kept full programmatic access via their un-revoked wm_ key. validateUserApiKey checks key existence only (never the owner's subscription), and the apiAccess gate was scoped to PREMIUM_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 to isUserApiKey (the wm_ key is the authenticating credential ⇒ sessionUserId is the resolved owner):

  • Resolve the owner's entitlement once and 403 before the fix(api): P1 — enforce per-tier API limits (Phase 1: per-account burst + usage meter + safety ceiling) #3199 per-account rate-limit block when the entitlement is affirmatively inactive/expired (!apiAccess || validUntil < now).
  • The resolved entitlement is reused by the rate-limit block (no duplicate lookup).
  • Fail-open on an unresolved entitlement (transient Convex/cache null) 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, so a lapsed key there leaks no product; re-validating arbitrary keys on that anonymous surface would add an unauthenticated Convex-lookup amplification vector (a rotating fake wm_ key per request defeats the negative cache, ahead of any rate limit).

Data (verified against prod, 30d)

  • Real leak: 3 downgraded accounts (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 was list-acled-events (166 reqs, free data).
  • The larger plan_key=None served volume (~65k) is active paying api_starter customers with a telemetry-attribution gap — the fix correctly keeps serving them.

Acceptance criteria (#4611)

  • Inactive 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).
  • Active apiAccess owner unaffected (fail-open on transient null protects them further).
  • Re-subscribing restores the same key (no revocation).
  • Enforced before the per-account rate-limit eligibility check.

Tests

  • New server/__tests__/gateway-user-key-apiaccess.test.ts — downgraded/expired/null(fail-open)/active/re-subscribe/enterprise-exempt/public-not-revalidated.
  • Updated gateway-api-rate-limit.test.ts (the case that codified the bug now asserts 403).
  • Full vitest 501 pass · tsx gateway/auth 92 pass · 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_starter customers emit plan_key=None on 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

…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
@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 1, 2026 7:06pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a route-wide active-subscription gate for wm_ user API keys in server/gateway.ts, closing a real churn-leak: cancelled customers kept full programmatic access via un-revoked keys because the prior apiAccess check was scoped only to PREMIUM_RPC_PATHS. The fix runs before the #3199 rate-limit block, reuses the resolved entitlement to avoid a duplicate Convex lookup, fails open on null (transient backend outage), and correctly skips PUBLIC_NO_AUTH_RPC_PATHS to avoid an unauthenticated Convex-lookup amplification vector.

  • server/gateway.ts: The old if (isUserApiKey && needsLegacyProBearerGate && sessionUserId) gate (PREMIUM routes only) is replaced by if (isUserApiKey && sessionUserId) (all keyed routes), with the rejection condition updated from fail-closed to fail-open; the resolved entitlement is stored in userKeyEntitlement and reused in the rate-limit block downstream.
  • server/__tests__/gateway-user-key-apiaccess.test.ts: New test suite covering downgraded, expired, null (fail-open), active, re-subscribe, enterprise-exempt, and public-no-auth cases with explicit assertions that the rate-limit layer is never reached for rejected keys.
  • server/__tests__/gateway-api-rate-limit.test.ts: The existing test that previously asserted 200 for apiAccess:false is updated to expect 403, codifying the fix.

Confidence Score: 5/5

Safe 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

Filename Overview
server/gateway.ts New route-wide user-key active-subscription gate replaces the old PREMIUM-only check; fail-open design, correct scoping via isUserApiKey, and entitlement reuse are all implemented correctly.
server/tests/gateway-user-key-apiaccess.test.ts New test file covering all meaningful gate states (downgraded, expired, null fail-open, active, re-subscribe, enterprise-exempt, public-no-auth) with correct mock wiring and rate-limit bypass assertions.
server/tests/gateway-api-rate-limit.test.ts One test updated: apiAccess:false downgraded key now asserts 403 instead of 200, correctly codifying the bug fix; both limiter mocks assert not.toHaveBeenCalled().

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "fix(security): enforce active apiAccess ..." | Re-trigger Greptile

@koala73
koala73 merged commit 9c531ed into main Jul 1, 2026
24 checks passed
@koala73
koala73 deleted the fix/4611-expired-apikey-apiaccess-gate branch July 1, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(security): cancelled/downgraded users keep full API access via un-revoked wm_ keys (apiAccess only checked on premium paths)

1 participant