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).
This commit is contained in:
daniel-c-harvey
2026-06-19 13:09:21 -04:00
parent dbd90ee52a
commit 622ee940f4
2 changed files with 25 additions and 2 deletions
@@ -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);
@@ -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)
{