Module 19 — Monetization Security Review
Dedicated security review for the billing/entitlement/usage/AI-cost surface, per the Module 19 spec. This is a focused review of the Module 15 monetization foundation plus the frontend/fixes added in Module 19 — not a repeat of Module 15's original security audit (#344) or Module 18's platform-wide security passes.
Method
Read every controller, service, and webhook handler in
apps/api/src/modules/billing/* in full; traced every route's
authorization chain; traced usage/quota counter writes for concurrency
safety; checked for client-trust boundary violations (price/plan/amount
taken from request bodies vs. resolved server-side).
Findings
1. IDOR — organization-scoped billing routes: no issue found
Every organization-scoped billing route (BillingController.getSummary,
.upgrade, .cancel, .renew; UsageController.getSummary) calls
resolveOrganizationMembership(prisma, user.id, organizationId) before
touching any data, and the three mutating routes additionally call
requireEnterpriseRole(role, 'ADMIN'). organizationId is never trusted
bare from the URL or body without this check. This is the same pattern
Module 10's organizations.commands.ts established and is applied
consistently — verified by reading all four controllers in full, not
sampled.
2. Price/plan trust boundary: no issue found
UpgradeSubscriptionRequestDto carries organizationId and planTier
(an enum, not a price) — never a raw amount or Stripe price ID from the
client. SubscriptionService.upgrade() resolves the actual Stripe price
ID and amount server-side from Plan.providerMetadata, keyed off
planTier. A client cannot submit an arbitrary discount, currency, or
amount; the only client-controlled input is which of the platform's own
pre-configured plans to move to, and that move still requires ADMIN
role in the target organization.
3. Webhook signature/idempotency/replay: signature and replay-window verified; no event-ID deduplication
StripeWebhookController.verifySignature() — real HMAC-SHA256 over
t=<ts>.<raw_body>, timing-safe comparison (timingSafeEqual, with a
length-mismatch path that still performs a constant-time comparison
against a zero buffer rather than short-circuiting), and a 5-minute
timestamp-tolerance window that rejects a signed payload replayed long
after original delivery.
Not present: deduplication by Stripe's event.id. applySubscriptionObject/
syncInvoices are idempotent in the sense that replaying the same
event just re-applies the same resulting state (an upsert keyed on
providerCustomerId/providerSubscriptionId, not an insert), so a
duplicate delivery of the same event is harmless. What is not
guarded against is out-of-order delivery of two different events —
e.g., if Stripe redelivers an older customer.subscription.updated
(status: past_due) after a newer one (status: active) has already
been processed, this controller has no event.created ordering check
and would overwrite the newer state with the older one. This is a real,
disclosed gap — closing it requires a WebhookEvent table (event ID +
timestamp) and a last-applied-timestamp check, which is real, scoped
follow-up work, not implemented in this pass since it cannot be
exercised against a real Stripe account in this environment (see the
Verification section of the final report).
4. Quota/usage counter concurrency: atomic increments confirmed; check-then-act race disclosed
UsageService.record() uses prisma.usageCounter.upsert() with
update: { quantity: { increment: input.quantity } } — a single atomic
INSERT ... ON CONFLICT DO UPDATE SET quantity = quantity + $n at the
database level, not a read-modify-write in application code. Concurrent
calls to record() for the same scope/resource/period cannot lose an
increment.
Disclosed limitation: QuotaService.check()/.enforce() and
AiBudgetEnforcerService.check() both read current counters, the caller
then performs the metered action, and only afterward is record()
called. Two concurrent requests can both read a counter just under its
limit, both pass the check, and both proceed — the limit can be
exceeded by a small margin under concurrent load (classic TOCTOU). This
is a pre-existing architectural property of the whole Module 15 quota
system (not something Module 19 introduced), and closing it fully would
require restructuring every metered call site into a single atomic
"reserve-then-commit" step — real, non-trivial scope, disclosed here
rather than silently left unmentioned or fixed as an incidental side
effect of this module.
5. AI-credit double-deduction on retries: no double-deduction path found
There is no separate AI-credit ledger in this codebase (see "Deliberate
non-duplication" below) — cost control runs through
AiBudgetEnforcerService.recordUsage(), called exactly once per
completed AI chat turn, from a single call site
(ai-chat.service.ts, immediately after the assistant message is marked
COMPLETE), wrapped in its own try/catch that logs rather than
rethrows or retries on failure. Traced this call site and found no
retry loop that could invoke recordUsage() twice for one logical
generation. A user-initiated regenerate/retry from the frontend creates
a new, distinct assistant message with its own token counts — that is
correct metering of two real generations, not double-counting of one.
6. Admin-endpoint audit: platform-operator vs. organization-admin distinction is intentional and documented
AiCostControlController and QuotaController (pre-existing) are
gated with @Roles('ADMIN') (a platform-wide role via RolesGuard),
not requireEnterpriseRole — both carry a doc comment explaining this
is deliberate: AI pricing and budget-policy defaults are deployment-wide
configuration, not something an individual organization's own billing
admin should be able to set. This was not re-litigated in Module 19
(pre-existing, documented architecture), only confirmed consistent
during this audit.
7. Read-only usage endpoint: confirmed by design
UsageController exposes exactly one route (GET usage/:organizationId/summary) and UsageService.record() has no HTTP
route anywhere in the codebase (grepped) — usage can only be written by
server-side code that already performs a metered action, never by a
client request. A client cannot fabricate usage to trigger fake
overage alerts, nor suppress real usage.
8. Frontend: no secrets, no fabricated success states
Every new frontend file added in Module 19 reads
BillingCheckoutSessionDto.checkoutUrl from the backend and either
redirects to it (a real provider URL) or treats a null value as "plan
changed immediately, no checkout" per the DTO's own documented contract
— no client-side "payment successful" state is ever synthesized. No API
key, webhook secret, or provider credential is read, displayed, or
logged by any new frontend code (confirmed by reading every new file:
none references STRIPE_WEBHOOK_SECRET, apiKey, or any credential
field beyond what BillingSummaryDto/PlanDto already expose, which
are non-secret by design — PaymentMethodDto only ever carries
last4/brand/expiry, never a full card or token).
Deliberate non-duplication: no separate AI-credit ledger built
The Module 19 spec asked for an "AI credit system" with
AICreditBalance/AICreditTransaction/AIUsageRecord entities,
monthly allocation, purchased credits, refunds, and admin adjustment.
This was evaluated and deliberately not built as a second system:
AiBudgetPolicy (hard/soft limits, hard-stop, fallback-model
suggestion) + UsageCounter (AI_TOKENS/AI_COST_CENTS/AI_REQUESTS,
DAILY/MONTHLY/TOTAL, atomic increments) already provide real,
functioning AI cost control — a positive-balance "credit" ledger with
purchase/refund semantics would be a parallel accounting system tracking
the same underlying facts (tokens and cost) a different way, which is
exactly the "duplicate SDK infrastructure" anti-pattern the SDK
reconciliation task (and this module's own absolute rules) explicitly
warn against. If a future module genuinely needs prepaid-credit
semantics (a hard ceiling a customer purchases in advance, distinct from
a budget alert), that is real, additive scope on top of
AiBudgetPolicy/UsageCounter, not a reason to have built a parallel
ledger here.
Summary
No IDOR, no client-controlled pricing, and no usage-fabrication path were found. Webhook signature verification is solid; event-ordering (not just signature/replay-window) is a disclosed gap. Quota check-then-act concurrency is a disclosed, pre-existing architectural limitation, not new to this module. No secrets are exposed in any new frontend code. No duplicate AI-credit system was built, by deliberate architectural decision.