Harden job import SSRF validation
This commit is contained in:
@@ -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<IHostAddressResolver>();
|
||||||
|
resolver
|
||||||
|
.Setup(x => x.ResolveAsync("127.0.0.1.nip.io", It.IsAny<CancellationToken>()))
|
||||||
|
.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<IHostAddressResolver>();
|
||||||
|
resolver
|
||||||
|
.Setup(x => x.ResolveAsync("internal.example.test", It.IsAny<CancellationToken>()))
|
||||||
|
.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<IHostAddressResolver>();
|
||||||
|
resolver
|
||||||
|
.Setup(x => x.ResolveAsync("example.com", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[] { IPAddress.Parse("93.184.216.34") });
|
||||||
|
|
||||||
|
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("<html><body>no schema</body></html>")
|
||||||
|
});
|
||||||
|
|
||||||
|
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("<html></html>")
|
||||||
|
});
|
||||||
|
|
||||||
|
var httpClient = new HttpClient(handler, disposeHandler: true);
|
||||||
|
var factory = new Mock<IHttpClientFactory>();
|
||||||
|
factory.Setup(x => x.CreateClient("jobimport")).Returns(httpClient);
|
||||||
|
|
||||||
|
return new JobImportService(
|
||||||
|
factory.Object,
|
||||||
|
new UniversalJobParser(),
|
||||||
|
Array.Empty<IJobSitePlugin>(),
|
||||||
|
new NoOpTranslationService(),
|
||||||
|
resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||||
|
|
||||||
|
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(_handler(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,8 @@ builder.Services.AddHostedService<CvProcessingHostedService>();
|
|||||||
builder.Services.AddHttpClient("jobimport")
|
builder.Services.AddHttpClient("jobimport")
|
||||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
{
|
{
|
||||||
AutomaticDecompression = DecompressionMethods.All
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
|
AllowAutoRedirect = false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
|
// 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.AddScoped<ITokenService, TokenService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<UniversalJobParser>();
|
builder.Services.AddSingleton<UniversalJobParser>();
|
||||||
|
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
|
||||||
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
|
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
|
||||||
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
|
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
|
||||||
builder.Services.AddSingleton<IJobSitePlugin, LinkedInPlugin>();
|
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 UniversalJobParser _universal;
|
||||||
private readonly IEnumerable<IJobSitePlugin> _plugins;
|
private readonly IEnumerable<IJobSitePlugin> _plugins;
|
||||||
private readonly ITranslationService _translation;
|
private readonly ITranslationService _translation;
|
||||||
|
private readonly IHostAddressResolver _hostAddressResolver;
|
||||||
|
|
||||||
public JobImportService(
|
public JobImportService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
UniversalJobParser universal,
|
UniversalJobParser universal,
|
||||||
IEnumerable<IJobSitePlugin> plugins,
|
IEnumerable<IJobSitePlugin> plugins,
|
||||||
ITranslationService translation)
|
ITranslationService translation,
|
||||||
|
IHostAddressResolver hostAddressResolver)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_universal = universal;
|
_universal = universal;
|
||||||
_plugins = plugins;
|
_plugins = plugins;
|
||||||
_translation = translation;
|
_translation = translation;
|
||||||
|
_hostAddressResolver = hostAddressResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<JobImportResult> PreviewAsync(string url, CancellationToken cancellationToken)
|
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
|
return new JobImportResult
|
||||||
{
|
{
|
||||||
SourceUrl = url ?? "",
|
SourceUrl = url ?? "",
|
||||||
Success = false,
|
Success = false,
|
||||||
Parser = "none",
|
Parser = "none",
|
||||||
Error = error
|
Error = validation.Error
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var normalized = validation.Normalized;
|
||||||
|
|
||||||
var html = await FetchHtmlAsync(normalized, cancellationToken);
|
var html = await FetchHtmlAsync(normalized, cancellationToken);
|
||||||
if (html is null)
|
if (html is null)
|
||||||
{
|
{
|
||||||
@@ -124,62 +130,88 @@ public sealed class JobImportService
|
|||||||
return System.Text.Encoding.UTF8.GetString(bytes);
|
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))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
{
|
{
|
||||||
error = "URL is required.";
|
return UrlValidationResult.Reject("URL is required.");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri))
|
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri))
|
||||||
{
|
{
|
||||||
error = "Invalid URL.";
|
return UrlValidationResult.Reject("Invalid URL.");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uri.Scheme is not ("http" or "https"))
|
if (uri.Scheme is not ("http" or "https"))
|
||||||
{
|
{
|
||||||
error = "Only http/https URLs are supported.";
|
return UrlValidationResult.Reject("Only http/https URLs are supported.");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
if (uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
error = "Local URLs are not allowed.";
|
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block literal private IPs.
|
|
||||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||||
{
|
{
|
||||||
if (IsPrivateIp(ip))
|
if (IsBlockedAddress(ip))
|
||||||
{
|
{
|
||||||
error = "Private IP URLs are not allowed.";
|
return UrlValidationResult.Reject("Local or private network URLs are not allowed.");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return UrlValidationResult.Allow(uri.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized = uri.ToString();
|
IPAddress[] addresses;
|
||||||
return true;
|
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)
|
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||||
{
|
{
|
||||||
var b = ip.GetAddressBytes();
|
var b = ip.GetAddressBytes();
|
||||||
return b[0] == 10 ||
|
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] == 172 && b[1] >= 16 && b[1] <= 31) ||
|
||||||
(b[0] == 192 && b[1] == 168) ||
|
(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)
|
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;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user