fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key

Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.

- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
  (transport-derived, unspoofable; IPv4-mapped addresses normalized).
  Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
  X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
  (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
  fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
  Used by GetBootstrapScriptFunction (traffic arrives via
  go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-29 00:01:31 +02:00
parent 26bcab9054
commit 6cbd507b4f
3 changed files with 94 additions and 8 deletions
@@ -68,6 +68,26 @@ public class GetBootstrapScriptFunctionTests
{
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
}
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
// someone appended to what AFD set — untrusted, fail closed. ──
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
[Theory]
[InlineData(new[] { Fdid }, true)]
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
[InlineData(new[] { "other-profile-id" }, false)]
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
[InlineData(new[] { "spoofed," + Fdid }, false)]
[InlineData(new string[0], false)]
[InlineData(new[] { "" }, false)]
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
{
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
}
}
/// <summary>
@@ -61,10 +61,12 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
try
{
// Rate limit by source IP (prevent brute-force enumeration of short codes).
// Own bucket, deliberately not shared with bootstrap-validate. On breach the
// response is still a 200 error script (irm | iex must surface the message);
// Retry-After is informational for non-iex clients.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
// Own bucket, deliberately not shared with bootstrap-validate. AFD-aware
// extraction: traffic arrives via go.autopilotmonitor.com, where the trusted
// hop is Front Door egress — X-Azure-ClientIP is honored under FDID proof.
// On breach the response is still a 200 error script (irm | iex must surface
// the message); Retry-After is informational for non-iex clients.
var clientIp = ClientIpExtractor.GetRateLimitClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit($"bootstrap-script:{clientIp}", 20); // 20 req/min
if (!rateLimitResult.IsAllowed)
{
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Security
@@ -26,10 +28,72 @@ namespace AutopilotMonitor.Functions.Security
public static string GetTrustedClientIp(HttpRequestData? req)
{
if (req == null) return Unknown;
if (!req.Headers.TryGetValues("X-Forwarded-For", out var values)) return Unknown;
// If a request carries multiple X-Forwarded-For headers (rare but legal),
// concatenate them so the rightmost entry across all headers wins.
return ExtractTrustedHop(string.Join(",", values));
if (req.Headers.TryGetValues("X-Forwarded-For", out var values))
{
// If a request carries multiple X-Forwarded-For headers (rare but legal),
// concatenate them so the rightmost entry across all headers wins.
var hop = ExtractTrustedHop(string.Join(",", values));
if (hop != Unknown) return hop;
}
// Flex Consumption does not reliably surface X-Forwarded-For to the isolated
// worker (observed 2026-07-28: every anonymous caller keyed as "unknown",
// collapsing all per-IP rate-limit buckets into one). The transport-level
// peer address is unspoofable and equals what the rightmost XFF hop would
// carry when no proxy chain exists; behind a fronting proxy it is that
// proxy's egress — coarse but honest, per the model above.
var remote = req.FunctionContext.GetHttpContext()?.Connection.RemoteIpAddress;
if (remote == null) return Unknown;
if (remote.IsIPv4MappedToIPv6) remote = remote.MapToIPv4();
return remote.ToString();
}
/// <summary>
/// Rate-limit key for endpoints reached through Azure Front Door (e.g.
/// go.autopilotmonitor.com → /api/bootstrap/go). Behind AFD the trusted hop is
/// AFD's egress IP, so all callers would share a handful of buckets — one abuser
/// could starve every legitimate technician. AFD carries the true client IP in
/// X-Azure-ClientIP; it is honored ONLY when the request provably traversed OUR
/// profile: X-Azure-FDID (which AFD overwrites on pass-through, so a client
/// cannot inject it across AFD) must equal the FrontDoorProfileId app setting.
/// Fail-closed: unset setting, missing/mismatched/multi-valued FDID, or empty
/// client IP all fall back to <see cref="GetTrustedClientIp"/>.
/// </summary>
public static string GetRateLimitClientIp(HttpRequestData? req)
{
if (req == null) return Unknown;
var expectedFdid = Environment.GetEnvironmentVariable("FrontDoorProfileId");
if (!string.IsNullOrWhiteSpace(expectedFdid)
&& req.Headers.TryGetValues("X-Azure-FDID", out var fdidValues)
&& FdidMatches(fdidValues, expectedFdid)
&& req.Headers.TryGetValues("X-Azure-ClientIP", out var clientIpValues))
{
var ip = ExtractTrustedHop(string.Join(",", clientIpValues));
if (ip != Unknown) return ip;
}
return GetTrustedClientIp(req);
}
/// <summary>
/// Exactly one FDID value, equal to the configured profile ID. Multiple values
/// mean someone appended to what AFD set — treated as untrusted.
/// </summary>
internal static bool FdidMatches(IEnumerable<string> headerValues, string expected)
{
string? single = null;
foreach (var raw in headerValues)
{
foreach (var part in raw.Split(','))
{
var trimmed = part.Trim();
if (trimmed.Length == 0) continue;
if (single != null) return false;
single = trimmed;
}
}
return single != null && string.Equals(single, expected, StringComparison.OrdinalIgnoreCase);
}
/// <summary>