mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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>
|
||||
|
||||
+6
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user