From 6a223a4b700e6c1952f93e2c150b4bcdd50afeeb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Apr 2026 16:26:14 +0200 Subject: [PATCH] Harden job import SSRF validation --- JobTrackerApi.Tests/JobImportServiceTests.cs | 98 +++++++++++++++++++ JobTrackerApi/Program.cs | 4 +- .../JobImport/IHostAddressResolver.cs | 14 +++ .../Services/JobImport/JobImportService.cs | 78 ++++++++++----- 4 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 JobTrackerApi.Tests/JobImportServiceTests.cs create mode 100644 JobTrackerApi/Services/JobImport/IHostAddressResolver.cs diff --git a/JobTrackerApi.Tests/JobImportServiceTests.cs b/JobTrackerApi.Tests/JobImportServiceTests.cs new file mode 100644 index 0000000..fa9a730 --- /dev/null +++ b/JobTrackerApi.Tests/JobImportServiceTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http; +using JobTrackerApi.Services.JobImport; +using JobTrackerApi.Services.JobImport.Translation; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobImportServiceTests +{ + [Fact] + public async Task Preview_rejects_hostname_that_resolves_to_loopback() + { + var resolver = new Mock(); + resolver + .Setup(x => x.ResolveAsync("127.0.0.1.nip.io", It.IsAny())) + .ReturnsAsync(new[] { IPAddress.Loopback }); + + var service = CreateService(resolver.Object); + + var result = await service.PreviewAsync("http://127.0.0.1.nip.io:5202/api/auth/config", CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal("none", result.Parser); + Assert.Equal("Local or private network URLs are not allowed.", result.Error); + } + + [Fact] + public async Task Preview_rejects_hostname_that_resolves_to_private_ip() + { + var resolver = new Mock(); + resolver + .Setup(x => x.ResolveAsync("internal.example.test", It.IsAny())) + .ReturnsAsync(new[] { IPAddress.Parse("10.10.1.5") }); + + var service = CreateService(resolver.Object); + + var result = await service.PreviewAsync("https://internal.example.test/job/123", CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal("Local or private network URLs are not allowed.", result.Error); + } + + [Fact] + public async Task Preview_allows_public_hostname_resolution_and_fetches_html() + { + var resolver = new Mock(); + resolver + .Setup(x => x.ResolveAsync("example.com", It.IsAny())) + .ReturnsAsync(new[] { IPAddress.Parse("93.184.216.34") }); + + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("no schema") + }); + + var service = CreateService(resolver.Object, handler); + + var result = await service.PreviewAsync("https://example.com/job", CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal("universal", result.Parser); + Assert.Equal("No JobPosting schema found.", result.Error); + } + + private static JobImportService CreateService(IHostAddressResolver resolver, HttpMessageHandler? handler = null) + { + handler ??= new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("") + }); + + var httpClient = new HttpClient(handler, disposeHandler: true); + var factory = new Mock(); + factory.Setup(x => x.CreateClient("jobimport")).Returns(httpClient); + + return new JobImportService( + factory.Object, + new UniversalJobParser(), + Array.Empty(), + new NoOpTranslationService(), + resolver); + } + + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public StubHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_handler(request)); + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 512dbd2..48afbd6 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -128,7 +128,8 @@ builder.Services.AddHostedService(); 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(options => builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/JobImport/IHostAddressResolver.cs b/JobTrackerApi/Services/JobImport/IHostAddressResolver.cs new file mode 100644 index 0000000..002bab3 --- /dev/null +++ b/JobTrackerApi/Services/JobImport/IHostAddressResolver.cs @@ -0,0 +1,14 @@ +using System.Net; + +namespace JobTrackerApi.Services.JobImport; + +public interface IHostAddressResolver +{ + Task ResolveAsync(string host, CancellationToken cancellationToken); +} + +public sealed class DnsHostAddressResolver : IHostAddressResolver +{ + public Task ResolveAsync(string host, CancellationToken cancellationToken) + => Dns.GetHostAddressesAsync(host, cancellationToken); +} diff --git a/JobTrackerApi/Services/JobImport/JobImportService.cs b/JobTrackerApi/Services/JobImport/JobImportService.cs index b112c59..dd69ef3 100644 --- a/JobTrackerApi/Services/JobImport/JobImportService.cs +++ b/JobTrackerApi/Services/JobImport/JobImportService.cs @@ -15,32 +15,38 @@ public sealed class JobImportService private readonly UniversalJobParser _universal; private readonly IEnumerable _plugins; private readonly ITranslationService _translation; + private readonly IHostAddressResolver _hostAddressResolver; public JobImportService( IHttpClientFactory httpClientFactory, UniversalJobParser universal, IEnumerable plugins, - ITranslationService translation) + ITranslationService translation, + IHostAddressResolver hostAddressResolver) { _httpClientFactory = httpClientFactory; _universal = universal; _plugins = plugins; _translation = translation; + _hostAddressResolver = hostAddressResolver; } public async Task 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 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); + } }