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:
@@ -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
|
// 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
|
// 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.
|
// 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)
|
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, 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);
|
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;
|
HttpResponseMessage upstream;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
upstream = await _upstream.PostAsync(upstreamPath, content, ct);
|
upstream = await _upstream.SendAsync(request, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user