Skip to content

Harden transfer endpoint: validation + idempotency - #297

Open
achalc wants to merge 1 commit into
DevOpsfrom
devin/1787758171-harden-transfer
Open

achalc wants to merge 1 commit into
DevOpsfrom
devin/1787758171-harden-transfer

Conversation

@achalc

@achalc achalc commented Aug 26, 2026

Copy link
Copy Markdown

Summary

POST /transfer previously accepted any BigDecimal and posted it with no validation and no transaction boundary. A negative amount drained the recipient (from.balance.subtract(-10) credits the sender, to.balance.add(-10) debits the recipient), and a double-submit / browser refresh posted the same transfer twice.

AccountService.transferAmount now takes an idempotency key and rejects bad requests before any balance moves:

@Transactional
void transferAmount(Account from, String toUsername, BigDecimal amount, String idempotencyKey) {
    validateAmount(amount);            // null | <= 0 | scale > 2 | > MAX_TRANSFER_AMOUNT (1_000_000)
    recipient = toUsername.trim();     // blank -> reject; equalsIgnoreCase(from.username) -> reject
    key = idempotencyKey.trim();       // blank -> reject
    if (processedTransferRepository.existsByIdempotencyKey(key)) reject("Duplicate transfer request ignored");
    ... insufficient-funds + recipient-lookup checks (unchanged) ...
    recordIdempotencyKey(key, from.username);  // saveAndFlush; unique-constraint violation -> duplicate
    ... existing debit/credit + two Transaction rows ...
}

Two layers guard duplicates: the existsByIdempotencyKey read handles the common double-submit, and the unique constraint on processed_transfer.idempotency_key (claimed via saveAndFlush before balances change) is authoritative for the concurrent race — a DataIntegrityViolationException there is translated into the same rejection. Adding @Transactional also closes the existing hole where a failure between the debit and the credit destroyed money.

The key is supplied by the dashboard: GET /dashboard puts a fresh transferToken UUID in the model, the transfer form carries it as a hidden field, so resubmitting the same rendered page is rejected rather than posted twice.

Rejections use the new InvalidTransferException (a RuntimeException, so the controller's existing catch renders it as the dashboard error message — no behaviour change for the happy path).

AccountServiceTransferTest (13 Mockito tests, no DB) covers each rejected case: negative, zero, null, sub-cent, over-limit, self-transfer (case/whitespace-insensitive), blank recipient, missing key, duplicate key, lost unique-constraint race, insufficient funds, unknown recipient, plus the successful transfer.

Verified: ./mvnw clean test → 14 tests, BUILD SUCCESS (with the local MySQL container running).

Devin-Org: engineering

Link to Devin session: https://app.devin.ai/sessions/1c937d5949664b52bb653967367b8770
Requested by: @achalc


Devin Review

Status Commit
⚪ Not started

Run Devin Review

💡 Connect your GitHub account to enable automatic code reviews.

Devin Review (Staging)
Open in Devin Review

…tection

Co-Authored-By: Achal Channarasappa <achal.channarasappa@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Negative-amount abuse remains in deposit and withdraw

transferAmount now rejects non-positive amounts, but deposit (AccountService.deposit) and withdraw (AccountService.withdraw) still accept any amount. withdraw with a negative value computes balance.subtract(-x), minting money. Same vulnerability class the PR targets, left unaddressed on these endpoints.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and real: withdraw with a negative amount does balance.subtract(-x), minting money, and deposit accepts negatives too. Both are pre-existing and outside this PR's scope (transfer endpoint only), so I'm not expanding the diff unilaterally — flagging for the repo owner to decide whether to fold the same validateAmount guard into deposit/withdraw here or in a follow-up.

Comment on lines 136 to 147
if (fromAccount.getBalance().compareTo(amount) < 0) {
throw new RuntimeException("Insufficient funds");
}

Account toAccount = accountRepository.findByUsername(toUsername)
Account toAccount = accountRepository.findByUsername(recipient)
.orElseThrow(() -> new RuntimeException("Recipient account not found"));

recordIdempotencyKey(key, fromAccount.getUsername());

// Deduct from sender's account
fromAccount.setBalance(fromAccount.getBalance().subtract(amount));
accountRepository.save(fromAccount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Concurrent distinct transfers can overdraw balance

@Transactional adds no row locking, and the idempotency key only dedupes identical requests. Two concurrent transfers from one sender with different tokens both pass the balance check on a fromAccount loaded outside the transaction, then both debit, producing a lost update. Pre-existing; not the double-submit case this PR targets.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate — @Transactional alone gives no row lock, so two concurrent transfers with distinct tokens can both pass the balance check on the detached fromAccount and overdraw. Correctly noted as pre-existing and distinct from the double-submit case this PR targets. The fix would be a pessimistic re-read of the sender (SELECT ... FOR UPDATE via @Lock(PESSIMISTIC_WRITE)) inside the transaction; leaving it out of this diff and flagging for the owner.

Comment on lines +128 to +134
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
if (key.isEmpty()) {
throw new InvalidTransferException("Missing transfer request identifier");
}
if (processedTransferRepository.existsByIdempotencyKey(key)) {
throw new InvalidTransferException("Duplicate transfer request ignored");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Idempotency key is global, not per user

existsByIdempotencyKey and the unique constraint are not scoped by fromUsername, which is stored but never used for lookup. Keys are client-supplied hidden fields. Random UUIDs make collisions negligible, so this is not a practical issue, but the key namespace is shared across all users.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the key namespace is global and from_username is stored but not part of the lookup or the unique constraint. With server-generated random UUIDs collisions are negligible, but scoping is stricter: a (from_username, idempotency_key) unique constraint plus existsByFromUsernameAndIdempotencyKey would keep one user's key from ever affecting another's. Small change if the owner wants it in this PR.

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.

1 participant