Two valid requests can violate one business rule
Request A belongs to Alice. It names her tenant’s unused credit. The service checks the credit, marks it applied and adds 1,000 cents to the tenant balance.
Request B belongs to Alice too. It names the same credit and passes the same checks. After both requests finish, the credit is marked applied once, but the balance has increased by 2,000 cents.
There is no forged identity, foreign object or injected expression in this constructed case. The problem is the interval between a correct observation and the write that relies on it. A fact that was true when each request checked it is not permission for both requests to perform the effect.
Write the rule about the result
The fictional document service now applies a one-use credit to a tenant account. Credit C-1001 belongs to Cedar and is worth 1,000 cents. Its rule is:
A committed application of this credit may increase Cedar’s balance by 1,000 cents at most once. If the application fails before committing, neither the claim nor its balance effect should remain.
These are two related obligations. One prevents duplicate application. The other prevents partial application. Treating both as “use a transaction” hides the distinction we need to test.
The local lab stores an applied flag in a credit row and a balance in an account row. Amounts are integer cents and tenant ownership is checked before applying a credit. This is a synthetic local fixture, not a payment service.
The initial implementation looks reasonable when read as one request:
BEGIN
read credit where id = supplied id and tenant = Cedar
reject if missing or already applied
mark credit applied
increment Cedar balance by the credit amount
COMMIT
It uses bound SQL parameters. It checks the tenant. It even wraps the operation in a Spring transaction at READ_COMMITTED. All of that is retained in the vulnerable version.
Keep both requests alive
A sequential test passes. Apply C-1001 once and receive 200; apply it again after the first call finishes and receive 409. The second call sees the committed applied=true flag and stops.
An overlapping schedule asks a different question:
| Step | Request A | Request B |
|---|---|---|
| 1 | Reads applied=false |
|
| 2 | Pauses before writing | Reads applied=false |
| 3 | Marks applied, adds 1,000, commits | Still holds its earlier observation |
| 4 | Marks applied, adds 1,000, commits |
The second unconditional update is allowed to set an already-true flag to true again. Its balance update still runs. Looking only at the final flag would miss the duplicate effect; inspecting the final balance exposes it.
That is a race condition in the business operation. PortSwigger calls this family a limit-overrun race: multiple requests pass a limit check before the relevant state change prevents further use. The exact schedule here is our own controlled experiment. Race-condition mechanism
The lab uses H2 2.4.240. Under its read-committed behavior, connections see committed data and their own changes; an ordinary read does not reserve the observed business condition for that request’s later write. This experiment does not read uncommitted data. Both requests see the genuinely unused credit before either changes it. H2 transaction isolation and MVCC
A transaction gives the operation a commit/rollback boundary. It does not, at this isolation level and with these statements, make the earlier SELECT an exclusive right to apply the credit. The observed duplicate is compatible with both transactions committing all their own writes.
Stop measuring luck
A useful regression should not depend on whether two requests happen to arrive within a few milliseconds. The fixture provides a pause immediately after the initial read. Request A and request B each signal arrival, then wait on separate latches. Once both have arrived, the driver releases the selected first request, waits for its HTTP response, and releases the second.
This does not manufacture a new database operation. It holds two real HTTP requests at a point their ordinary execution already passes through. The pause expands the vulnerable interval and makes the tested order explicit. It is demonstration instrumentation, not an estimate of exploit success in a deployed service. Java’s CountDownLatch supplies the wait/release mechanism. Latch contract
The lab README gives the commands for both orders, first with the unconditional write and then with the repair. Predict the final balance as well as each response before running them. Reset only after the previous requests have finished.
Both schedules produce statuses [200,200], balanceCents:2000 and appliedCredits:1. Neither request needs an invalid parameter. Reverse the order and the same rule fails.
Authentication, tenant checks and CSRF protection remain enabled in both modes. They answer different questions from whether two valid calls may perform the same effect.
Put the condition in the write
The repaired operation changes the claim statement:
UPDATE credit
SET applied = TRUE
WHERE id = ? AND tenant = ? AND applied = FALSE
It then checks the update count:
int changed = jdbc.update(sql, id, "cedar");
if (changed != 1) {
throw new ResponseStatusException(CONFLICT);
}
JdbcTemplate.update reports the number of affected rows. Here, the credit ID is a primary key, so a successful claim affects exactly one row. A request whose condition no longer matches must not continue to the balance increment. JdbcTemplate update contract
The initial read is still present to obtain the fixed amount and reject obvious invalid requests. It is no longer the final authority to apply the credit. That decision is made by the conditional write against the database’s current row state. There is no API changing the fixture’s amount concurrently; a product allowing that would need to define how amount changes participate in the operation.
Run both schedules with the conditional claim enabled. Now the first committed claimant receives 200; the other receives 409. The final balance is 1,000 and only one credit is applied. The guided tests also release both waiters without choosing a winner and require one success and one conflict.
A second distinct credit still works. Applying C-1001 and C-1002 produces a 2,000-cent balance and two applied credits. The rule limits each credit, not the account’s total activity.
A lock around Java code could serialize callers sharing that lock. It would not automatically coordinate another process or a different code path. This repair places the decision where the shared row is changed. Our test uses one application process and multiple database connections; it does not claim a tested multi-instance deployment. Other designs can use database locks or uniqueness constraints, but their exact transaction behavior also needs verification.
Keep the claim and effect together
The conditional update is only half the operation. After it succeeds, the service increments the balance:
UPDATE account
SET balance_cents = balance_cents + ?
WHERE tenant = ?
Both statements remain inside the service transaction. A synthetic exception after the claim must restore applied=false and balance zero. Another exception after the balance update must restore both values too. The guided tests execute both failure points, then retry and verify a successful single application.
Spring’s ordinary declarative rollback policy rolls back for unchecked exceptions. The fixture uses an IllegalStateException and lets it escape the transactional method. Checked exceptions and caught exceptions require deliberate treatment; the presence of an annotation should not replace an executed failure test. Spring rollback rules
The negative control removes the conditional predicate and runs the repair assertions again. Only the overlap cases fail; sequential replay, tenant checks and rollback still work. That result explains why an ordinary-request suite could look reassuring while missing the business-rule failure. A useful test must distinguish the guarantee it measures from the other guarantees that happen to hold.
A successful response is another boundary
Suppose the database commits, but the caller loses the response. This service returns 409 when that caller retries the consumed credit. It preserves the one-use effect; it does not replay the original successful response. An idempotency-key protocol would need a defined scope, request matching and durable result handling. None is implemented or claimed here.
The balance is a database write in the same transaction. An email, external payment or message sent elsewhere would not be reversed by rolling back these rows. Coordinating such effects needs a separate design and failure experiment. Do not infer “exactly once everywhere” from one conditional update.
These results belong to H2 2.4.240 at READ_COMMITTED, with multiple connections in one local application. Moving the statements to another database requires checking its isolation, affected-row behavior and failure/retry rules. Controlled schedules establish reproducible counterexamples and regressions; they do not enumerate every interleaving or establish crash recovery.
Practice: choose the evidence for a second caller
The same application can apply credits through a worker entry point. Its contract is the same: each credit has at most one committed balance effect; an operation that fails before completion leaves neither write behind. Ordinary application, distinct credits and tenant rejection must continue to work.
Using the worksheet, decide whether this path meets that contract. Choose the observations that would distinguish a duplicate effect, a partial effect and a correct application. Write your own tests before opening the supplied comparison check. Then make any justified change and show which test would expose its removal. A no-change conclusion also needs evidence and a deliberately broken counterexample.
Keep the result small: executable tests and a short explanation of the decision, one rejected alternative and the remaining limit. The worker is invoked through Spring; its HTTP adapter is synchronous and no queue is modeled. This is a changed caller within the mechanism you have just studied, not a search for an unknown vulnerability category. The review guide provides progressive hints after a saved attempt.
The important question is where a request acquires the right to produce an effect, and what survives if it cannot finish. A final flag or a successful response answers neither question on its own.