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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user