mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
6a85f45604
SecurityValidator stamps ValidatedTenantId once the certificate tenant binding (or the bootstrap token) proves the tenant, and DeviceValidation once the device-validation chain has run: the admitting ValidatorType name, None for a tenant without a validator, Transient for the 503, Rejected for the 403 of stage device. RequestTelemetryMiddleware resolves the row's TenantId in one place (policy tenant, proven tenant, then the X-Tenant-Id header only on the routes that validate it) and copies DeviceValidation as a dimension. Register, config and upload-url rows carried no TenantId before, so tenant-filtered KQL missed exactly the calls that run the validation, and the validators' success line is Information-level and never reaches App Insights. Proving which validator admitted a device is now one query. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
856 lines
44 KiB
C#
856 lines
44 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Security
|
|
{
|
|
/// <summary>
|
|
/// Centralized security validation for all API requests
|
|
/// Performs certificate validation, rate limiting, and hardware whitelisting
|
|
/// </summary>
|
|
public class SecurityValidator
|
|
{
|
|
private static readonly Regex GuidRegex = new Regex(
|
|
@"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
|
RegexOptions.Compiled);
|
|
|
|
/// <summary>
|
|
/// Validates that a string is a valid GUID format.
|
|
/// Use this to prevent OData filter injection in Table Storage queries.
|
|
/// </summary>
|
|
public static bool IsValidGuid(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return false;
|
|
|
|
return Guid.TryParse(value, out _) && GuidRegex.IsMatch(value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that a value is a valid GUID and throws if not.
|
|
/// </summary>
|
|
public static void EnsureValidGuid(string? value, string parameterName)
|
|
{
|
|
if (!IsValidGuid(value))
|
|
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;
|
|
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
|
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
|
private readonly DeviceAssociationValidator? _deviceAssociationValidator;
|
|
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
|
|
private readonly IntuneDeviceBindingValidator? _intuneDeviceBindingValidator;
|
|
private readonly BootstrapSessionService? _bootstrapSessionService;
|
|
private readonly ILogger _logger;
|
|
|
|
public SecurityValidator(
|
|
TenantConfigurationService configService,
|
|
AdminConfigurationService adminConfigService,
|
|
RateLimitService rateLimitService,
|
|
AutopilotDeviceValidator autopilotDeviceValidator,
|
|
CorporateIdentifierValidator corporateIdentifierValidator,
|
|
ILogger logger,
|
|
BootstrapSessionService? bootstrapSessionService = null,
|
|
DeviceAssociationValidator? deviceAssociationValidator = null,
|
|
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
|
|
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
|
|
{
|
|
_configService = configService;
|
|
_adminConfigService = adminConfigService;
|
|
_rateLimitService = rateLimitService;
|
|
_autopilotDeviceValidator = autopilotDeviceValidator;
|
|
_corporateIdentifierValidator = corporateIdentifierValidator;
|
|
_deviceAssociationValidator = deviceAssociationValidator;
|
|
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
|
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
|
_bootstrapSessionService = bootstrapSessionService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the effective device (agent/cert) rate limit for a tenant:
|
|
/// the per-tenant override if set, otherwise the global default raised to the tenant
|
|
/// edition's entitlement floor (Pro: 150/min — see FeatureEntitlementCatalog).
|
|
/// The global AdminConfiguration read is served from a 5-minute in-memory cache.
|
|
/// </summary>
|
|
private async Task<int> ResolveDeviceRateLimitAsync(TenantConfiguration config)
|
|
{
|
|
// The global read is served from a 5-minute in-memory cache (O(1) dictionary hit on the
|
|
// hot path), so we always take it rather than branching on the override.
|
|
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
|
// Edition resolves purely from the config already in hand — no extra I/O on the
|
|
// agent hot path. Fail-closed by construction (unknown tier → Community → null floor).
|
|
var entitlementFloor = FeatureEntitlementCatalog
|
|
.Get(Services.TenantEntitlementService.Resolve(config, DateTime.UtcNow))
|
|
.DeviceRateLimitPerMinute;
|
|
return RateLimitResolver.ResolveDeviceLimit(
|
|
config.CustomRateLimitRequestsPerMinute,
|
|
adminConfig.GlobalRateLimitRequestsPerMinute,
|
|
entitlementFloor);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates request security (certificate, rate limit, hardware whitelist)
|
|
/// </summary>
|
|
/// <param name="req">HTTP request</param>
|
|
/// <param name="tenantId">Tenant ID for configuration lookup</param>
|
|
/// <returns>Security validation result</returns>
|
|
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null)
|
|
{
|
|
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
|
|
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
|
|
|
if (!tenantExists)
|
|
{
|
|
_logger.LogWarning("Rejected agent request: unknown tenant {TenantId}", tenantId);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Tenant not registered",
|
|
Details = "This tenant ID is not registered with the platform."
|
|
};
|
|
}
|
|
|
|
if (config.IsCurrentlyDisabled())
|
|
{
|
|
_logger.LogWarning("Rejected agent request: suspended tenant {TenantId} (reason: {Reason})",
|
|
tenantId, config.DisabledReason);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Tenant is suspended",
|
|
Details = config.DisabledReason ?? "This tenant has been suspended. Contact support."
|
|
};
|
|
}
|
|
|
|
// 0.5 Bootstrap token gate (pre-MDM auth for OOBE bootstrapped agents)
|
|
// 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 = 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)
|
|
{
|
|
// SECURITY: Bootstrap tokens are always GUIDs. Reject non-GUID values
|
|
// to prevent OData filter injection in the token lookup query.
|
|
if (!IsValidGuid(bootstrapTokenHeader))
|
|
{
|
|
_logger.LogWarning("Rejected agent request: bootstrap token is not a valid GUID format");
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Unauthorized,
|
|
ErrorMessage = "Invalid bootstrap token format",
|
|
Details = "Bootstrap token must be a valid GUID."
|
|
};
|
|
}
|
|
|
|
var bootstrapSession = await _bootstrapSessionService.ValidateTokenAsync(bootstrapTokenHeader);
|
|
if (bootstrapSession == null)
|
|
{
|
|
_logger.LogWarning("Rejected agent request: invalid or expired bootstrap token");
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Unauthorized,
|
|
ErrorMessage = "Invalid or expired bootstrap token",
|
|
Details = "Bootstrap token not found, expired, or revoked."
|
|
};
|
|
}
|
|
|
|
// Verify the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
|
if (!Services.TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
|
{
|
|
_logger.LogWarning("Rejected bootstrap token: feature disabled for tenant {TenantId}", tenantId);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Bootstrap token feature is not enabled for this tenant"
|
|
};
|
|
}
|
|
|
|
// Verify the token's tenant matches the request tenant
|
|
if (!string.Equals(bootstrapSession.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogWarning("Bootstrap token tenant mismatch: token={TokenTenant}, request={RequestTenant}",
|
|
bootstrapSession.TenantId, tenantId);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Bootstrap token does not match tenant"
|
|
};
|
|
}
|
|
|
|
// Read hardware headers (informational, not enforced for bootstrap auth)
|
|
var bsManufacturer = req.Headers.Contains("X-Device-Manufacturer")
|
|
? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault() : null;
|
|
var bsModel = req.Headers.Contains("X-Device-Model")
|
|
? req.Headers.GetValues("X-Device-Model").FirstOrDefault() : null;
|
|
var bsSerial = req.Headers.Contains("X-Device-SerialNumber")
|
|
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() : null;
|
|
|
|
_logger.LogInformation("Bootstrap token auth accepted for tenant {TenantId} (code {ShortCode})",
|
|
tenantId, bootstrapSession.ShortCode);
|
|
|
|
// Rate limit check for bootstrap auth (DoS protection)
|
|
var bsRateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
|
var bsRateLimitResult = _rateLimitService.CheckRateLimit(
|
|
$"bootstrap:{bootstrapTokenHeader}",
|
|
bsRateLimitValue
|
|
);
|
|
|
|
if (!bsRateLimitResult.IsAllowed)
|
|
{
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.TooManyRequests,
|
|
ErrorMessage = "Rate limit exceeded",
|
|
RateLimitResult = bsRateLimitResult
|
|
};
|
|
}
|
|
|
|
RequestRowMarkers.Stamp(req, RequestRowMarkers.ValidatedTenantKey, tenantId);
|
|
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
|
RequestRowMarkers.DeviceValidationValue(ValidatorType.Bootstrap));
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = true,
|
|
IsBootstrapAuth = true,
|
|
BootstrapShortCode = bootstrapSession.ShortCode,
|
|
Manufacturer = bsManufacturer,
|
|
Model = bsModel,
|
|
SerialNumber = bsSerial,
|
|
RateLimitResult = bsRateLimitResult,
|
|
ValidatedBy = ValidatorType.Bootstrap
|
|
};
|
|
}
|
|
|
|
// Security validation is always enforced (no longer configurable per tenant)
|
|
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
|
|
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
|
|
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateDeviceAssociation && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
|
|
{
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Device validation is required",
|
|
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation', 'Device Association Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
|
|
};
|
|
}
|
|
|
|
// 1. Validate client certificate
|
|
// 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()
|
|
: null;
|
|
|
|
var certValidation = CertificateValidator.ValidateCertificate(certHeader, _logger);
|
|
if (!certValidation.IsValid)
|
|
{
|
|
LogRequestRejection("cert", tenantId, req, sessionId,
|
|
extraReason: certValidation.ErrorMessage,
|
|
thumbprint: certValidation.Thumbprint);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Unauthorized,
|
|
ErrorMessage = "Invalid or missing client certificate",
|
|
Details = certValidation.ErrorMessage
|
|
};
|
|
}
|
|
|
|
// 1b. CERT-TENANT-BINDING — the certificate must belong to THIS tenant.
|
|
// The chain is pinned to Microsoft's Intune roots, which every Intune tenant shares, so a
|
|
// valid cert alone only proves "issued to some tenant". Without this gate an attacker
|
|
// with their own Intune tenant and a known serial number of the victim tenant walks
|
|
// straight through, because device serials are printed on the chassis.
|
|
var certTenantOutcome = EvaluateCertTenantBinding(certValidation, tenantId, req, sessionId);
|
|
if (CertTenantBinding.Rejects(certTenantOutcome))
|
|
{
|
|
LogRequestRejection("certtenant", tenantId, req, sessionId,
|
|
extraReason: $"certificate belongs to tenant {certValidation.CertTenantId}",
|
|
thumbprint: certValidation.Thumbprint);
|
|
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Certificate does not belong to this tenant",
|
|
// Deliberately does not echo which tenant the certificate belongs to: that is
|
|
// the caller's own value on a genuine mismatch, and telling an attacker how the
|
|
// comparison landed helps them. The full pair is in the rejection log.
|
|
Details = "The client certificate was issued to a different Microsoft Entra tenant than this request targets."
|
|
};
|
|
}
|
|
|
|
// From here on the tenant is proven (known, not suspended, certificate issued to it).
|
|
// Register / config / upload-url take the tenant from body or query, not from a
|
|
// validated header, so this stamp is what gives their request rows a TenantId.
|
|
RequestRowMarkers.Stamp(req, RequestRowMarkers.ValidatedTenantKey, tenantId);
|
|
|
|
// 2. Check rate limit (DoS protection)
|
|
// Effective limit = per-tenant override if set, otherwise the global default.
|
|
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
|
|
|
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
|
certValidation.Thumbprint!,
|
|
rateLimitValue
|
|
);
|
|
|
|
if (!rateLimitResult.IsAllowed)
|
|
{
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.TooManyRequests,
|
|
ErrorMessage = "Rate limit exceeded",
|
|
RateLimitResult = rateLimitResult
|
|
};
|
|
}
|
|
|
|
// 3. Validate hardware whitelist
|
|
var manufacturer = req.Headers.Contains("X-Device-Manufacturer")
|
|
? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault()
|
|
: null;
|
|
|
|
var model = req.Headers.Contains("X-Device-Model")
|
|
? req.Headers.GetValues("X-Device-Model").FirstOrDefault()
|
|
: null;
|
|
|
|
var hardwareValidation = HardwareWhitelistValidator.ValidateHardware(
|
|
manufacturer,
|
|
model,
|
|
config.GetManufacturerWhitelist(),
|
|
config.GetModelWhitelist(),
|
|
_logger
|
|
);
|
|
|
|
if (!hardwareValidation.IsValid)
|
|
{
|
|
LogRequestRejection("hardware", tenantId, req, sessionId,
|
|
extraReason: hardwareValidation.ErrorMessage,
|
|
thumbprint: certValidation.Thumbprint);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Hardware not authorized",
|
|
Details = hardwareValidation.ErrorMessage
|
|
};
|
|
}
|
|
|
|
// 4. Validate device registration (Autopilot and/or Corporate Identifier)
|
|
string? serialNumber = req.Headers.Contains("X-Device-SerialNumber")
|
|
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
|
: null;
|
|
string? autopilotDeviceId = null;
|
|
bool deviceValidated = false;
|
|
bool deviceValidationTransient = false;
|
|
// Largest Retry-After any transient validator asked for (GraphAuthFailure: a fresh
|
|
// token that still lacks the permission wants 120 s, not the 30-s default).
|
|
int? deviceValidationRetryAfter = null;
|
|
string? deviceValidationError = null;
|
|
ValidatorType validatedBy = ValidatorType.Unknown;
|
|
|
|
// One time budget for the whole (sequential) validator chain — see
|
|
// DeviceValidationBudget: a stuck Graph call must not park the request past the
|
|
// agent's own client timeout. Budget exhaustion surfaces as a transient → 503 below.
|
|
using var validationBudget = DeviceValidationBudget.CreateChainCts();
|
|
var validationCt = validationBudget.Token;
|
|
|
|
if (config.ValidateAutopilotDevice)
|
|
{
|
|
var autopilotResult = await _autopilotDeviceValidator.ValidateAutopilotDeviceAsync(tenantId, serialNumber, sessionId, validationCt);
|
|
if (autopilotResult.IsValid)
|
|
{
|
|
deviceValidated = true;
|
|
autopilotDeviceId = autopilotResult.AutopilotDeviceId;
|
|
validatedBy = ValidatorType.AutopilotV1;
|
|
}
|
|
else
|
|
{
|
|
deviceValidationError = autopilotResult.ErrorMessage;
|
|
deviceValidationTransient = autopilotResult.IsTransient;
|
|
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, autopilotResult.RetryAfterSeconds);
|
|
}
|
|
}
|
|
|
|
if (!deviceValidated && config.ValidateCorporateIdentifier)
|
|
{
|
|
var corpResult = await _corporateIdentifierValidator.ValidateAsync(tenantId, manufacturer, model, serialNumber, sessionId, validationCt);
|
|
if (corpResult.IsValid)
|
|
{
|
|
deviceValidated = true;
|
|
validatedBy = ValidatorType.CorporateIdentifier;
|
|
}
|
|
else
|
|
{
|
|
// Accumulate instead of overwrite: with several validators enabled, only the
|
|
// LAST failure used to surface in the 403 Details / rejection log, hiding why
|
|
// the earlier (usually more relevant) lookups missed. Transient is sticky-OR:
|
|
// if ANY validator failed transiently the device might actually be authorized,
|
|
// so the caller must return 503 Retry-After rather than a definitive 403.
|
|
deviceValidationError = CombineValidationErrors(deviceValidationError, corpResult.ErrorMessage);
|
|
deviceValidationTransient |= corpResult.IsTransient;
|
|
}
|
|
}
|
|
|
|
// Autopilot device preparation "Device association" (GA since 2026-08-25): the device
|
|
// was pre-associated to this tenant in Intune (Devices > Enrollment > Device association)
|
|
// and Intune marks it corporate-owned itself — no corporate identifier upload exists for
|
|
// it, so the two lookups above miss. Same serial-based contract and resilience as the
|
|
// Autopilot lookup (30/5 min cache, transient → 503 below).
|
|
if (!deviceValidated && config.ValidateDeviceAssociation && _deviceAssociationValidator != null)
|
|
{
|
|
var associationResult = await _deviceAssociationValidator.LookupAsync(tenantId, serialNumber, sessionId, validationCt);
|
|
if (associationResult.IsValid)
|
|
{
|
|
deviceValidated = true;
|
|
validatedBy = ValidatorType.DeviceAssociation;
|
|
}
|
|
else
|
|
{
|
|
deviceValidationError = CombineValidationErrors(deviceValidationError, associationResult.ErrorMessage);
|
|
deviceValidationTransient |= associationResult.IsTransient;
|
|
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, associationResult.RetryAfterSeconds);
|
|
}
|
|
}
|
|
|
|
// W365 fallback: Cloud PCs are structurally never Autopilot-registered, so the serial
|
|
// lookups above always miss for them. Identity here comes from the chain-validated
|
|
// client certificate, NOT from spoofable headers: the Subject CN carries the Intune
|
|
// device id, and only a Windows-365-service-provisioned machine has a cloudPC object
|
|
// with that managedDeviceId — a regular Intune-enrolled (non-Autopilot) device still
|
|
// ends in the 403 below.
|
|
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
|
|
{
|
|
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
|
|
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId, validationCt);
|
|
if (cloudPcResult.IsValid)
|
|
{
|
|
deviceValidated = true;
|
|
validatedBy = ValidatorType.CloudPc;
|
|
}
|
|
else
|
|
{
|
|
deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage);
|
|
deviceValidationTransient |= cloudPcResult.IsTransient;
|
|
}
|
|
}
|
|
|
|
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateDeviceAssociation || config.ValidateCloudPcDevice) && !deviceValidated)
|
|
{
|
|
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
|
|
// Definitive failures (device not registered) → 403 Forbidden
|
|
if (deviceValidationTransient)
|
|
{
|
|
_logger.LogWarning(
|
|
"Device validation transient failure for tenant {TenantId}, serial {SerialNumber}. Returning 503 Retry-After.",
|
|
tenantId, serialNumber);
|
|
|
|
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey, RequestRowMarkers.DeviceValidation.Transient);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.ServiceUnavailable,
|
|
ErrorMessage = "Device validation temporarily unavailable",
|
|
Details = deviceValidationError,
|
|
RetryAfterSeconds = deviceValidationRetryAfter ?? 30
|
|
};
|
|
}
|
|
|
|
LogRequestRejection("device", tenantId, req, sessionId,
|
|
extraReason: deviceValidationError,
|
|
thumbprint: certValidation.Thumbprint);
|
|
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey, RequestRowMarkers.DeviceValidation.Rejected);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = false,
|
|
StatusCode = HttpStatusCode.Forbidden,
|
|
ErrorMessage = "Device not registered",
|
|
Details = deviceValidationError
|
|
};
|
|
}
|
|
|
|
// 5. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
|
|
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
|
|
// proves the specific device is one the tenant actually enrolled, so a certificate
|
|
// lifted from a decommissioned machine stops resolving. Observation only: it never
|
|
// touches deviceValidated or the returned result.
|
|
//
|
|
// Fire-and-forget: a cold Graph round-trip can take seconds and an observation-only
|
|
// check must never sit in the agent's request path. That rules out the request-row
|
|
// dimension stage 1 uses (the row is written the moment the function returns, before
|
|
// this task finishes), so the outcome is logged at Warning instead (one line per real
|
|
// Graph lookup, see ServedFromCache). Volume is bounded: only Global Admins can enable the
|
|
// toggle. Widening it beyond preview should move to an inline call plus the
|
|
// CertTenantBinding-style request dimension.
|
|
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
|
|
{
|
|
var bindingValidator = _intuneDeviceBindingValidator;
|
|
var bindingTenant = tenantId;
|
|
var bindingSession = sessionId;
|
|
var bindingSubject = certValidation.Subject;
|
|
var bindingThumbprint = certValidation.Thumbprint;
|
|
var bindingLogger = _logger;
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
TryGetIntuneDeviceIdFromCertSubject(bindingSubject, out var certDeviceId);
|
|
var binding = await bindingValidator.ValidateAsync(bindingTenant, certDeviceId, bindingSession);
|
|
|
|
// One line per real Graph lookup, not per request. Measured on the first
|
|
// live enrollment: 136 requests produced exactly ONE Graph call (the 30 min
|
|
// positive cache did its job), so per-request logging repeated the same
|
|
// finding 136 times. Cached repeats add nothing - the age just counts up
|
|
// mechanically from the same enrolledDateTime. Uncached outcomes
|
|
// (Transient, NoDeviceIdInCert) still log every time, which is what we want
|
|
// for anomalies.
|
|
if (binding.ServedFromCache)
|
|
return;
|
|
|
|
// Age of the device object at request time: the discriminator between a
|
|
// genuine enrollment race (object created moments ago, or not yet) and a
|
|
// certificate that never belonged to this tenant.
|
|
var enrolledAgeSeconds = binding.EnrolledDateTime.HasValue
|
|
? (long)Math.Round((DateTimeOffset.UtcNow - binding.EnrolledDateTime.Value).TotalSeconds)
|
|
: -1;
|
|
|
|
bindingLogger.LogWarning(
|
|
"AgentCertDeviceBinding outcome={Outcome} enforced={Enforced} tenant={TenantId} "
|
|
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
|
|
+ "mgmtState={ManagementState} thumbprint={Thumbprint} session={SessionId} detail={Detail}",
|
|
binding.Outcome, false, bindingTenant,
|
|
certDeviceId ?? "n/a", binding.DeviceName ?? "n/a", enrolledAgeSeconds,
|
|
binding.ManagementState ?? "n/a", bindingThumbprint ?? "n/a",
|
|
bindingSession ?? "n/a", binding.ErrorMessage ?? "n/a");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
bindingLogger.LogWarning(ex,
|
|
"AgentCertDeviceBinding (shadow) failed for tenant {TenantId} - ignored.", bindingTenant);
|
|
}
|
|
});
|
|
}
|
|
|
|
// All checks passed. The admitting validator rides on the request row: the Sessions
|
|
// row keeps ValidatedBy per session, this is the per-request counter and timestamp.
|
|
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
|
RequestRowMarkers.DeviceValidationValue(validatedBy));
|
|
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
|
|
return new SecurityValidationResult
|
|
{
|
|
IsValid = true,
|
|
CertificateThumbprint = certValidation.Thumbprint,
|
|
IntuneDeviceId = certIntuneDeviceId,
|
|
Manufacturer = manufacturer,
|
|
Model = model,
|
|
SerialNumber = serialNumber,
|
|
AutopilotDeviceId = autopilotDeviceId,
|
|
RateLimitResult = rateLimitResult,
|
|
ValidatedBy = validatedBy
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Joins the per-validator failure messages so the 403 Details / rejection log carries
|
|
/// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the
|
|
/// last validator's.
|
|
/// </summary>
|
|
internal static string? CombineValidationErrors(string? accumulated, string? next)
|
|
{
|
|
if (string.IsNullOrEmpty(next)) return accumulated;
|
|
if (string.IsNullOrEmpty(accumulated)) return next;
|
|
return accumulated + " | " + next;
|
|
}
|
|
|
|
/// <summary>Largest Retry-After requested so far (null when no validator asked for one).</summary>
|
|
internal static int? MaxRetryAfter(int? accumulated, int? next)
|
|
=> next is int n && (accumulated is not int a || n > a) ? n : accumulated;
|
|
|
|
/// <summary>
|
|
/// Extracts the Intune device id from an MDM client certificate subject. Certs issued by
|
|
/// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN
|
|
/// (field-verified 2026-08-06 on a W365 Cloud PC). Returns false when the subject has no
|
|
/// CN or the CN is not a canonical GUID — the CloudPc validator then rejects it as
|
|
/// "not a valid Intune device id" without ever touching Graph.
|
|
/// </summary>
|
|
internal static bool TryGetIntuneDeviceIdFromCertSubject(string? subject, out string? intuneDeviceId)
|
|
{
|
|
intuneDeviceId = null;
|
|
if (string.IsNullOrWhiteSpace(subject))
|
|
return false;
|
|
|
|
foreach (var part in subject!.Split(','))
|
|
{
|
|
var trimmed = part.Trim();
|
|
if (!trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
var value = trimmed.Substring(3).Trim();
|
|
if (IsValidGuid(value))
|
|
{
|
|
intuneDeviceId = value.ToLowerInvariant();
|
|
return true;
|
|
}
|
|
return false; // first CN wins; a non-GUID CN is definitive (not an Intune MDM device cert shape)
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// CERT-TENANT-BINDING — evaluates whether the tenant stamped into the agent's certificate
|
|
/// matches the tenant the request claims, records the outcome, and returns it so the caller
|
|
/// can enforce. The enforcement rule itself lives in <see cref="CertTenantBinding.Rejects"/>,
|
|
/// not here, so "what blocks a request" is answerable from one place.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Two carriers, because they answer different questions and have different costs:
|
|
/// <list type="bullet">
|
|
/// <item><description>Every outcome is stamped onto the request row via
|
|
/// <see cref="CertTenantBinding.RequestItemKey"/>. That row already exists per request and is
|
|
/// unsampled, so the denominator ("how many requests matched") costs no extra telemetry.
|
|
/// A trace line could not do this: worker-side <c>LogInformation</c> never reaches App
|
|
/// Insights, so the bulk "Match" case would simply be invisible.</description></item>
|
|
/// <item><description>Outcomes that need an enforcement decision (mismatch, missing or
|
|
/// undecodable extension) are additionally logged at Warning with the full context —
|
|
/// thumbprint, cert tenant, session — because each one is individually actionable.</description></item>
|
|
/// </list>
|
|
/// </remarks>
|
|
private string EvaluateCertTenantBinding(
|
|
CertificateValidationResult certValidation,
|
|
string tenantId,
|
|
HttpRequestData req,
|
|
string? sessionId)
|
|
{
|
|
var outcome = CertTenantBinding.Outcome.Match;
|
|
try
|
|
{
|
|
outcome = CertTenantBinding.Evaluate(
|
|
certValidation.CertTenantId,
|
|
certValidation.CertTenantIdStatus,
|
|
tenantId);
|
|
|
|
// Denominator: rides along on the request row for every outcome, including Match.
|
|
var items = req.FunctionContext?.Items;
|
|
if (items != null)
|
|
items[CertTenantBinding.RequestItemKey] = outcome;
|
|
|
|
// Everything below is the detail record for outcomes someone has to act on.
|
|
if (outcome == CertTenantBinding.Outcome.Match)
|
|
return outcome;
|
|
|
|
var agentVersion = req.Headers.Contains("X-Agent-Version")
|
|
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() ?? "n/a"
|
|
: "n/a";
|
|
|
|
// Only the cert's tenant is logged, never the raw certificate. On a mismatch this is
|
|
// the attacker-controlled side of the comparison and the single most useful field.
|
|
const string template =
|
|
"AgentCertTenantBinding outcome={Outcome} enforced={Enforced} wouldReject={WouldReject} "
|
|
+ "tenant={TenantId} certTenant={CertTenantId} status={Status} thumbprint={Thumbprint} "
|
|
+ "session={SessionId} ver={AgentVersion}";
|
|
|
|
var certTenant = certValidation.CertTenantId?.ToString() ?? "n/a";
|
|
var wouldReject = CertTenantBinding.WouldRejectUnderEnforcement(outcome);
|
|
|
|
_logger.LogWarning(template,
|
|
outcome, true, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
|
|
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Fail open on our own bug. This gate exists to stop a cross-tenant certificate, and
|
|
// an exception in the evaluation is not evidence of one - turning it into a rejection
|
|
// would take the whole fleet down over a defect on our side.
|
|
_logger.LogWarning(ex, "AgentCertTenantBinding evaluation failed for tenant {TenantId} - request allowed", tenantId);
|
|
return CertTenantBinding.Outcome.Match;
|
|
}
|
|
|
|
return outcome;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits a structured warning with request-side context (path, hardware headers, agent
|
|
/// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted
|
|
/// inside <see cref="CertificateValidator"/> via shared <c>thumbprint</c> + correlates 1:1
|
|
/// with <c>AgentDistressReport</c> events on <c>TenantId</c>+<c>SerialNumber</c>+timestamp.
|
|
/// </summary>
|
|
private void LogRequestRejection(string stage, string tenantId, HttpRequestData req, string? sessionId, string? extraReason, string? thumbprint)
|
|
{
|
|
string GetHeader(string name) => req.Headers.Contains(name)
|
|
? (req.Headers.GetValues(name).FirstOrDefault() ?? "n/a")
|
|
: "n/a";
|
|
|
|
var path = req.Url?.AbsolutePath ?? "n/a";
|
|
var manufacturer = GetHeader("X-Device-Manufacturer");
|
|
var model = GetHeader("X-Device-Model");
|
|
var serial = GetHeader("X-Device-SerialNumber");
|
|
var agentVersion = GetHeader("X-Agent-Version");
|
|
|
|
_logger.LogWarning(
|
|
"AgentRequestRejected stage={Stage} tenant={TenantId} path={Path} session={SessionId} thumbprint={Thumbprint} mfr={Manufacturer} model={Model} serial={SerialNumber} ver={AgentVersion} reason={Reason}",
|
|
stage, tenantId, path, sessionId ?? "n/a", thumbprint ?? "n/a", manufacturer, model, serial, agentVersion, extraReason ?? "n/a");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of security validation
|
|
/// </summary>
|
|
public class SecurityValidationResult
|
|
{
|
|
/// <summary>
|
|
/// Whether the request passed all security checks
|
|
/// </summary>
|
|
public bool IsValid { get; set; }
|
|
|
|
/// <summary>
|
|
/// HTTP status code if validation failed
|
|
/// </summary>
|
|
public HttpStatusCode StatusCode { get; set; }
|
|
|
|
/// <summary>
|
|
/// Error message if validation failed
|
|
/// </summary>
|
|
public string? ErrorMessage { get; set; }
|
|
|
|
/// <summary>
|
|
/// Detailed error information
|
|
/// </summary>
|
|
public string? Details { get; set; }
|
|
|
|
/// <summary>
|
|
/// Certificate thumbprint (if validation succeeded)
|
|
/// </summary>
|
|
public string? CertificateThumbprint { get; set; }
|
|
|
|
/// <summary>
|
|
/// Intune device id taken from the client certificate CN (lower-case GUID). Set only on
|
|
/// the certificate path and only when the CN has that shape. TLS-proven — the one device
|
|
/// identifier that survives a certificate re-issue, which is why SESSION-OWNER-BINDING
|
|
/// falls back to it when the thumbprint changed.
|
|
/// </summary>
|
|
public string? IntuneDeviceId { get; set; }
|
|
|
|
/// <summary>
|
|
/// Device manufacturer (if validation succeeded)
|
|
/// </summary>
|
|
public string? Manufacturer { get; set; }
|
|
|
|
/// <summary>
|
|
/// Device model (if validation succeeded)
|
|
/// </summary>
|
|
public string? Model { get; set; }
|
|
|
|
/// <summary>
|
|
/// Device serial number (if Autopilot device validation is enabled and succeeded)
|
|
/// </summary>
|
|
public string? SerialNumber { get; set; }
|
|
|
|
/// <summary>
|
|
/// Autopilot device ID from Intune (if Autopilot device validation is enabled and succeeded)
|
|
/// </summary>
|
|
public string? AutopilotDeviceId { get; set; }
|
|
|
|
/// <summary>
|
|
/// Rate limit result (if validation succeeded)
|
|
/// </summary>
|
|
public RateLimitResult? RateLimitResult { get; set; }
|
|
|
|
/// <summary>
|
|
/// Suggested retry delay in seconds (set when StatusCode is 503 ServiceUnavailable)
|
|
/// </summary>
|
|
public int? RetryAfterSeconds { get; set; }
|
|
|
|
/// <summary>
|
|
/// Whether this request was authenticated via a bootstrap token (pre-MDM OOBE auth)
|
|
/// </summary>
|
|
public bool IsBootstrapAuth { get; set; }
|
|
|
|
/// <summary>
|
|
/// Bootstrap session short code (only set when IsBootstrapAuth is true)
|
|
/// </summary>
|
|
public string? BootstrapShortCode { get; set; }
|
|
|
|
/// <summary>
|
|
/// Which validator ultimately authorized this device. Copied into the
|
|
/// RegisterSession response so the agent can reconcile the enrollment-type
|
|
/// verdict with its registry-based detection.
|
|
/// </summary>
|
|
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
|
|
}
|
|
}
|