-
Notifications
You must be signed in to change notification settings - Fork 170
Fix double refund race condition vulnerability #1406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Fix double refund race condition vulnerability #1406
Conversation
Reviewer's guide (collapsed on small PRs)Reviewer's GuideAdds transactional row-level locking around the order refund endpoint to prevent a double-refund race condition by ensuring refund calculations and validations occur under an exclusive database lock. Sequence diagram for concurrent refund requests with row-level lockingsequenceDiagram
autonumber
actor User1
actor User2
participant API1 as OrderViewSet_refund_Thread1
participant API2 as OrderViewSet_refund_Thread2
participant DB as Database
User1->>API1: POST /orders/{id}/refund
User2->>API2: POST /orders/{id}/refund
API1->>DB: BEGIN TRANSACTION
API1->>DB: SELECT payment FOR UPDATE
Note over DB: Row-level lock acquired by API1
API2->>DB: BEGIN TRANSACTION
API2->>DB: SELECT payment FOR UPDATE (blocks)
API1->>API1: available_amount = amount - refunded_amount
API1->>API1: validate refund <= available_amount
API1->>DB: UPDATE payment (refunded_amount += refund)
API1->>DB: COMMIT (releases row lock)
API1-->>User1: 200 OK refund processed
DB-->>API2: SELECT payment FOR UPDATE (lock acquired)
API2->>API2: available_amount = updated_amount - updated_refunded_amount
API2->>API2: validate refund <= available_amount (fails)
API2-->>User2: 400 Bad Request refund exceeds available_amount
API2->>DB: ROLLBACK
Flow diagram for secured refund endpoint with transactional lockingflowchart TD
A[Refund endpoint called
OrderViewSet_refund] --> B[Start transaction
transaction.atomic]
B --> C[Load payment
OrderPayment.select_for_update]
C --> D[Parse refund amount
from request]
D --> E[Check provider:
full_refund_possible]
E --> F[Check provider:
partial_refund_possible]
F --> G[Compute available_amount = payment.amount - payment.refunded_amount
under row lock]
G --> H{Is amount <= 0?}
H -->|Yes| Z1[Return error
invalid amount]
H -->|No| I{Is amount > available_amount?}
I -->|Yes| Z2[Return error
refund exceeds available]
I -->|No| J[Process refund via provider]
J --> K[Update payment.refunded_amount
and persist]
K --> L[Commit transaction]
L --> M[Return success]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey there - I've reviewed your changes - here's some feedback:
- Instead of calling
self.get_object()and then re-querying withOrderPayment.objects.select_for_update(), consider usingself.get_queryset().select_for_update().get(pk=...)to avoid the extra query and ensure any view-level filtering or permission constraints are preserved. - Since
@transaction.atomicis now applied at the view level, double-check whether any upstream code wraps this call in another atomic block; if so, you may want to useatomic(savepoint=False)or move the locking logic closer to the model/service layer to avoid unnecessary nesting and potential deadlocks.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Instead of calling `self.get_object()` and then re-querying with `OrderPayment.objects.select_for_update()`, consider using `self.get_queryset().select_for_update().get(pk=...)` to avoid the extra query and ensure any view-level filtering or permission constraints are preserved.
- Since `@transaction.atomic` is now applied at the view level, double-check whether any upstream code wraps this call in another atomic block; if so, you may want to use `atomic(savepoint=False)` or move the locking logic closer to the model/service layer to avoid unnecessary nesting and potential deadlocks.
## Individual Comments
### Comment 1
<location> `app/eventyay/api/views/order.py:1252-1253` </location>
<code_context>
+ @transaction.atomic
def refund(self, request, **kwargs):
- payment = self.get_object()
+ # Acquire row-level lock on payment to prevent concurrent refund race conditions
+ payment = OrderPayment.objects.select_for_update().get(pk=self.get_object().pk)
+
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
</code_context>
<issue_to_address>
**suggestion:** Avoid the double query and consider integrating select_for_update with get_object to reduce overhead.
This pattern does two lookups: `self.get_object()` and then `OrderPayment.objects.select_for_update().get(...)`, adding overhead and touching the row once before it’s locked. To avoid this, consider either (a) adding `select_for_update()` in `get_queryset()` when locking is needed, or (b) introducing a `get_locked_object()` helper that reuses `get_object()`’s filtering/permission logic but issues a single `SELECT ... FOR UPDATE` query.
Suggested implementation:
```python
@action(detail=True, methods=['POST'])
@transaction.atomic
def refund(self, request, **kwargs):
# Acquire row-level lock on payment to prevent concurrent refund race conditions.
# We reuse DRF's filtering & permission logic while issuing a single SELECT ... FOR UPDATE.
queryset = self.filter_queryset(self.get_queryset().select_for_update())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
payment = get_object_or_404(queryset, **{self.lookup_field: lookup_value})
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
```
1. Ensure `get_object_or_404` is imported at the top of `app/eventyay/api/views/order.py`, for example:
`from rest_framework.generics import get_object_or_404`
If it is already imported elsewhere in the file, no further import changes are required.
2. This code assumes the view class defines `lookup_field` / `lookup_url_kwarg` in the standard DRF way. If the lookup is customized (e.g., using a different kwarg name or composite key), adjust the lookup construction accordingly.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR fixes a critical race condition vulnerability in the refund endpoint that could allow multiple concurrent refund requests to process more money than the original payment amount, leading to financial loss for event organizers.
Key Changes:
- Added database transaction with row-level locking using
@transaction.atomicandselect_for_update()to prevent concurrent refund processing - Implemented proper TOCTOU (Time-Of-Check-Time-Of-Use) protection by calculating available refund amount under lock
- Added clear documentation comments explaining the race condition prevention mechanism
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Please rebase and update the PR |
b3df2f8 to
4ea2aaf
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
| return self.retrieve(request, [], **kwargs) | ||
|
|
||
| @action(detail=True, methods=['POST']) | ||
| @transaction.atomic |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Putting transaction.atomic here doesn't work, because this refund function already handle an PaymentException:
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:Read the Avoid catching exceptions inside atomic! note.
Fix Double Refund Race Condition Vulnerability
Summary
Fixes a security vulnerability where concurrent refund requests could refund more money than the original payment amount, resulting in direct financial loss to event organizers.
How It Works
refunded_amount, rejects requestTested
fixes #1405
Summary by Sourcery
Prevent concurrent refund race conditions that could allow refunds exceeding the original payment.
Bug Fixes:
Enhancements: