Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 3 additions & 73 deletions src/main/java/com/mindee/input/URLInputSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,13 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Locale;
import lombok.Getter;

/**
Expand Down Expand Up @@ -62,78 +57,13 @@ public static Builder builder(URL url) {
* Ensures the URL can be safely sent to the Mindee server.
*
* <p>
* Rejects any URL that could be used for Server-Side Request Forgery (SSRF):
* <ul>
* <li>non-HTTPS schemes,</li>
* <li>embedded userinfo (e.g. {@code https://user:pass@host}),</li>
* <li>loopback hostnames ({@code localhost}, {@code *.localhost}),</li>
* <li>hosts that resolve to loopback, link-local, site-local (RFC 1918),
* any-local ({@code 0.0.0.0}), multicast, IPv6 unique-local
* ({@code fc00::/7}) or carrier-grade NAT ({@code 100.64.0.0/10})
* addresses.</li>
* </ul>
* Rejects any URL that don't follow an HTTPS scheme.
* </p>
*/
public void validateSecure() {
if (!"https".equalsIgnoreCase(this.url.getProtocol())) {
throw new MindeeException("Only HTTPS source URLs are allowed");
}
String userInfo = this.url.getUserInfo();
if (userInfo != null && !userInfo.isEmpty()) {
throw new MindeeException("Source URLs must not embed user credentials");
}
String host = this.url.getHost();
if (host == null || host.isEmpty()) {
throw new MindeeException("Source URL is missing a host");
}
String lowerHost = host.toLowerCase(Locale.ROOT);
if (
"localhost".equals(lowerHost)
|| lowerHost.endsWith(".localhost")
|| "ip6-localhost".equals(lowerHost)
|| "ip6-loopback".equals(lowerHost)
) {
throw new MindeeException("Loopback hostnames are not allowed: " + host);
}

InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new MindeeException("Unable to resolve source URL host: " + host, e);
}
for (InetAddress addr : addresses) {
if (isDisallowedAddress(addr)) {
throw new MindeeException(
"Source URL host resolves to a disallowed address: " + addr.getHostAddress()
);
}
}
}

private static boolean isDisallowedAddress(InetAddress addr) {
return addr.isLoopbackAddress()
|| addr.isLinkLocalAddress()
|| addr.isSiteLocalAddress()
|| addr.isAnyLocalAddress()
|| addr.isMulticastAddress()
|| isUniqueLocalIpv6(addr)
|| isCarrierGradeNat(addr);
}

private static boolean isUniqueLocalIpv6(InetAddress addr) {
if (!(addr instanceof Inet6Address)) {
return false;
}
byte[] raw = addr.getAddress();
return (raw[0] & 0xFE) == 0xFC;
}

private static boolean isCarrierGradeNat(InetAddress addr) {
if (!(addr instanceof Inet4Address)) {
return false;
}
byte[] raw = addr.getAddress();
return (raw[0] & 0xFF) == 100 && (raw[1] & 0xC0) == 0x40;
}

/**
Expand Down Expand Up @@ -191,7 +121,7 @@ private HttpURLConnection handleRedirects(HttpURLConnection connection) throws I
connection.disconnect();

HttpURLConnection newConnection = createConnection(new URL(newUrl));
return handleRedirects(newConnection); // Recursive call to handle multiple redirects
return handleRedirects(newConnection);
}
return connection;
}
Expand Down
5 changes: 2 additions & 3 deletions src/main/java/com/mindee/v2/MindeeClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import com.mindee.v2.http.MindeeHttpExceptionV2;
import com.mindee.v2.parsing.CommonResponse;
import com.mindee.v2.parsing.JobResponse;
import com.mindee.v2.parsing.JobStatus;
import com.mindee.v2.parsing.error.ErrorResponse;
import com.mindee.v2.parsing.search.SearchResponse;
import com.mindee.v2.product.extraction.ExtractionResponse;
Expand Down Expand Up @@ -244,10 +243,10 @@ private <TResponse extends CommonResponse> TResponse pollAndFetch(
interruptibleSleep((long) (currentIntervalSec * 1000), pollingOptions);
resp = getJob(initialJob.getJob().getId());

if (resp.getJob().getStatusEnum() == JobStatus.Failed) {
if (resp.getJob().getStatus().equals("Failed")) {
attempts = max;
}
if (resp.getJob().getStatusEnum() == JobStatus.Processed) {
if (resp.getJob().getStatus().equals("Processed")) {
return getResult(responseClass, resp.getJob().getId());
}
currentIntervalSec = Math.min(currentIntervalSec * backoffMultiplier, maxIntervalSec);
Expand Down
8 changes: 0 additions & 8 deletions src/main/java/com/mindee/v2/parsing/Job.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,6 @@ public final class Job {
@JsonProperty("status")
private String status;

/**
* Typed view of {@link #status}. Returns {@link JobStatus#Unknown} for values
* the SDK doesn't recognise, or {@code null} if the raw status is {@code null}.
*/
public JobStatus getStatusEnum() {
return JobStatus.fromValue(status);
}

