Harden job import SSRF validation

This commit is contained in:
2026-04-11 16:26:14 +02:00
parent b4719a9916
commit 6a223a4b70
4 changed files with 170 additions and 24 deletions
+3 -1
View File
@@ -128,7 +128,8 @@ builder.Services.AddHostedService<CvProcessingHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = false
});
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
@@ -166,6 +167,7 @@ builder.Services.AddIdentityCore<ApplicationUser>(options =>
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, LinkedInPlugin>();
@@ -0,0 +1,14 @@
using System.Net;
namespace JobTrackerApi.Services.JobImport;
public interface IHostAddressResolver
{
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
}
public sealed class DnsHostAddressResolver : IHostAddressResolver
{
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken)
=> Dns.GetHostAddressesAsync(host, cancellationToken);
}
@@ -15,32 +15,38 @@ public sealed class JobImportService
private readonly UniversalJobParser _universal;
private readonly IEnumerable<IJobSitePlugin> _plugins;
private readonly ITranslationService _translation;
private readonly IHostAddressResolver _hostAddressResolver;
public JobImportService(
IHttpClientFactory httpClientFactory,
UniversalJobParser universal,
IEnumerable<IJobSitePlugin> plugins,
ITranslationService translation)
ITranslationService translation,
IHostAddressResolver hostAddressResolver)
{
_httpClientFactory = httpClientFactory;
_universal = universal;
_plugins = plugins;
_translation = translation;
_hostAddressResolver = hostAddressResolver;
}
public async Task<JobImportResult> PreviewAsync(string url, CancellationToken cancellationToken)
{
if (!TryValidateUrl(url, out var normalized, out var error))
var validation = await ValidateUrlAsync(url, cancellationToken);
if (!validation.Allowed)
{
return new JobImportResult
{
SourceUrl = url ?? "",
Success = false,
Parser = "none",
Error = error
Error = validation.Error
};
}
var normalized = validation.Normalized;
var html = await FetchHtmlAsync(normalized, cancellationToken);
if (html is null)
{
@@ -124,62 +130,88 @@ public sealed class JobImportService
return System.Text.Encoding.UTF8.GetString(bytes);
}
private static bool TryValidateUrl(string? url, out string normalized, out string error)
private async Task<UrlValidationResult> ValidateUrlAsync(string? url, CancellationToken cancellationToken)
{
normalized = "";
error = "";
if (string.IsNullOrWhiteSpace(url))
{
error = "URL is required.";
return false;
return UrlValidationResult.Reject("URL is required.");
}
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri))
{
error = "Invalid URL.";
return false;
return UrlValidationResult.Reject("Invalid URL.");
}
if (uri.Scheme is not ("http" or "https"))
{
error = "Only http/https URLs are supported.";
return false;
return UrlValidationResult.Reject("Only http/https URLs are supported.");
}
if (uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
error = "Local URLs are not allowed.";
return false;
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
// Block literal private IPs.
if (IPAddress.TryParse(uri.Host, out var ip))
{
if (IsPrivateIp(ip))
if (IsBlockedAddress(ip))
{
error = "Private IP URLs are not allowed.";
return false;
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
return UrlValidationResult.Allow(uri.ToString());
}
normalized = uri.ToString();
return true;
IPAddress[] addresses;
try
{
addresses = await _hostAddressResolver.ResolveAsync(uri.Host, cancellationToken);
}
catch
{
return UrlValidationResult.Reject("Host resolution failed.");
}
if (addresses.Length == 0 || addresses.Any(IsBlockedAddress))
{
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
}
return UrlValidationResult.Allow(uri.ToString());
}
private static bool IsPrivateIp(IPAddress ip)
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] == 169 && b[1] == 254);
(b[0] == 198 && (b[1] == 18 || b[1] == 19));
}
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal;
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);
public static UrlValidationResult Reject(string error) => new(false, string.Empty, error);
}
}