Compare commits

...
4 changed files with 133 additions and 17 deletions
@@ -337,6 +337,33 @@ public class GeneralUtils {
}
}
/**
* Checks whether a URL is safe for server-side fetches without making a network request.
*
* @param urlStr the URL to validate
* @return {@code true} if the URL uses http/https and resolves to a non-sensitive address
*/
public boolean isUrlAllowedForServerFetch(String urlStr) {
if (urlStr == null || urlStr.isBlank()) {
return false;
}
try {
URL url = URI.create(urlStr).toURL();
String protocol = url.getProtocol();
if (!"http".equals(protocol) && !"https".equals(protocol)) {
return false;
}
String host = url.getHost();
if (host == null || host.isBlank()) {
return false;
}
return !isDisallowedNetworkLocation(host);
} catch (Exception e) {
log.debug("URL {} is not allowed for server fetch: {}", urlStr, e.getMessage());
return false;
}
}
/**
* Determines whether the specified host resolves to a disallowed network location, such as
* local, private, multicast, or reserved ranges. Excessive DNS results are also blocked.
@@ -567,6 +594,7 @@ public class GeneralUtils {
default -> toBytes(value, 2); // Default to MB
};
}
return bytes;
} catch (NumberFormatException e) {
log.warn("Failed to parse size string '{}': {}", sizeStr, e.getMessage());
return null;
@@ -613,6 +641,44 @@ public class GeneralUtils {
return unit != null && VALID_SIZE_UNITS.contains(unit.toUpperCase(Locale.ROOT));
}
private Long parseSizeToBytes(String sizeStr, String defaultUnit) {
if (sizeStr.endsWith("TB")) {
return convertDecimalToBytes(sizeStr.substring(0, sizeStr.length() - 2), 1024L * 1024L
* 1024L * 1024L);
} else if (sizeStr.endsWith("GB")) {
return convertDecimalToBytes(
sizeStr.substring(0, sizeStr.length() - 2), 1024L * 1024L * 1024L);
} else if (sizeStr.endsWith("MB")) {
return convertDecimalToBytes(sizeStr.substring(0, sizeStr.length() - 2), 1024L * 1024L);
} else if (sizeStr.endsWith("KB")) {
return convertDecimalToBytes(sizeStr.substring(0, sizeStr.length() - 2), 1024L);
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
return convertDecimalToBytes(sizeStr.substring(0, sizeStr.length() - 1), 1L);
}
// Use provided default unit or fall back to MB
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB";
return switch (unit) {
case "TB" -> convertDecimalToBytes(sizeStr, 1024L * 1024L * 1024L * 1024L);
case "GB" -> convertDecimalToBytes(sizeStr, 1024L * 1024L * 1024L);
case "MB" -> convertDecimalToBytes(sizeStr, 1024L * 1024L);
case "KB" -> convertDecimalToBytes(sizeStr, 1024L);
case "B" -> convertDecimalToBytes(sizeStr, 1L);
default -> convertDecimalToBytes(sizeStr, 1024L * 1024L); // Default to MB
};
}
private Long convertDecimalToBytes(String value, long multiplier) {
BigDecimal parsed = new BigDecimal(value);
if (parsed.signum() < 0) {
return null;
}
BigDecimal bytes = parsed.multiply(BigDecimal.valueOf(multiplier));
if (bytes.compareTo(BigDecimal.valueOf(Long.MAX_VALUE)) > 0) {
return null;
}
return bytes.setScale(0, RoundingMode.DOWN).longValue();
}
/* Enhanced byte formatting with TB/PB support and better precision. */
public String formatBytes(long bytes) {
if (bytes < 0) {
@@ -82,7 +82,13 @@ public class SplitPdfBySizeController {
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
Long maxBytes = GeneralUtils.convertSizeToBytes(value);
if (maxBytes == null || maxBytes <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"split value: " + value);
}
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} else if (type == 1) {
@@ -54,6 +54,7 @@ public class ConvertWebsiteToPDF {
Pattern.compile("(?<![a-z0-9_])file\\s*:(?:/{1,3}|%2f|%5c|%3a|&#x2f;|&#47;)");
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
private static final int MAX_REDIRECTS = 5;
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@Operation(
@@ -86,6 +87,12 @@ public class ConvertWebsiteToPDF {
.queryParam("error", "error.invalidUrlFormat")
.build()
.toUri();
} else if (!GeneralUtils.isUrlAllowedForServerFetch(URL)) {
location =
uriComponentsBuilder
.queryParam("error", "error.invalidUrlFormat")
.build()
.toUri();
} else if (!GeneralUtils.isURLReachable(URL)) {
// validate the URL is reachable
location =
@@ -166,29 +173,60 @@ public class ConvertWebsiteToPDF {
private String fetchRemoteHtml(String url) throws IOException, InterruptedException {
HttpClient client =
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request =
HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(20))
.GET()
.header("User-Agent", "Stirling-PDF/URL-to-PDF")
.build();
URI currentUri = URI.create(url);
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
String currentUrl = currentUri.toString();
if (!GeneralUtils.isUrlAllowedForServerFetch(currentUrl)) {
throw ExceptionUtils.createIOException(
"error.invalidUrlFormat",
"Disallowed URL target: {0}",
null,
currentUrl);
}
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpRequest request =
HttpRequest.newBuilder(currentUri)
.timeout(Duration.ofSeconds(20))
.GET()
.header("User-Agent", "Stirling-PDF/URL-to-PDF")
.build();
if (response.statusCode() >= 400 || response.body() == null) {
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Failed to retrieve remote HTML. Status: {0}",
null,
response.statusCode());
HttpResponse<String> response =
client.send(
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
int statusCode = response.statusCode();
if (statusCode >= 300 && statusCode < 400) {
String location = response.headers().firstValue("location").orElse(null);
if (location == null || location.isBlank()) {
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Redirect response missing Location header",
null);
}
currentUri = currentUri.resolve(location);
continue;
}
if (statusCode >= 400 || response.body() == null) {
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Failed to retrieve remote HTML. Status: {0}",
null,
statusCode);
}
return response.body();
}
return response.body();
throw ExceptionUtils.createIOException(
"error.httpRequestFailed",
"Too many redirects while retrieving remote HTML",
null);
}
private boolean containsDisallowedUriScheme(String htmlContent) {
@@ -948,6 +948,12 @@ public class CompressController {
boolean autoMode = false;
if (expectedOutputSizeString != null && expectedOutputSizeString.length() > 1) {
expectedOutputSize = GeneralUtils.convertSizeToBytes(expectedOutputSizeString);
if (expectedOutputSize == null || expectedOutputSize <= 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"expected output size: " + expectedOutputSizeString);
}
autoMode = true;
}