/**
* Status of the job.
*/
Expand Down
42 changes: 0 additions & 42 deletions src/main/java/com/mindee/v2/parsing/JobStatus.java

This file was deleted.

71 changes: 0 additions & 71 deletions src/test/java/com/mindee/input/URLInputSourceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,77 +101,6 @@ void httpScheme_isRejected() throws MalformedURLException {
MindeeException e = assertThrows(MindeeException.class, src::validateSecure);
assertTrue(e.getMessage().contains("HTTPS"));
}

@Test
void userInfo_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://user:pass@example.com/file.pdf").build();
MindeeException e = assertThrows(MindeeException.class, src::validateSecure);
assertTrue(e.getMessage().contains("credentials"));
}

@Test
void loopbackHostname_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://localhost/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void subLocalhostHostname_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://foo.localhost/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void loopbackIpv4_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://127.0.0.1/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void loopbackIpv6_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://[::1]/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void anyLocalIpv4_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://0.0.0.0/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void privateRfc1918_isRejected() throws MalformedURLException {
for (String host : new String[] { "10.0.0.1", "172.16.0.1", "192.168.1.1" }) {
var src = URLInputSource.builder("https://" + host + "/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure, "expected rejection for " + host);
}
}

@Test
void linkLocalIpv4_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://169.254.169.254/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void cgnat_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://100.64.0.1/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void uniqueLocalIpv6_isRejected() throws MalformedURLException {
var src = URLInputSource.builder("https://[fd00::1]/file.pdf").build();
assertThrows(MindeeException.class, src::validateSecure);
}

@Test
void unresolvableHost_isRejected() throws MalformedURLException {
var src = URLInputSource
.builder("https://this-host-should-not-exist.invalid/file.pdf")
.build();
assertThrows(MindeeException.class, src::validateSecure);
}
}

static class TestableURLInputSource extends URLInputSource {
Expand Down
2 changes: 1 addition & 1 deletion src/test/java/com/mindee/v2/MindeeClientIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ void getResultFromUrl_mustSucceed() throws IOException, InterruptedException {
for (int i = 0; i < 80 && resultUrl == null; i++) {
Thread.sleep(1500);
var poll = mindeeClient.getJob(jobId);
if (poll.getJob().getStatusEnum() == com.mindee.v2.parsing.JobStatus.Processed) {
if (poll.getJob().getStatus().equals("Processed")) {
resultUrl = poll.getJob().getResultUrl();
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/test/java/com/mindee/v2/parsing/JobTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ void whenProcessing_mustHaveValidProperties() throws IOException {
var job = response.getJob();
assertNotNull(job);
assertEquals("Processing", job.getStatus());
assertEquals(JobStatus.Processing, job.getStatusEnum());
assertNotNull(job.getCreatedAt());
assertNull(job.getCompletedAt());
assertNull(job.getResultUrl());
Expand All @@ -46,7 +45,6 @@ void whenProcessing_mustHaveValidProperties() throws IOException {
var job = response.getJob();
assertNotNull(job);
assertEquals("Processed", job.getStatus());
assertEquals(JobStatus.Processed, job.getStatusEnum());
assertNotNull(job.getCreatedAt());
assertNotNull(job.getCompletedAt());
assertNotNull(job.getResultUrl());
Expand Down
Loading