mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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" });
|
||||
|
||||
+5
-3
@@ -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" });
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user