Skip to content

Commit 2232bb1

Browse files
committed
Security: add expiry check to atomic consume, use raw SQL for cleanup
TryConsumeAtomicAsync now includes ExpiresAt > now in the WHERE clause to close the TOCTOU race window between application-level expiry check and SQL execution. DeleteExpiredAsync now uses raw SQL instead of loading all rows into memory (DoS prevention). Also deletes consumed codes to prevent unbounded table growth. Uses EF Core SQLite DateTimeOffset format for correct string comparison. Addresses findings #2 (CRITICAL), #4 (HIGH), #6 (HIGH), #13 (LOW).
1 parent 8fb927a commit 2232bb1

File tree

1 file changed

+18
-18
lines changed

1 file changed

+18
-18
lines changed

backend/src/Taskdeck.Infrastructure/Repositories/OAuthAuthCodeRepository.cs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,32 @@ public OAuthAuthCodeRepository(TaskdeckDbContext context) : base(context)
2020
public async Task<bool> TryConsumeAtomicAsync(string code, CancellationToken cancellationToken = default)
2121
{
2222
// Atomic UPDATE ensures only one concurrent request can consume a code.
23-
// The WHERE clause filters on IsConsumed = 0 so the second requester gets 0 affected rows.
23+
// The WHERE clause enforces ALL invariants atomically:
24+
// - IsConsumed = 0: single-use semantics (second requester gets 0 rows)
25+
// - ExpiresAt > now: prevents TOCTOU race where expiry passes between
26+
// the application-level check and the SQL execution
27+
//
28+
// EF Core SQLite stores DateTimeOffset as "yyyy-MM-dd HH:mm:ss.fffffff+HH:mm" format.
29+
// We must use the same format for string comparison to work correctly.
2430
var now = DateTimeOffset.UtcNow;
31+
var nowStr = now.ToString("yyyy-MM-dd HH:mm:ss.fffffff+00:00");
2532
var affected = await _context.Database.ExecuteSqlRawAsync(
26-
"UPDATE OAuthAuthCodes SET IsConsumed = 1, ConsumedAt = {0}, UpdatedAt = {1} WHERE Code = {2} AND IsConsumed = 0",
27-
now.ToString("o"),
28-
now.ToString("o"),
29-
code);
33+
"UPDATE OAuthAuthCodes SET IsConsumed = 1, ConsumedAt = {0}, UpdatedAt = {1} WHERE Code = {2} AND IsConsumed = 0 AND ExpiresAt > {3}",
34+
nowStr, nowStr, code, nowStr);
3035

3136
return affected > 0;
3237
}
3338

3439
public async Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
3540
{
36-
// EF Core 8 with SQLite cannot translate DateTimeOffset comparisons in LINQ
37-
// queries. Load all codes and filter in memory for cleanup.
38-
// Auth codes are short-lived and few in number, so this is acceptable.
39-
var allCodes = await _context.Set<OAuthAuthCode>()
40-
.ToListAsync(cancellationToken);
41-
42-
var expired = allCodes.Where(e => e.ExpiresAt < cutoff).ToList();
43-
44-
if (expired.Count == 0)
45-
return 0;
41+
// Use raw SQL to avoid loading all rows into memory (DoS risk with large tables).
42+
// Deletes both expired codes AND consumed codes to prevent unbounded table growth.
43+
// EF Core SQLite stores DateTimeOffset as "yyyy-MM-dd HH:mm:ss.fffffff+HH:mm".
44+
var cutoffStr = cutoff.ToString("yyyy-MM-dd HH:mm:ss.fffffff+00:00");
45+
var affected = await _context.Database.ExecuteSqlRawAsync(
46+
"DELETE FROM OAuthAuthCodes WHERE ExpiresAt < {0} OR IsConsumed = 1",
47+
cutoffStr);
4648

47-
_context.Set<OAuthAuthCode>().RemoveRange(expired);
48-
await _context.SaveChangesAsync(cancellationToken);
49-
return expired.Count;
49+
return affected;
5050
}
5151
}

0 commit comments

Comments
 (0)