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:
cesnimda
2026-08-30 11:00:52 +02:00
parent a74daa7aa4
commit a8bf505ce5
14 changed files with 1869 additions and 110 deletions
@@ -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();
}
}