Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs
T
Oliver Kieselbach c18de5d76c 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>
2026-08-29 20:04:04 +02:00

122 lines
6.2 KiB
C#

using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// POST /api/bootstrap/register-session — cert-free session registration for pre-enrollment agents.
/// Requires X-Bootstrap-Token header. Delegates to RegisterSessionFunction.ProcessRegisterAsync.
/// </summary>
public class BootstrapRegisterSessionFunction
{
private readonly ILogger<BootstrapRegisterSessionFunction> _logger;
private readonly RegisterSessionFunction _inner;
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 BootstrapSessionService _bootstrapSessionService;
public BootstrapRegisterSessionFunction(
ILogger<BootstrapRegisterSessionFunction> logger,
RegisterSessionFunction inner,
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
BootstrapSessionService bootstrapSessionService)
{
_logger = logger;
_inner = inner;
_configService = configService;
_adminConfigService = adminConfigService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_bootstrapSessionService = bootstrapSessionService;
}
[Function("BootstrapRegisterSession")]
public async Task<RegisterSessionOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bootstrap/register-session")] HttpRequestData req)
{
try
{
// 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" });
return new RegisterSessionOutput { HttpResponse = noToken };
}
// Parse request body (same as original RegisterSessionFunction)
if (req.Headers.TryGetValues("Content-Length", out var clValues)
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
&& contentLength > 1_048_576)
{
var tooLarge = req.CreateResponse(HttpStatusCode.BadRequest);
await tooLarge.WriteAsJsonAsync(new { success = false, message = "Request body too large" });
return new RegisterSessionOutput { HttpResponse = tooLarge };
}
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var request = JsonConvert.DeserializeObject<RegisterSessionRequest>(requestBody);
if (request?.Registration == null)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { success = false, message = "Invalid request payload" });
return new RegisterSessionOutput { HttpResponse = badReq };
}
var registration = request.Registration;
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
}
var (validation, errorResponse) = await req.ValidateSecurityAsync(
registration.TenantId, _configService, _adminConfigService, _rateLimitService,
_autopilotDeviceValidator, _corporateIdentifierValidator,
_logger, registration.SessionId,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator);
if (errorResponse != null)
{
return new RegisterSessionOutput { HttpResponse = errorResponse };
}
return await _inner.ProcessRegisterAsync(req, registration, validation);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in bootstrap register-session");
var error = req.CreateResponse(HttpStatusCode.InternalServerError);
await error.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return new RegisterSessionOutput { HttpResponse = error };
}
}
}
}