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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user