From 622ee940f4c1a871774b9a22465c20a4ee4b43de Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 19 Jun 2026 13:09:21 -0400 Subject: [PATCH] fix(phase-16): forward X-Forwarded-For from EventProxyController so the API rate limiter partitions per client IP Proxy chains any inbound XFF with the connection IP before relaying upstream; UseForwardedHeaders resolves it to the limiter's partition key. Documents the EventRepository first-play counter race (unique index is the backstop). --- DeepDrftData/Repositories/EventRepository.cs | 6 ++++++ .../Controllers/EventProxyController.cs | 21 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/DeepDrftData/Repositories/EventRepository.cs b/DeepDrftData/Repositories/EventRepository.cs index 75dff45..e61ef99 100644 --- a/DeepDrftData/Repositories/EventRepository.cs +++ b/DeepDrftData/Repositories/EventRepository.cs @@ -102,6 +102,12 @@ public class EventRepository // Bump the matching bucket column on the track's counter row, creating the row on first play. The // row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists // it inside the same transaction as the event append. + // + // Race note: two concurrent first-plays of the same track can both reach this method, find no + // counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on + // (track_id) and throw, causing the outer transaction to roll back and the event to be dropped — + // no crash, no counter corruption. At the expected play volume this is an acceptable loss; the + // unique index is the integrity backstop. private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct) { var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct); diff --git a/DeepDrftPublic/Controllers/EventProxyController.cs b/DeepDrftPublic/Controllers/EventProxyController.cs index 0724e48..e367dea 100644 --- a/DeepDrftPublic/Controllers/EventProxyController.cs +++ b/DeepDrftPublic/Controllers/EventProxyController.cs @@ -47,12 +47,29 @@ public class EventProxyController : ControllerBase body = await reader.ReadToEndAsync(ct); } - using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, upstreamPath) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + // Forward the real client IP so DeepDrftAPI's per-IP rate limiter (Program.cs "events" policy) + // partitions on individual listeners rather than the proxy host. Standard XFF chaining: relay + // any inbound X-Forwarded-For from an upstream proxy (nginx), then append the connection IP + // of the current hop (the browser → public host connection). DeepDrftAPI calls + // UseForwardedHeaders() in production, which resolves the leftmost untrusted value in the + // chain into Connection.RemoteIpAddress — which the rate limiter then keys on. + var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + if (clientIp is not null) + { + var existing = Request.Headers["X-Forwarded-For"].ToString(); + var xff = string.IsNullOrEmpty(existing) ? clientIp : $"{existing}, {clientIp}"; + request.Headers.TryAddWithoutValidation("X-Forwarded-For", xff); + } HttpResponseMessage upstream; try { - upstream = await _upstream.PostAsync(upstreamPath, content, ct); + upstream = await _upstream.SendAsync(request, ct); } catch (Exception ex) {