diff --git a/docs/backend/client-cert-tenant-binding.md b/docs/backend/client-cert-tenant-binding.md index d350fad4..85995df7 100644 --- a/docs/backend/client-cert-tenant-binding.md +++ b/docs/backend/client-cert-tenant-binding.md @@ -10,7 +10,7 @@ tags: - certificates - intune - multi-tenant -timestamp: 2026-08-24T00:00:00+02:00 +timestamp: 2026-08-29T00:00:00+02:00 --- # Problem @@ -129,6 +129,41 @@ Grep marker for every site involved: `CERT-TENANT-BINDING`. The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is unaffected by either stage. +# Certificate transport and proof of possession + +Every stage above validates certificate *content*: chain, validity window, EKU, tenant extension. +None of them proves the caller holds the private key — a certificate is public material (sent in +clear in a TLS 1.2 client-auth handshake, readable from the machine store by any local user, present +on resold hardware). Proof of possession comes from the TLS handshake alone: the platform runs +`clientCertMode=Required` on every path not listed in its `clientCertExclusionPaths`, forwards the +handshake certificate as `X-ARR-ClientCert`, and **strips any inbound copy of that header — on +excluded paths too** (verified 2026-08-29 with a spoofed header against `/api/bootstrap/config`: +the validator saw "No certificate provided"). So `X-ARR-ClientCert` is trustworthy exactly because +the caller cannot write it. + +Two consequences shape `SecurityValidator`: + +* **No other certificate header is ever read.** A former local-dev fallback (`X-Client-Certificate`) + accepted certificate bytes from a client-writable header, which decouples the certificate from + key possession: anyone holding a device's public certificate would authenticate as that device. + Removed 2026-08-29. Local development sets `X-ARR-ClientCert` directly — Core Tools does not strip + it. +* **Bootstrap routes are token-only, fail-closed.** `/api/bootstrap/*` is in the platform's + exclusion list so cert-less OOBE agents can reach it — which also means nothing on those paths + proves a handshake happened. The token is therefore the sole credential there: + `SecurityValidator.IsBootstrapRoute` (path prefix, mirroring the exclusion entry so no function + can forget to opt in) rejects a missing or **empty** `X-Bootstrap-Token`, and a missing + `BootstrapSessionService`, with 401 before the certificate stage can run. The live gap this + closes: the function-level `Headers.Contains` gate accepted an empty header while the validator + treated it as "no token" and fell through to the certificate stage, where the fallback header + above was parsed. Bootstrap tokens are never accepted as a *replacement* for the certificate on + platform-enforced routes either — there the cert requirement is already satisfied by the + handshake before the request reaches code. + +The exclusion list is portal-only state (no IaC). Adding `/api/agent` to it, or moving a +device-authenticated function under an excluded prefix, would silently reopen the header path; +`BootstrapRouteFailClosedTests` pins the code half of this contract. + **Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was issued to this tenant; it does not prove the certificate belongs to a device the tenant still has. `IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the diff --git a/docs/index.md b/docs/index.md index 6e36f426..89602aab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -80,7 +80,7 @@ https://docs.autopilotmonitor.com (separate repository). * [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID. * [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score. -* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry. +* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed). * [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface. * [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections. diff --git a/docs/log.md b/docs/log.md index dbff4c35..5519a1e5 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-29 (7) + +* **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage. + ## 2026-08-29 (6) * **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed). diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs index ba615daa..5fe26aa2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; @@ -99,8 +99,9 @@ public sealed class BootstrapAuthDisabledTenantTests { // Sanity: a non-disabled tenant DOES reach the bootstrap branch (which will then // run its own validation against _bootstrapSessionService). With _bootstrapSessionService - // null in this test, the bootstrap branch falls through to the cert path — proving - // that the §0 gate did NOT block. + // null and no request URL (= not a /api/bootstrap/* route, where that combination is + // rejected fail-closed — see BootstrapRouteFailClosedTests), the branch falls through to + // the platform-cert path — proving that the §0 gate did NOT block. var enabled = TenantConfiguration.CreateDefault(TenantId); enabled.Disabled = false; enabled.ValidateAutopilotDevice = false; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapRouteFailClosedTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapRouteFailClosedTests.cs new file mode 100644 index 00000000..63e539bd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapRouteFailClosedTests.cs @@ -0,0 +1,161 @@ +using System.Net; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Bootstrap routes (/api/bootstrap/*) are excluded from the platform's TLS client-cert +/// requirement, so nothing on those paths proves an mTLS handshake ever happened. The bootstrap +/// token must therefore be the ONLY credential accepted there — a request that reaches the +/// certificate stage on a bootstrap route would authenticate from header bytes alone (a public +/// certificate is replayable without its private key). Live probe 2026-08-29: an empty +/// X-Bootstrap-Token passed the function gate, skipped the token branch and had a +/// client-supplied certificate header parsed. These tests pin the fail-closed behaviour. +/// +public sealed class BootstrapRouteFailClosedTests +{ + private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + private const string BootstrapRoute = "https://example.invalid/api/bootstrap/config"; + private const string AgentRoute = "https://example.invalid/api/agent/telemetry"; + + // A well-formed DER certificate is not needed: what matters is whether the certificate stage + // ran at all, and the "No certificate provided" vs. parse-error details tell the two apart. + private const string JunkCertBase64 = "QUJD"; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task BootstrapRoute_TokenMissingOrEmpty_Rejected401_BeforeCertificateStage(string? token) + { + var sut = BuildValidator(bootstrapService: null); + var req = BuildRequest(BootstrapRoute, token, ("X-ARR-ClientCert", JunkCertBase64)); + + var result = await sut.ValidateRequestAsync(req, TenantId); + + Assert.False(result.IsValid); + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage); + } + + [Fact] + public async Task BootstrapRoute_ClientSuppliedCertificateHeader_NeverParsed() + { + var sut = BuildValidator(bootstrapService: null); + var req = BuildRequest(BootstrapRoute, token: "", ("X-Client-Certificate", JunkCertBase64)); + + var result = await sut.ValidateRequestAsync(req, TenantId); + + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage); + Assert.DoesNotContain("certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AgentRoute_ClientCertificateHeader_IsIgnored() + { + // The removed X-Client-Certificate fallback: on a platform-enforced route the only + // certificate source is X-ARR-ClientCert. A client-writable header must read as "no cert". + var sut = BuildValidator(bootstrapService: null); + var req = BuildRequest(AgentRoute, token: null, ("X-Client-Certificate", JunkCertBase64)); + + var result = await sut.ValidateRequestAsync(req, TenantId); + + Assert.False(result.IsValid); + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("Invalid or missing client certificate", result.ErrorMessage); + Assert.Equal("No certificate provided", result.Details); + } + + [Fact] + public async Task AgentRoute_ArrClientCertHeader_ReachesCertificateStage() + { + // Control: the platform-set header is still the one consulted (junk bytes → parse error, + // proving the certificate stage ran on the supplied value). + var sut = BuildValidator(bootstrapService: null); + var req = BuildRequest(AgentRoute, token: null, ("X-ARR-ClientCert", JunkCertBase64)); + + var result = await sut.ValidateRequestAsync(req, TenantId); + + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("Invalid or missing client certificate", result.ErrorMessage); + Assert.NotEqual("No certificate provided", result.Details); + } + + [Fact] + public void GetBootstrapToken_TreatsEmptyAsAbsent() + { + Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: ""))); + Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: null))); + Assert.Equal("abc", SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "abc"))); + } + + [Theory] + [InlineData("https://example.invalid/api/bootstrap/config", true)] + [InlineData("https://example.invalid/API/Bootstrap/register-session", true)] + [InlineData("https://example.invalid/api/bootstrapx/config", false)] + [InlineData("https://example.invalid/api/agent/telemetry", false)] + public void IsBootstrapRoute_MatchesPlatformExclusionPrefix(string url, bool expected) + { + Assert.Equal(expected, SecurityValidator.IsBootstrapRoute(BuildRequest(url, token: null))); + } + + // ---------------------------------------------------------------- harness + + private static SecurityValidator BuildValidator(BootstrapSessionService? bootstrapService) + { + var configRepo = Mock.Of(); + var cache = new MemoryCache(new MemoryCacheOptions()); + + // AllowInsecureAgentRequests clears the "at least one device validator" gate so agent-route + // requests reach the certificate stage — the stage these tests reason about. + var config = TenantConfiguration.CreateDefault(TenantId); + config.AllowInsecureAgentRequests = true; + + var configServiceMock = new Mock( + configRepo, Mock.Of>(), cache) + { CallBase = false }; + configServiceMock + .Setup(x => x.TryGetConfigurationAsync(It.IsAny())) + .ReturnsAsync((config, true)); + + var adminConfigServiceMock = new Mock( + configRepo, Mock.Of>(), cache) + { CallBase = false }; + adminConfigServiceMock + .Setup(x => x.GetConfigurationAsync()) + .ReturnsAsync(new AdminConfiguration()); + + return new SecurityValidator( + configService: configServiceMock.Object, + adminConfigService: adminConfigServiceMock.Object, + rateLimitService: new RateLimitService(cache, Mock.Of>()), + autopilotDeviceValidator: null!, + corporateIdentifierValidator: null!, + logger: Mock.Of(), + bootstrapSessionService: bootstrapService, + deviceAssociationValidator: null); + } + + private static HttpRequestData BuildRequest(string url, string? token, params (string Name, string Value)[] extraHeaders) + { + var contextMock = new Mock(); + contextMock.SetupGet(c => c.Items).Returns(new Dictionary()); + var reqMock = new Mock(contextMock.Object); + + var headers = new HttpHeadersCollection(); + if (token != null) headers.Add("X-Bootstrap-Token", token); + foreach (var (name, value) in extraHeaders) headers.Add(name, value); + + reqMock.SetupGet(r => r.Headers).Returns(headers); + reqMock.SetupGet(r => r.Url).Returns(new Uri(url)); + return reqMock.Object; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs index 097c1769..520466a1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Functions.Security; @@ -53,8 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap { try { - // Bootstrap-only: reject requests without X-Bootstrap-Token - if (!req.Headers.Contains("X-Bootstrap-Token")) + // Bootstrap-only: reject requests without a (non-empty) X-Bootstrap-Token before any + // parsing. SecurityValidator enforces the same rule fail-closed for every + // /api/bootstrap/* route; this is only the cheap early exit. + if (SecurityValidator.GetBootstrapToken(req) == null) { var noToken = req.CreateResponse(HttpStatusCode.Unauthorized); await noToken.WriteAsJsonAsync(new { error = "X-Bootstrap-Token header is required" }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs index 900d85bc..332671b5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using AutopilotMonitor.Functions.Functions.Sessions; using AutopilotMonitor.Functions.Security; @@ -56,8 +56,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap { try { - // Bootstrap-only: reject requests without X-Bootstrap-Token - if (!req.Headers.Contains("X-Bootstrap-Token")) + // Bootstrap-only: reject requests without a (non-empty) X-Bootstrap-Token before any + // parsing. SecurityValidator enforces the same rule fail-closed for every + // /api/bootstrap/* route; this is only the cheap early exit. + if (SecurityValidator.GetBootstrapToken(req) == null) { var noToken = req.CreateResponse(HttpStatusCode.Unauthorized); await noToken.WriteAsJsonAsync(new { success = false, message = "X-Bootstrap-Token header is required" }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs index be615c6e..46c3cafc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Security; @@ -53,8 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap { try { - // Bootstrap-only: reject requests without X-Bootstrap-Token - if (!req.Headers.Contains("X-Bootstrap-Token")) + // Bootstrap-only: reject requests without a (non-empty) X-Bootstrap-Token before any + // parsing. SecurityValidator enforces the same rule fail-closed for every + // /api/bootstrap/* route; this is only the cheap early exit. + if (SecurityValidator.GetBootstrapToken(req) == null) { var noToken = req.CreateResponse(HttpStatusCode.Unauthorized); await noToken.WriteAsJsonAsync(new { error = "X-Bootstrap-Token header is required" }); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs index 29aa07df..631f5e10 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs @@ -86,7 +86,7 @@ namespace AutopilotMonitor.Functions.Security /// /// Validates a client certificate from HTTP request header /// - /// Base64-encoded certificate from X-Client-Certificate header + /// Base64-encoded certificate from the platform-set X-ARR-ClientCert header /// Logger for diagnostic output /// Validation result with certificate details public static CertificateValidationResult ValidateCertificate(string? certificateBase64, ILogger? logger = null) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index f4174a60..9ef293f2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -41,6 +41,32 @@ namespace AutopilotMonitor.Functions.Security throw new ArgumentException($"Invalid {parameterName} format. Expected a valid GUID.", parameterName); } + /// + /// Route prefix (below the host's api route prefix) of the token-only bootstrap + /// endpoints. Mirrors the /api/bootstrap entry of the Function App's + /// clientCertExclusionPaths: requests under it never carry a platform-verified certificate. + /// + public const string BootstrapRoutePrefix = "/api/bootstrap/"; + + /// + /// True when the request targets a bootstrap route (), + /// where the bootstrap token is the sole accepted credential. + /// + public static bool IsBootstrapRoute(HttpRequestData req) + => req.Url?.AbsolutePath?.StartsWith(BootstrapRoutePrefix, StringComparison.OrdinalIgnoreCase) == true; + + /// + /// The X-Bootstrap-Token header value, or null when absent or empty. Callers must treat an + /// empty header exactly like a missing one — header presence proves nothing. + /// + public static string? GetBootstrapToken(HttpRequestData req) + { + var value = req.Headers.Contains("X-Bootstrap-Token") + ? req.Headers.GetValues("X-Bootstrap-Token").FirstOrDefault() + : null; + return string.IsNullOrWhiteSpace(value) ? null : value; + } + private readonly TenantConfigurationService _configService; private readonly AdminConfigurationService _adminConfigService; private readonly RateLimitService _rateLimitService; @@ -138,9 +164,30 @@ namespace AutopilotMonitor.Functions.Security // If the agent sends an X-Bootstrap-Token header, validate it and skip all other checks // (cert, rate limit, hardware, device validation). This enables agents started before // Intune enrollment to authenticate using a time-limited token generated by the admin. - var bootstrapTokenHeader = req.Headers.Contains("X-Bootstrap-Token") - ? req.Headers.GetValues("X-Bootstrap-Token").FirstOrDefault() - : null; + var bootstrapTokenHeader = GetBootstrapToken(req); + + // Bootstrap routes (/api/bootstrap/*) are excluded from the platform's TLS client-cert + // requirement so cert-less OOBE agents can reach them. That makes the token the ONLY + // authority there: the certificate stage below must never run for these routes, because + // on an excluded path nothing proves the caller ever completed an mTLS handshake. + // Fail closed on a missing/empty token or a missing token service instead of falling + // through. Derived from the request path — the same prefix the platform excludes — so + // no function can forget to opt in. + if (IsBootstrapRoute(req) && (bootstrapTokenHeader == null || _bootstrapSessionService == null)) + { + if (_bootstrapSessionService == null) + _logger.LogError("Rejected bootstrap request for tenant {TenantId}: BootstrapSessionService is not wired", tenantId); + else + _logger.LogWarning("Rejected bootstrap request for tenant {TenantId}: X-Bootstrap-Token header missing or empty", tenantId); + + return new SecurityValidationResult + { + IsValid = false, + StatusCode = HttpStatusCode.Unauthorized, + ErrorMessage = "X-Bootstrap-Token header is required", + Details = "Bootstrap endpoints authenticate with a bootstrap token only." + }; + } if (!string.IsNullOrEmpty(bootstrapTokenHeader) && _bootstrapSessionService != null) { @@ -253,15 +300,16 @@ namespace AutopilotMonitor.Functions.Security } // 1. Validate client certificate - // Production: Azure App Service mTLS forwards client cert in X-ARR-ClientCert header. - // Local dev fallback: X-Client-Certificate header for testing with Azure Functions Core Tools - // (which does not support mTLS). This fallback never triggers in production because - // requests without a TLS client cert are rejected by Azure before reaching the function. + // The platform terminates mTLS (clientCertMode=Required on every non-excluded path) and + // forwards the handshake certificate as X-ARR-ClientCert; it strips any inbound copy of + // that header, including on excluded paths (verified 2026-08-29). The TLS handshake is the + // proof that the caller holds the private key — the certificate bytes alone are public + // and would be replayable. That is why NO other header is ever accepted here: a + // client-writable header decouples the certificate from key possession. Local + // development sets X-ARR-ClientCert directly (Core Tools does not strip it). var certHeader = req.Headers.Contains("X-ARR-ClientCert") ? req.Headers.GetValues("X-ARR-ClientCert").FirstOrDefault() - : req.Headers.Contains("X-Client-Certificate") - ? req.Headers.GetValues("X-Client-Certificate").FirstOrDefault() - : null; + : null; var certValidation = CertificateValidator.ValidateCertificate(certHeader, _logger); if (!certValidation.IsValid)