EC-Council CASE study materials
EC-Council CASE study resources

LedgerLoop: a cross-tenant invoice leak and a stale-claim architecture

EC-Council CASE · Put your knowledge to work in context.

All case studies

LedgerLoop: a cross-tenant invoice leak and a stale-claim architecture

LedgerLoop is a multi-tenant accounts-payable SaaS: 4,200 tenants, one shared Postgres database, a tenant_id column on every table, and a single API. Authentication issues an RS256 JWT containing sub, tenant_id, role and a 24-hour exp; the browser caches it in localStorage. Middleware verifies the signature and copies the claims into the request context. Controllers then filter queries by req.ctx.tenant_id.

A bug bounty report shows user A, a Viewer in tenant 100, called GET /api/invoices/{id} with an invoice id belonging to tenant 200 and received the full record including bank details. The developer's fix adds tenant_id to the WHERE clause.

The same report notes that a token belonging to an employee removed 19 hours earlier still worked, and that password reset emails a six-digit code valid for 24 hours with unlimited attempts. The VP of Engineering wants a review before the next release.

1. Explain why the WHERE clause fix is incomplete and enumerate every authorization failure visible in this design.

Read the worked answer

The reported bug is broken object-level authorization. The patch closes that instance and nothing else.

Failures visible:

  1. Authorization lives in every controller. With hundreds of handlers, one forgotten filter is a breach. The bounty hunter found one instance, not the only one.
  2. Claims are trusted for up to 24 hours. A user demoted from Admin to Viewer, or removed from a tenant, keeps the old role until the token expires. That is exactly the 19-hour offboarding finding.
  3. Role from the token is used for function-level checks. A Viewer should never reach POST endpoints; if the only gate is a role read from a claim, revocation and demotion do not take effect.
  4. Over-exposure. Even a legitimately authorized Viewer received bank details. This is field-level authorization, a separate control from row-level filtering.
  5. Enumeration. Invoice ids are almost certainly sequential, so the same bug is a bulk-dump primitive.
  6. No authorization audit trail, so you cannot tell how many records were actually read.

Where checks belong: gateway for authentication and coarse tenant routing; service for function-level and resource-level policy; data layer as a backstop (Postgres row-level security keyed on a session variable); serialization layer for per-role response DTOs. Move policy out of controllers into a single enforcement point - a policy engine or a shared decorator - so a new endpoint inherits the check instead of having to remember it. Then add a cross-tenant test harness that runs every endpoint as tenant A against tenant B objects and fails the build on any 200.

2. Redesign the password reset flow. Address entropy, storage, expiry, attempt limits, enumeration and post-reset session handling.

Read the worked answer

Token: 128 bits from a CSPRNG, encoded base64url (about 22 characters) or 32 bytes hex. A six-digit code is only acceptable with a five-minute TTL and a five-attempt cap bound to the reset session - 10^6 space with 24 hours and unlimited guesses is a guaranteed compromise.

Storage: store only HMAC-SHA256(token, server-key). High-entropy random tokens do not need a slow KDF; they need a keyed hash so a database read does not yield usable tokens.

Lifecycle: single use, enforced by an atomic compare-and-set so two concurrent requests cannot both consume it. TTL 15-30 minutes. Issuing a new token invalidates prior outstanding tokens for that account. On successful reset, invalidate all sessions and refresh tokens for the user, notify the user by email and audit the event.

Attempts and rate: per-account and per-IP throttles with exponential backoff, plus a global cap so a distributed guess does not scale.

Enumeration: identical response body and comparable response time whether or not the address exists. No account identifier in the URL path. Use POST or a token exchange rather than a GET link that email scanners and prefetchers will burn - and set a strict Referrer-Policy so the token does not leak to third parties.

Comparison: constant-time compare on the token hash.

After reset: apply the password policy and breached-password screening, and confirm the reset out-of-band to the old address so a hijacked mailbox is visible.

3. Recommend a token and session revocation design given the 24-hour tokens and the requirement that offboarding takes effect immediately. State the trade-offs you accept.

Read the worked answer

You cannot revoke a signed JWT - you can only shorten it and check state.

Recommended design: access token 10-15 minutes carrying a sid (session id) and a token version. A server-side session record in Redis holds the sid, user, tenant, role and version. Refresh tokens are rotated on every use with reuse detection: if an old refresh token appears again, kill the whole session family. On offboarding or demotion, delete the session records and bump the user's version; the next request with the old token fails the check.

Where the check happens. Either on every request (strongest, adds roughly a millisecond on a local Redis) or at refresh (cheaper, but the worst-case revocation delay equals the access-token lifetime). For an AP system that can move money, check every request and accept the latency.

Cache TTL is your real revocation SLA. If you cache session state, the revocation window is the cache TTL, not zero. Set it to 30 seconds and document a 60-second RTO rather than claiming instant.

Denylist alternative: a jti denylist entry must live at least as long as the token's remaining lifetime and needs the same fast store, so it is the same infrastructure with more bookkeeping. Use it only for emergency single-token kill.

Extras: wire the identity provider's back-channel logout so SSO logout propagates, and require step-up re-authentication for bank-detail changes and role grants regardless of token validity, because a stolen live token is indistinguishable from the user.