mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-01 18:43:13 +00:00
* 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.
92 lines
3.1 KiB
C#
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);
|
|
}
|
|
}
|