fix(cv): harden document parsing
Upgrade and hash-lock upload-facing parser dependencies, reject resource-heavy or mismatched inputs, remove unsafe backend binary fallbacks, and prevent internal parser failures from leaking to users.
This commit is contained in:
@@ -3,7 +3,6 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Models;
|
||||
@@ -1352,78 +1351,29 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|| Regex.IsMatch(text, @"(?im)^\s*#\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)");
|
||||
}
|
||||
|
||||
private static async Task<string> ExtractTextAsync(IFormFile file, string extension)
|
||||
private static async Task<string> ExtractPlainTextAsync(IFormFile file, string extension, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
using var reader = new StreamReader(
|
||||
stream,
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true),
|
||||
detectEncodingFromByteOrderMarks: true);
|
||||
var buffer = new char[8192];
|
||||
var result = new StringBuilder(capacity: (int)Math.Min(file.Length, 64 * 1024));
|
||||
while (true)
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return (await reader.ReadToEndAsync()).Trim();
|
||||
}
|
||||
|
||||
await using var memory = new MemoryStream();
|
||||
await file.CopyToAsync(memory);
|
||||
var bytes = memory.ToArray();
|
||||
|
||||
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var raw = Encoding.Latin1.GetString(bytes);
|
||||
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
||||
.Select(match => match.Groups[1].Value)
|
||||
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
|
||||
.SelectMany(match => Regex.Matches(match.Groups[1].Value, @"\((.*?)\)", RegexOptions.Singleline).Select(x => x.Groups[1].Value)))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => Regex.Unescape(value))
|
||||
.ToList();
|
||||
|
||||
var joined = textMatches.Count > 0 ? string.Join(" ", textMatches) : raw;
|
||||
var scrubbed = Regex.Replace(joined, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " ");
|
||||
return Regex.Replace(scrubbed, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
if (string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(bytes), System.IO.Compression.ZipArchiveMode.Read, leaveOpen: false);
|
||||
var entry = archive.GetEntry("word/document.xml");
|
||||
if (entry is null) return string.Empty;
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream, Encoding.UTF8);
|
||||
var xml = await reader.ReadToEndAsync();
|
||||
var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace);
|
||||
XNamespace word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
var body = document.Root?.Element(word + "body");
|
||||
if (body is null) return string.Empty;
|
||||
|
||||
static string Text(XElement element, XNamespace ns) => string.Concat(
|
||||
element.Descendants(ns + "t").Select(node => node.Value));
|
||||
|
||||
var blocks = new List<string>();
|
||||
foreach (var block in body.Elements())
|
||||
var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken);
|
||||
if (read == 0) break;
|
||||
if (result.Length + read > 200_000)
|
||||
{
|
||||
if (block.Name == word + "p")
|
||||
{
|
||||
var paragraph = Text(block, word).Trim();
|
||||
if (paragraph.Length == 0) continue;
|
||||
var style = block.Element(word + "pPr")?.Element(word + "pStyle")?.Attribute(word + "val")?.Value ?? string.Empty;
|
||||
if (style.Contains("Role", StringComparison.OrdinalIgnoreCase) && blocks.Count > 0) blocks.Add(string.Empty);
|
||||
blocks.Add(style.Contains("Bullet", StringComparison.OrdinalIgnoreCase) ? $"- {paragraph}" : paragraph);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.Name != word + "tbl") continue;
|
||||
foreach (var row in block.Elements(word + "tr"))
|
||||
{
|
||||
var cells = row.Elements(word + "tc")
|
||||
.Select(cell => string.Join(" ", cell.Elements(word + "p").Select(paragraph => Text(paragraph, word).Trim()).Where(value => value.Length > 0)))
|
||||
.Where(value => value.Length > 0)
|
||||
.ToList();
|
||||
if (cells.Count > 0) blocks.Add(string.Join(" | ", cells));
|
||||
}
|
||||
throw new InvalidOperationException("The extracted CV text is too large to process safely.");
|
||||
}
|
||||
|
||||
return string.Join("\n", blocks).Trim();
|
||||
result.Append(buffer, 0, read);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
return result.ToString().Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +231,15 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
text = (await ExtractTextAsync(file, extension)).Trim();
|
||||
text = (await ExtractPlainTextAsync(file, extension, cancellationToken)).Trim();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
|
||||
throw new InvalidOperationException(
|
||||
string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
|
||||
? "The uploaded CV file could not be read or was empty."
|
||||
: "The document extraction service could not read this CV safely.");
|
||||
}
|
||||
|
||||
text = RepairKnownMojibake(text);
|
||||
@@ -506,8 +510,12 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var generationFailure = ex as AiGenerationException;
|
||||
var retryable = generationFailure?.Retryable == true;
|
||||
var failureMessage = generationFailure?.Message
|
||||
?? (ex is InvalidOperationException
|
||||
? ex.Message
|
||||
: "CV processing failed unexpectedly. Please try again.");
|
||||
run.Status = retryable ? "queued" : "failed";
|
||||
run.ErrorMessage = ex.Message;
|
||||
run.ErrorMessage = failureMessage;
|
||||
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (!retryable)
|
||||
@@ -519,7 +527,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
return new CvProcessingOutcome(
|
||||
false,
|
||||
generationFailure?.Category ?? "cv_processing_failed",
|
||||
ex.Message,
|
||||
failureMessage,
|
||||
retryable,
|
||||
generationFailure?.Provider,
|
||||
generationFailure?.Model,
|
||||
|
||||
Reference in New Issue
Block a user