diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetBootstrapScriptFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetBootstrapScriptFunctionTests.cs index a5d58033..e13abe1e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetBootstrapScriptFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetBootstrapScriptFunctionTests.cs @@ -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)); + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs index 3f59c567..42eb104d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs @@ -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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Security/ClientIpExtractor.cs b/src/Backend/AutopilotMonitor.Functions/Security/ClientIpExtractor.cs index 3e381a23..15a89c9a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/ClientIpExtractor.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/ClientIpExtractor.cs @@ -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(); + } + + /// + /// 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 . + /// + 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); + } + + /// + /// Exactly one FDID value, equal to the configured profile ID. Multiple values + /// mean someone appended to what AFD set — treated as untrusted. + /// + internal static bool FdidMatches(IEnumerable 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); } ///