Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Middleware/NoStoreCacheMiddleware.cs
T
Oliver Kieselbach 12d67380e9 feat(mcp+backend): session_debug — client-side diag-ZIP analysis via HMAC ticket
Lets tenant-admins and GAs run end-to-end session debugging over MCP: backend
telemetry plus the agent diagnostics ZIP, downloaded and analyzed locally by the
AI client. The backend never unzips/parses the archive (no parse CPU); it only
mints a short-lived signed download ticket and proxy-streams bytes.

Backend (no ConfigVersion bump, no agent change):
- DiagnosticsDownloadTicket: HMAC codec (reuses PaginationTokenSigningKey,
  purpose-tagged diag-dl-v1, 10-min TTL) so a no-JWT client can download.
- DiagnosticsBlobStreamer: shared proxy-stream engine; DiagnosticsDownloadFunction
  (web/JWT) refactored onto it. ClassifyBlobName/BlobDestination stay put (xUnit).
- POST diagnostics/download-ticket (MemberRead, QueryParam, GA-bypass) +
  GET diagnostics/download?t= (anonymous; tenantId/blobName read only from the
  signed ticket -> no retargeting). Both added to NoStoreCacheMiddleware.

MCP:
- get_session_diagnostics tool (all roles) -> absolute downloadUrl + static zipMap
  + instructions; unwraps the {success,session} envelope.
- diag_zip_layout catalog (get_resource + protocol resource).
- debug-session prompt (tenant + GA variants).
- Tool descriptions flag the client requirement (needs local file/shell tools;
  pure chat clients cannot unzip).

Tests: backend 2380/2380 (ticket codec, NoStore, classify, catalog completeness);
MCP 261 passed (incl. envelope-unwrap regression). Inert until redeploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:29:29 +02:00

111 lines
5.0 KiB
C#

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Stamps <c>Cache-Control: no-store</c> + <c>Pragma: no-cache</c> on responses for
/// credential- or identity-bearing endpoints, preventing shared caches, browsers, and
/// HTTP/1.0 proxies from retaining bearer tokens, SAS URLs, or PII session payloads.
/// <para>
/// Allowlist over deny-all: a single centralized place to audit which endpoints are
/// considered sensitive. New credential-returning endpoints must be added here.
/// </para>
/// </summary>
public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware
{
private const string CacheControlHeader = "Cache-Control";
private const string CacheControlValue = "no-store";
private const string PragmaHeader = "Pragma";
private const string PragmaValue = "no-cache";
/// <summary>
/// Exact-match credential / identity endpoints. Bearer-token returning routes
/// (bootstrap/validate, agent/upload-url) carry acute risk if cached. Session-
/// list and raw-table surfaces are listed here for the literal path; their
/// sub-routes (with {id}) are covered by the prefix table below.
/// </summary>
private static readonly string[] _exactRoutes =
{
"/api/auth/me",
"/api/agent/config",
"/api/agent/upload-url", // returns long-lived diagnostics SAS URL
"/api/bootstrap/config",
// Session list / search / diagnostic-download surfaces — PII session payloads
"/api/sessions",
"/api/diagnostics/download-url", // proxies diagnostics ZIP content
"/api/diagnostics/download-ticket", // returns a short-lived signed download ticket (credential)
"/api/diagnostics/download", // ticket-gated anonymous ZIP stream (?t=...)
// GA cross-tenant raw + global PII surfaces
"/api/raw/sessions",
"/api/raw/events",
"/api/global/sessions",
"/api/global/distress-reports",
"/api/global/session-reports", // covers list; /download-url + /{id}/note via prefix
// Critical-table backup feature — manifest + job-status responses can carry
// property values from AdminConfiguration / TenantConfiguration (SAS URLs,
// webhook secrets, API keys) and must never be cached. Trigger endpoint also
// here so a returned jobId / statusUrl doesn't end up in proxy cache.
"/api/global/backups",
"/api/global/backups/trigger",
};
/// <summary>
/// Prefix-match routes. Used for routes with path parameters where exact match
/// is impossible. Anything beneath these prefixes is treated as sensitive.
/// </summary>
private static readonly string[] _prefixRoutes =
{
"/api/bootstrap/validate/", // /api/bootstrap/validate/{code} — Bearer token!
"/api/config/", // /api/config/{tenantId} + sub-routes
"/api/sessions/", // /api/sessions/{id} + sub-routes (events, signals, …)
"/api/search/", // /api/search/{quick,sessions,sessions-by-event,sessions-by-cve}
"/api/global/raw/", // GA cross-tenant raw event/session/table surfaces
"/api/global/search/", // GA cross-tenant search results
"/api/global/session-reports/", // /download-url (SAS) + /{reportId}/note
"/api/global/backups/", // covers /{backupId}, /jobs/{jobId} — same secret-bearing rationale
};
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var httpContext = context.GetHttpContext();
if (httpContext != null)
{
var path = httpContext.Request.Path.Value ?? string.Empty;
if (IsSensitive(path))
{
// Direct-write before next() — OnStarting() is not reliably triggered
// in the .NET 8 isolated worker (the host bridges the worker's response,
// so the hook fires on a shadow object that never reaches the wire).
// Same pattern used by CorrelationIdMiddleware and TimingAllowOriginMiddleware.
if (!httpContext.Response.Headers.ContainsKey(CacheControlHeader))
httpContext.Response.Headers[CacheControlHeader] = CacheControlValue;
if (!httpContext.Response.Headers.ContainsKey(PragmaHeader))
httpContext.Response.Headers[PragmaHeader] = PragmaValue;
}
}
await next(context);
}
internal static bool IsSensitive(string path)
{
if (string.IsNullOrEmpty(path))
return false;
foreach (var exact in _exactRoutes)
{
if (path.Equals(exact, StringComparison.OrdinalIgnoreCase))
return true;
}
foreach (var prefix in _prefixRoutes)
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}