proposal-system/api/src/ProposalSystem.Api/Middleware/InternalApiKeyMiddleware.cs
Adam Moussa 9e579f84e2
fix(web,api): pin nanoid and sanitize auth logs (SEC-29) (#312)
* fix: bump nanoid to 3.3.16 and postcss to 8.5.26

Bump nanoid from 3.3.16 to 3.3.18 in web/
Bump postcss from 8.5.25 to 8.5.26 in web/

Closes [Dependabot 47] (https://github.com/Sea-Haven-Industries/proposal-system/security/dependabot/47)

* fix(api): sanitize request path in internal API key logs (SEC-29)

Strip CR/LF from Request.Path before logging invalid-key and disallowed-path warnings so CodeQL alerts 4 and 5 close without changing 401/403 behavior.

* fix(api): log user id instead of email on cognito role sync (SEC-29)

Keep AuthResponse.Email unchanged so CodeQL alert 1 closes without altering the callback payload.

* fix(api): use sanitized path on both internal key logs (SEC-29)

The 401 branch referenced an out-of-scope identifier and the 403 branch skipped SanitizeForLog. Cover newline-in-path logs and Cognito role-sync user-id logging with tests.
2026-08-20 12:38:29 -04:00

92 lines
3.1 KiB
C#

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<InternalApiKeyMiddleware> _logger;
public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<InternalApiKeyMiddleware> 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);
}
}