fix(security): drop client-writable certificate header; bootstrap routes token-only, fail-closed

SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was
absent. That header is client-writable, and certificate content alone carries no proof of private-key
possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable,
but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token
passed the function gate (Headers.Contains) while the validator treated it as no token and fell
through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29).

- Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths.
- IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired
  BootstrapSessionService => 401 before the certificate stage.
- GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it.
- Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-29 20:04:04 +02:00
parent 250cc667dd
commit c18de5d76c
10 changed files with 280 additions and 25 deletions
+36 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+4
View File
@@ -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).
@@ -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;
@@ -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;
/// <summary>
/// Bootstrap routes (<c>/api/bootstrap/*</c>) 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
/// <c>X-Bootstrap-Token</c> passed the function gate, skipped the token branch and had a
/// client-supplied certificate header parsed. These tests pin the fail-closed behaviour.
/// </summary>
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<IConfigRepository>();
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<TenantConfigurationService>(
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
configServiceMock
.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((config, true));
var adminConfigServiceMock = new Mock<AdminConfigurationService>(
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), 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<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!,
corporateIdentifierValidator: null!,
logger: Mock.Of<ILogger>(),
bootstrapSessionService: bootstrapService,
deviceAssociationValidator: null);
}
private static HttpRequestData BuildRequest(string url, string? token, params (string Name, string Value)[] extraHeaders)
{
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
contextMock.SetupGet(c => c.Items).Returns(new Dictionary<object, object>());
var reqMock = new Mock<HttpRequestData>(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;
}
}
@@ -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" });
@@ -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" });
@@ -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" });
@@ -86,7 +86,7 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Validates a client certificate from HTTP request header
/// </summary>
/// <param name="certificateBase64">Base64-encoded certificate from X-Client-Certificate header</param>
/// <param name="certificateBase64">Base64-encoded certificate from the platform-set X-ARR-ClientCert header</param>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>Validation result with certificate details</returns>
public static CertificateValidationResult ValidateCertificate(string? certificateBase64, ILogger? logger = null)
@@ -41,6 +41,32 @@ namespace AutopilotMonitor.Functions.Security
throw new ArgumentException($"Invalid {parameterName} format. Expected a valid GUID.", parameterName);
}
/// <summary>
/// Route prefix (below the host's <c>api</c> route prefix) of the token-only bootstrap
/// endpoints. Mirrors the <c>/api/bootstrap</c> entry of the Function App's
/// clientCertExclusionPaths: requests under it never carry a platform-verified certificate.
/// </summary>
public const string BootstrapRoutePrefix = "/api/bootstrap/";
/// <summary>
/// True when the request targets a bootstrap route (<see cref="BootstrapRoutePrefix"/>),
/// where the bootstrap token is the sole accepted credential.
/// </summary>
public static bool IsBootstrapRoute(HttpRequestData req)
=> req.Url?.AbsolutePath?.StartsWith(BootstrapRoutePrefix, StringComparison.OrdinalIgnoreCase) == true;
/// <summary>
/// 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.
/// </summary>
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)