fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
@@ -97,6 +97,7 @@ public sealed class AuthController : ControllerBase
|
||||
public string AppVersion { get; init; } = "unknown";
|
||||
public string? AppCommitSha { get; init; }
|
||||
public string UiLanguage { get; init; } = "en";
|
||||
public string? SessionContext { get; init; }
|
||||
}
|
||||
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
|
||||
private const int MaxAvatarBytes = 1_000_000;
|
||||
@@ -467,6 +468,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
@@ -1234,6 +1236,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
private MeResult WithBuildMetadata(MeResult result, bool include)
|
||||
{
|
||||
result = result with { SessionContext = User.FindFirstValue("sid") };
|
||||
if (!include) return result;
|
||||
|
||||
return result with
|
||||
|
||||
@@ -83,6 +83,7 @@ namespace JobTrackerApi.Controllers
|
||||
var q = s.Replace("\"", "\"\"");
|
||||
return needs ? $"\"{q}\"" : q;
|
||||
}
|
||||
static string Text(string? s) => Esc(CsvCellSafety.SpreadsheetText(s));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(string.Join(",",
|
||||
@@ -108,27 +109,28 @@ namespace JobTrackerApi.Controllers
|
||||
foreach (var j in jobs)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
Esc(j.Company?.Name),
|
||||
Esc(j.Company?.Location),
|
||||
Esc(j.Company?.Source),
|
||||
Esc(j.JobTitle),
|
||||
Esc(j.Status),
|
||||
Text(j.Company?.Name),
|
||||
Text(j.Company?.Location),
|
||||
Text(j.Company?.Source),
|
||||
Text(j.JobTitle),
|
||||
Text(j.Status),
|
||||
Esc(j.DateApplied?.ToString("o")),
|
||||
Esc(j.Location),
|
||||
Esc(j.Salary),
|
||||
Text(j.Location),
|
||||
Text(j.Salary),
|
||||
Esc(j.SalaryMin?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
Esc(j.SalaryMax?.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
Esc(j.SalaryCurrency),
|
||||
Esc(j.SalaryPeriod),
|
||||
Esc(j.NextAction),
|
||||
Text(j.SalaryCurrency),
|
||||
Text(j.SalaryPeriod),
|
||||
Text(j.NextAction),
|
||||
Esc(j.FollowUpAt?.ToString("o")),
|
||||
Esc(j.JobUrl),
|
||||
Esc(j.Notes),
|
||||
Esc(j.CoverLetterText)
|
||||
Text(j.JobUrl),
|
||||
Text(j.Notes),
|
||||
Text(j.CoverLetterText)
|
||||
));
|
||||
}
|
||||
|
||||
return File(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", $"job-tracker-export-{stamp}.csv");
|
||||
// Excel's direct CSV open needs the UTF-8 BOM to preserve Norwegian text.
|
||||
return File([.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(sb.ToString())], "text/csv; charset=utf-8", $"job-tracker-export-{stamp}.csv");
|
||||
}
|
||||
|
||||
return File(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(jobs), "application/json", $"job-tracker-export-{stamp}.json");
|
||||
|
||||
+35
-18
@@ -69,6 +69,8 @@ builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
|
||||
builder.Services.AddTransient<ProfileCvController>();
|
||||
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
|
||||
builder.Services.AddOptions<PdfRenderOptions>().BindConfiguration("CvExports").ValidateDataAnnotations().ValidateOnStart();
|
||||
builder.Services.AddSingleton<PdfRenderAdmission>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
||||
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
|
||||
@@ -128,20 +130,19 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
|
||||
});
|
||||
|
||||
// Enable CORS (allowlist by default)
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||||
if (corsOrigins.Length == 0)
|
||||
corsOrigins = new[] { builder.Environment.IsProduction() ? externalOrigin.BaseUrl : "http://localhost:3000" };
|
||||
if (corsOrigins.Any(x => x.Trim() == "*"))
|
||||
throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled.");
|
||||
if (builder.Environment.IsProduction() && corsOrigins.Any(x => x.Trim().TrimEnd('/') != externalOrigin.BaseUrl))
|
||||
throw new InvalidOperationException("Production Cors:Origins must match the canonical same-origin application URL.");
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("AllowReact", policy =>
|
||||
{
|
||||
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||||
if (origins.Length == 0)
|
||||
{
|
||||
origins = new[] { "http://localhost:3000" };
|
||||
}
|
||||
|
||||
if (origins.Any(x => x.Trim() == "*"))
|
||||
throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled.");
|
||||
|
||||
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
|
||||
policy.WithOrigins(corsOrigins.Select(x => x.Trim().TrimEnd('/')).Where(x => x.Length > 0).ToArray())
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
@@ -156,6 +157,7 @@ builder.Services.AddProblemDetails(options =>
|
||||
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
|
||||
});
|
||||
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
|
||||
builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>();
|
||||
builder.Services.AddOpenApi();
|
||||
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(dataRoot))
|
||||
@@ -186,11 +188,8 @@ builder.Services.AddHostedService<EmailSendAttemptRecoveryHostedService>();
|
||||
builder.Services.AddHostedService<AccountDeletionHostedService>();
|
||||
|
||||
builder.Services.AddHttpClient("jobimport")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
.ConfigurePrimaryHttpMessageHandler(services =>
|
||||
JobImportHttpTransport.CreateHandler(services.GetRequiredService<IHostAddressResolver>()));
|
||||
|
||||
builder.Services.AddHttpClient("maintenance-jobs", client =>
|
||||
{
|
||||
@@ -430,6 +429,20 @@ var authLoginPermitLimit = builder.Configuration.GetValue<int?>("RateLimits:Auth
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = async (context, cancellationToken) =>
|
||||
{
|
||||
var response = context.HttpContext.Response;
|
||||
response.Headers.CacheControl = "no-store";
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
await response.WriteAsJsonAsync(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status429TooManyRequests,
|
||||
Title = "Too many requests",
|
||||
Detail = "Please wait before trying again.",
|
||||
Extensions = { ["code"] = "rate_limit_exceeded", ["traceId"] = context.HttpContext.TraceIdentifier }
|
||||
}, cancellationToken);
|
||||
};
|
||||
|
||||
options.AddPolicy("auth-login", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
@@ -455,7 +468,7 @@ builder.Services.AddRateLimiter(options =>
|
||||
|
||||
options.AddPolicy("email-send", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"email-send:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
||||
partitionKey: RateLimitPartitionKeys.Account(context, "email-send"),
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
@@ -477,7 +490,7 @@ builder.Services.AddRateLimiter(options =>
|
||||
|
||||
options.AddPolicy("account-data", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"account-data:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
||||
partitionKey: RateLimitPartitionKeys.Account(context, "account-data"),
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 2,
|
||||
@@ -599,7 +612,6 @@ await using (var attachmentScope = app.Services.CreateAsyncScope())
|
||||
}
|
||||
|
||||
app.UseCors("AllowReact");
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
@@ -641,6 +653,11 @@ app.Use(async (ctx, next) =>
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// Authorization can select an endpoint's explicit auth scheme (local instead of smart's
|
||||
// Google principal). Partition only after the final, validated resource principal is set.
|
||||
// AllowAnonymous security endpoints still reach their unchanged IP-scoped policies.
|
||||
app.UseRateLimiter();
|
||||
app.UseMiddleware<SessionContextMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
// Liveness probe for the container health check. Deliberately cheap and anonymous: it answers "is
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public static class CsvCellSafety
|
||||
{
|
||||
// Apply to text before CSV quoting, never to stored data or typed numbers/dates.
|
||||
// Prefix before (not instead of) whitespace/controls; an existing apostrophe is safe.
|
||||
public static string SpreadsheetText(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value ?? "";
|
||||
foreach (var rune in value.EnumerateRunes())
|
||||
{
|
||||
if (Rune.IsWhiteSpace(rune) || Rune.IsControl(rune) || Rune.GetUnicodeCategory(rune) == UnicodeCategory.Format)
|
||||
continue;
|
||||
return rune.Value is '=' or '+' or '-' or '@' ? "'" + value : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public static class ForwardedProxyConfiguration
|
||||
|
||||
foreach (var value in networks)
|
||||
{
|
||||
if (!IPNetwork.TryParse(value, out var network))
|
||||
if (!IPNetwork.TryParse(value, out var network) || network.PrefixLength == 0)
|
||||
throw new InvalidOperationException($"Proxy:KnownNetworks contains an invalid CIDR: {value}");
|
||||
|
||||
options.KnownNetworks.Add(network);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace JobTrackerApi.Services.JobImport;
|
||||
|
||||
internal static class JobImportHttpTransport
|
||||
{
|
||||
// The optional dial function is a socket-boundary test seam, not a configurable policy.
|
||||
internal static SocketsHttpHandler CreateHandler(
|
||||
IHostAddressResolver resolver,
|
||||
Func<IPEndPoint, CancellationToken, ValueTask<Stream>>? connect = null) => new()
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
AllowAutoRedirect = false,
|
||||
// A proxy would resolve/connect the origin outside this boundary.
|
||||
UseProxy = false,
|
||||
ConnectCallback = (context, cancellationToken) =>
|
||||
ConnectAsync(context.DnsEndPoint, resolver, connect ?? ConnectSocketAsync, cancellationToken)
|
||||
// Return plain TCP; SocketsHttpHandler retains the original URI hostname for TLS/SNI.
|
||||
};
|
||||
|
||||
internal static bool IsBlockedAddress(IPAddress address)
|
||||
{
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0) return true;
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
|
||||
if (IPAddress.IsLoopback(address)) return true;
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
return bytes[0] is 0 or 10 or 127 or >= 224 ||
|
||||
(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) ||
|
||||
(bytes[0] == 169 && bytes[1] == 254) ||
|
||||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
|
||||
(bytes[0] == 192 && bytes[1] == 168) ||
|
||||
(bytes[0] == 198 && bytes[1] is 18 or 19);
|
||||
}
|
||||
|
||||
if (address.AddressFamily != AddressFamily.InterNetworkV6) return true;
|
||||
if (address.Equals(IPAddress.IPv6Any) || address.IsIPv6LinkLocal ||
|
||||
address.IsIPv6SiteLocal || address.IsIPv6Multicast || address.IsIPv6Teredo) return true;
|
||||
if ((bytes[0] & 0xfe) == 0xfc) return true; // Unique local fc00::/7.
|
||||
|
||||
// Reject legacy IPv4-compatible, well-known NAT64 and 6to4 destinations: their
|
||||
// eventual IPv4 peer is not represented by a native IPv6 socket destination.
|
||||
return bytes.AsSpan(0, 12).IndexOfAnyExcept((byte)0) < 0 ||
|
||||
(bytes[0] == 0x00 && bytes[1] == 0x64 && bytes[2] == 0xff && bytes[3] == 0x9b &&
|
||||
((bytes[4] == 0 && bytes[5] == 1) || bytes.AsSpan(4, 8).IndexOfAnyExcept((byte)0) < 0)) ||
|
||||
(bytes[0] == 0x20 && bytes[1] == 0x02);
|
||||
}
|
||||
|
||||
private static async ValueTask<Stream> ConnectAsync(
|
||||
DnsEndPoint endpoint,
|
||||
IHostAddressResolver resolver,
|
||||
Func<IPEndPoint, CancellationToken, ValueTask<Stream>> connect,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var addresses = IPAddress.TryParse(endpoint.Host, out var literal)
|
||||
? [literal]
|
||||
: await resolver.ResolveAsync(endpoint.Host, cancellationToken);
|
||||
|
||||
// Validate the entire answer before attempting any connection, including mixed answers.
|
||||
if (addresses.Length == 0 || addresses.Any(IsBlockedAddress))
|
||||
throw new HttpRequestException("Local or private network URLs are not allowed.");
|
||||
|
||||
SocketException? lastError = null;
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
|
||||
try
|
||||
{
|
||||
// No hostname is passed to the socket, so there is no second DNS lookup.
|
||||
return await connect(new IPEndPoint(normalized, endpoint.Port), cancellationToken);
|
||||
}
|
||||
catch (SocketException error)
|
||||
{
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpRequestException("Could not connect to the import destination.", lastError);
|
||||
}
|
||||
|
||||
internal static async ValueTask<Stream> ConnectSocketAsync(IPEndPoint endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(endpoint, cancellationToken);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ public sealed class JobImportService
|
||||
|
||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||
{
|
||||
if (IsBlockedAddress(ip))
|
||||
if (JobImportHttpTransport.IsBlockedAddress(ip))
|
||||
{
|
||||
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
|
||||
}
|
||||
@@ -188,7 +188,7 @@ public sealed class JobImportService
|
||||
return UrlValidationResult.Reject("Host resolution failed.");
|
||||
}
|
||||
|
||||
if (addresses.Length == 0 || addresses.Any(IsBlockedAddress))
|
||||
if (addresses.Length == 0 || addresses.Any(JobImportHttpTransport.IsBlockedAddress))
|
||||
{
|
||||
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
|
||||
}
|
||||
@@ -196,35 +196,6 @@ public sealed class JobImportService
|
||||
return UrlValidationResult.Allow(uri.ToString());
|
||||
}
|
||||
|
||||
private static bool IsBlockedAddress(IPAddress ip)
|
||||
{
|
||||
if (IPAddress.IsLoopback(ip)) return true;
|
||||
if (ip.Equals(IPAddress.Any) || ip.Equals(IPAddress.IPv6Any)) return true;
|
||||
if (ip.Equals(IPAddress.None) || ip.Equals(IPAddress.IPv6None)) return true;
|
||||
if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || ip.IsIPv6Teredo) return true;
|
||||
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
var b = ip.GetAddressBytes();
|
||||
return b[0] == 10 ||
|
||||
b[0] == 0 ||
|
||||
b[0] == 127 ||
|
||||
(b[0] == 100 && b[1] >= 64 && b[1] <= 127) ||
|
||||
(b[0] == 169 && b[1] == 254) ||
|
||||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
|
||||
(b[0] == 192 && b[1] == 168) ||
|
||||
(b[0] == 198 && (b[1] == 18 || b[1] == 19));
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return (bytes[0] & 0xfe) == 0xfc; // fc00::/7 unique local addresses
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed record UrlValidationResult(bool Allowed, string Normalized, string Error)
|
||||
{
|
||||
public static UrlValidationResult Allow(string normalized) => new(true, normalized, string.Empty);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class PdfRenderOptions
|
||||
{
|
||||
// One browser at a time until the deployment's memory/CPU budget is measured (R04).
|
||||
[Range(1, 8)] public int MaxConcurrentRenders { get; set; } = 1;
|
||||
[Range(1, 60)] public int RenderTimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class PdfCapacityException : Exception
|
||||
{
|
||||
public PdfCapacityException() : base("PDF generation is busy. Please try again shortly.") { }
|
||||
}
|
||||
|
||||
public sealed class PdfRenderTimeoutException : TimeoutException
|
||||
{
|
||||
public PdfRenderTimeoutException() : base("PDF generation took too long. Please try again.") { }
|
||||
}
|
||||
|
||||
// Registered once for the API process, shared even by separately constructed exporters.
|
||||
// ponytail: zero waiting, one active render per document owner; measure before adding a scheduler.
|
||||
public sealed class PdfRenderAdmission
|
||||
{
|
||||
private readonly HashSet<string> _owners = new(StringComparer.Ordinal);
|
||||
private readonly int _limit;
|
||||
private readonly ILogger<PdfRenderAdmission> _logger;
|
||||
private bool _disabled;
|
||||
public TimeSpan RenderTimeout { get; }
|
||||
|
||||
public PdfRenderAdmission(IOptions<PdfRenderOptions> options, ILogger<PdfRenderAdmission> logger)
|
||||
{
|
||||
var value = options.Value;
|
||||
Validator.ValidateObject(value, new ValidationContext(value), validateAllProperties: true);
|
||||
_limit = value.MaxConcurrentRenders;
|
||||
RenderTimeout = TimeSpan.FromSeconds(value.RenderTimeoutSeconds);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IDisposable Enter(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
|
||||
lock (_owners)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_disabled || _owners.Count >= _limit || _owners.Contains(ownerUserId))
|
||||
{
|
||||
_logger.LogDebug("PDF admission rejected; active={Active}, limit={Limit}, disabled={Disabled}", _owners.Count, _limit, _disabled);
|
||||
throw new PdfCapacityException();
|
||||
}
|
||||
// Log before reserving: even a failing logging provider must not strand a slot.
|
||||
_logger.LogDebug("PDF admission granted; active={Active}, limit={Limit}", _owners.Count + 1, _limit);
|
||||
_owners.Add(ownerUserId);
|
||||
return new Lease(this, ownerUserId);
|
||||
}
|
||||
}
|
||||
|
||||
// If the OS cannot confirm termination, never replace possibly still-running work.
|
||||
// This is a visible fail-closed fault, requiring operator inspection/restart.
|
||||
public void Disable()
|
||||
{
|
||||
lock (_owners) _disabled = true;
|
||||
}
|
||||
|
||||
private sealed class Lease(PdfRenderAdmission admission, string owner) : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
lock (admission._owners) admission._owners.Remove(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class PdfRenderExceptionHandler : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
if (exception is not (PdfCapacityException or PdfRenderTimeoutException)) return false;
|
||||
var busy = exception is PdfCapacityException;
|
||||
var status = busy ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status504GatewayTimeout;
|
||||
context.Response.StatusCode = status;
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
if (busy) context.Response.Headers.RetryAfter = "3";
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = status,
|
||||
Title = busy ? "PDF generation is busy" : "PDF generation timed out",
|
||||
Detail = exception.Message,
|
||||
Extensions = { ["code"] = busy ? "pdf_capacity_exceeded" : "pdf_render_timeout", ["traceId"] = context.TraceIdentifier }
|
||||
}, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -28,16 +28,26 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
private readonly AppPaths _paths;
|
||||
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
||||
private readonly int _retentionDays;
|
||||
private readonly PdfRenderAdmission _admission;
|
||||
private readonly IHostApplicationLifetime _lifetime;
|
||||
|
||||
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger, IConfiguration configuration)
|
||||
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger, IConfiguration configuration,
|
||||
PdfRenderAdmission admission, IHostApplicationLifetime lifetime)
|
||||
{
|
||||
_paths = paths;
|
||||
_logger = logger;
|
||||
_retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365);
|
||||
_admission = admission;
|
||||
_lifetime = lifetime;
|
||||
}
|
||||
|
||||
public async Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
using var operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.ApplicationStopping);
|
||||
using var admission = _admission.Enter(ownerUserId, operation.Token);
|
||||
operation.CancelAfter(_admission.RenderTimeout);
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
var outcome = "failed";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
PruneExpiredExports(DateOnly.FromDateTime(now.UtcDateTime).AddDays(-_retentionDays));
|
||||
var folder = Path.Combine(_paths.GetOwnerCvExportsRoot(ownerUserId), now.ToString("yyyyMMdd"));
|
||||
@@ -56,7 +66,7 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken);
|
||||
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, operation.Token);
|
||||
|
||||
var browserPath = ResolveBrowserPath();
|
||||
if (string.IsNullOrWhiteSpace(browserPath))
|
||||
@@ -77,22 +87,44 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
|
||||
using var process = new Process();
|
||||
process.StartInfo = startInfo;
|
||||
operation.Token.ThrowIfCancellationRequested();
|
||||
process.Start();
|
||||
using var pipeCancellation = CancellationTokenSource.CreateLinkedTokenSource(operation.Token);
|
||||
// Drain both pipes while running: a full pipe must not deadlock the child or retain
|
||||
// unbounded browser diagnostics (which can contain document contents/URLs).
|
||||
var stdout = process.StandardOutput.BaseStream.CopyToAsync(Stream.Null, pipeCancellation.Token);
|
||||
var stderr = process.StandardError.BaseStream.CopyToAsync(Stream.Null, pipeCancellation.Token);
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(60), cancellationToken);
|
||||
await process.WaitForExitAsync(operation.Token);
|
||||
await Task.WhenAll(stdout, stderr);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!process.HasExited) process.Kill(entireProcessTree: true);
|
||||
try
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
||||
catch (Exception) when (process.HasExited) { /* Natural exit raced cancellation. */ }
|
||||
// Keep admission until the process has actually exited, including cancellation.
|
||||
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_admission.Disable();
|
||||
_logger.LogCritical("PDF process termination could not be confirmed ({FailureType}); admission disabled until operator recovery", ex.GetType().Name);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Observe cancelled pipe copies; disposal also closes inherited pipe handles.
|
||||
pipeCancellation.Cancel();
|
||||
try { await Task.WhenAll(stdout, stderr); } catch (OperationCanceledException) { }
|
||||
}
|
||||
}
|
||||
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await process.StandardError.ReadToEndAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}");
|
||||
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}.");
|
||||
}
|
||||
|
||||
if (!File.Exists(storagePath))
|
||||
@@ -100,17 +132,34 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken);
|
||||
var bytes = await File.ReadAllBytesAsync(storagePath, operation.Token);
|
||||
outcome = "succeeded";
|
||||
return new CvPdfArtifact(fileName, storagePath, bytes);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_lifetime.ApplicationStopping.IsCancellationRequested)
|
||||
{
|
||||
outcome = "timed_out";
|
||||
throw new PdfRenderTimeoutException();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
outcome = "cancelled";
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
||||
_logger.LogWarning("PDF generation failed ({FailureType})", ex.GetType().Name);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (outcome != "succeeded")
|
||||
{
|
||||
try { File.Delete(storagePath); }
|
||||
catch (Exception ex) { _logger.LogWarning("Could not remove partial PDF ({FailureType})", ex.GetType().Name); }
|
||||
}
|
||||
TryDeleteDirectory(tempRoot);
|
||||
_logger.LogInformation("PDF generation finished; outcome={Outcome}, durationMs={DurationMs}", outcome, elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ namespace JobTrackerApi.Services;
|
||||
|
||||
public static class RateLimitPartitionKeys
|
||||
{
|
||||
public static string Account(HttpContext context, string policy)
|
||||
{
|
||||
var owner = LocalAuthIdentity.GetRequiredUserId(context.User);
|
||||
// Separate namespaces prevent an anonymous address colliding with an account ID.
|
||||
return owner is null
|
||||
? $"{policy}:ip:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}"
|
||||
: $"{policy}:user:{owner}";
|
||||
}
|
||||
|
||||
public static string PublicPdf(HttpContext context)
|
||||
{
|
||||
var client = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
// A precondition, never an authority source: all resource owners still come from User.
|
||||
// Bind retained browser requests to the existing, validated server-side session. No new token
|
||||
// store or schema is needed; knowing a session id alone cannot authenticate a request.
|
||||
public sealed class SessionContextMiddleware(RequestDelegate next)
|
||||
{
|
||||
public const string HeaderName = "X-Session-Context";
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true ||
|
||||
context.GetEndpoint()?.Metadata.GetMetadata<IAllowAnonymous>() is not null)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var supplied = context.Request.Headers[HeaderName];
|
||||
var safeMethod = HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method) ||
|
||||
HttpMethods.IsOptions(context.Request.Method) || HttpMethods.IsTrace(context.Request.Method);
|
||||
// Unbound reads support bootstrap, direct document links and API clients. When a browser
|
||||
// supplies context on a read, enforce it too so a stale tab cannot adopt another owner's data.
|
||||
if (safeMethod && supplied.Count == 0)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionId = context.User.FindFirst("sid")?.Value;
|
||||
if (supplied.Count != 1 || string.IsNullOrWhiteSpace(supplied[0]) ||
|
||||
string.IsNullOrWhiteSpace(sessionId) || !string.Equals(supplied[0], sessionId, StringComparison.Ordinal))
|
||||
{
|
||||
var missing = supplied.Count == 0 || (supplied.Count == 1 && string.IsNullOrWhiteSpace(supplied[0]));
|
||||
context.Response.StatusCode = missing ? StatusCodes.Status428PreconditionRequired : StatusCodes.Status409Conflict;
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
code = missing ? "session_context_required" : "session_context_mismatch",
|
||||
message = "Reload this page before continuing. Your session may have changed."
|
||||
}, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"CvExports": {
|
||||
"MaxConcurrentRenders": 1,
|
||||
"RenderTimeoutSeconds": 60
|
||||
},
|
||||
"Workers": {
|
||||
"RulesEnabled": false,
|
||||
"FollowUpRemindersEnabled": false,
|
||||
|
||||
Reference in New Issue
Block a user