using System.Security.Claims; using System.Security.Cryptography; using System.Text; namespace ProposalSystem.Api.Middleware; public class InternalApiKeyMiddleware { private static readonly string[] AllowedPathPrefixes = [ "/api/proposals", "/api/vendor-proposals", "/api/generated-pdfs", "/api/files", ]; private static string SanitizeForLog(string value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } var sanitized = value.Replace("\r", "").Replace("\n", ""); var builder = new StringBuilder(sanitized.Length); foreach (var c in sanitized) { if (!char.IsControl(c)) { builder.Append(c); } } return builder.ToString(); } private readonly RequestDelegate _next; private readonly byte[] _apiKeyBytes; private readonly ILogger _logger; public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration, ILogger logger) { _next = next; _logger = logger; var key = configuration["INTERNAL_API_KEY"] ?? ""; _apiKeyBytes = Encoding.UTF8.GetBytes(key); } public async Task InvokeAsync(HttpContext context) { if (_apiKeyBytes.Length > 0 && context.Request.Headers.TryGetValue("X-Internal-Api-Key", out var providedKey) && !string.IsNullOrEmpty(providedKey.ToString())) { var path = context.Request.Path.Value ?? ""; var sanitizedPath = SanitizeForLog(path); var providedBytes = Encoding.UTF8.GetBytes(providedKey.ToString()); if (!CryptographicOperations.FixedTimeEquals(providedBytes, _apiKeyBytes)) { _logger.LogWarning("Invalid internal API key from {RemoteIp} on {Path}", context.Connection.RemoteIpAddress, sanitizedPath); context.Response.StatusCode = 401; return; } if (!AllowedPathPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) { _logger.LogWarning("Internal API key used on disallowed path {Path} from {RemoteIp}", sanitizedPath, context.Connection.RemoteIpAddress); context.Response.StatusCode = 403; return; } var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "system"), new Claim("sub", "system-lambda-caller"), new Claim(ClaimTypes.Email, "system@proposal-system.internal"), new Claim("email", "system@proposal-system.internal"), new Claim("name", "System"), new Claim(ClaimTypes.Role, "admins"), new Claim("cognito:groups", "admins"), }; var identity = new ClaimsIdentity(claims, "InternalApiKey"); context.User = new ClaimsPrincipal(identity); } await _next(context); } }