From d338ac88fd300577e05c543c5f7d33386e8ffbe9 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Mon, 21 Jul 2025 14:40:07 +0100 Subject: [PATCH] url fixes --- .../common/model/ApplicationProperties.java | 20 ++ .../common/service/SsrfProtectionService.java | 205 ++++++++++++++++++ .../common/util/CustomHtmlSanitizer.java | 49 ++++- .../converters/ConvertImgPDFController.java | 4 +- .../api/misc/ExtractImageScansController.java | 6 +- .../api/misc/PrintFileController.java | 3 +- .../controller/api/misc/StampController.java | 1 - .../api/pipeline/PipelineProcessor.java | 3 +- .../api/security/WatermarkController.java | 4 +- .../src/main/resources/settings.yml.template | 11 + 10 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index e4edf2baa..91b328759 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -290,6 +290,7 @@ public class ApplicationProperties { private Datasource datasource; private Boolean disableSanitize; private Boolean enableUrlToPDF; + private Html html = new Html(); private CustomPaths customPaths = new CustomPaths(); private String fileUploadLimit; private TempFileManagement tempFileManagement = new TempFileManagement(); @@ -342,6 +343,25 @@ public class ApplicationProperties { } } + @Data + public static class Html { + private UrlSecurity urlSecurity = new UrlSecurity(); + + @Data + public static class UrlSecurity { + private boolean enabled = true; + private String level = "MEDIUM"; // MAX, MEDIUM, OFF + private List allowedDomains = new ArrayList<>(); + private List blockedDomains = new ArrayList<>(); + private List internalTlds = + Arrays.asList(".local", ".internal", ".corp", ".home"); + private boolean blockPrivateNetworks = true; + private boolean blockLocalhost = true; + private boolean blockLinkLocal = true; + private boolean blockCloudMetadata = true; + } + } + @Data public static class Datasource { private boolean enableCustomDatabase; diff --git a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java new file mode 100644 index 000000000..6b18cc81b --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java @@ -0,0 +1,205 @@ +package stirling.software.common.service; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SsrfProtectionService { + + private final ApplicationProperties applicationProperties; + + private static final Pattern DATA_URL_PATTERN = + Pattern.compile("^data:.*", Pattern.CASE_INSENSITIVE); + private static final Pattern FRAGMENT_PATTERN = Pattern.compile("^#.*"); + + public enum SsrfProtectionLevel { + OFF, // No SSRF protection - allows all URLs + MEDIUM, // Block internal networks but allow external URLs + MAX // Block all external URLs - only data: and fragments + } + + public boolean isUrlAllowed(String url) { + ApplicationProperties.Html.UrlSecurity config = + applicationProperties.getSystem().getHtml().getUrlSecurity(); + + if (!config.isEnabled()) { + return true; + } + + if (url == null || url.trim().isEmpty()) { + return false; + } + + String trimmedUrl = url.trim(); + + // Always allow data URLs and fragments + if (DATA_URL_PATTERN.matcher(trimmedUrl).matches() + || FRAGMENT_PATTERN.matcher(trimmedUrl).matches()) { + return true; + } + + SsrfProtectionLevel level = parseProtectionLevel(config.getLevel()); + + switch (level) { + case OFF: + return true; + case MAX: + return isMaxSecurityAllowed(trimmedUrl, config); + case MEDIUM: + return isMediumSecurityAllowed(trimmedUrl, config); + default: + return false; + } + } + + private SsrfProtectionLevel parseProtectionLevel(String level) { + try { + return SsrfProtectionLevel.valueOf(level.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid SSRF protection level '{}', defaulting to MEDIUM", level); + return SsrfProtectionLevel.MEDIUM; + } + } + + private boolean isMaxSecurityAllowed( + String url, ApplicationProperties.Html.UrlSecurity config) { + // MAX security: only allow explicitly whitelisted domains + try { + URI uri = new URI(url); + String host = uri.getHost(); + + if (host == null) { + return false; + } + + return config.getAllowedDomains().contains(host.toLowerCase()); + + } catch (Exception e) { + log.debug("Failed to parse URL for MAX security check: {}", url, e); + return false; + } + } + + private boolean isMediumSecurityAllowed( + String url, ApplicationProperties.Html.UrlSecurity config) { + try { + URI uri = new URI(url); + String host = uri.getHost(); + + if (host == null) { + return false; + } + + String hostLower = host.toLowerCase(); + + // Check explicit blocked domains + if (config.getBlockedDomains().contains(hostLower)) { + log.debug("URL blocked by explicit domain blocklist: {}", url); + return false; + } + + // Check internal TLD patterns + for (String tld : config.getInternalTlds()) { + if (hostLower.endsWith(tld.toLowerCase())) { + log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url); + return false; + } + } + + // If allowedDomains is specified, only allow those + if (!config.getAllowedDomains().isEmpty()) { + boolean isAllowed = + config.getAllowedDomains().stream() + .anyMatch( + domain -> + hostLower.equals(domain.toLowerCase()) + || hostLower.endsWith( + "." + domain.toLowerCase())); + + if (!isAllowed) { + log.debug("URL not in allowed domains list: {}", url); + return false; + } + } + + // Resolve hostname to IP address for network-based checks + try { + InetAddress address = InetAddress.getByName(host); + + if (config.isBlockPrivateNetworks() && isPrivateAddress(address)) { + log.debug("URL blocked - private network address: {}", url); + return false; + } + + if (config.isBlockLocalhost() && address.isLoopbackAddress()) { + log.debug("URL blocked - localhost address: {}", url); + return false; + } + + if (config.isBlockLinkLocal() && address.isLinkLocalAddress()) { + log.debug("URL blocked - link-local address: {}", url); + return false; + } + + if (config.isBlockCloudMetadata() + && isCloudMetadataAddress(address.getHostAddress())) { + log.debug("URL blocked - cloud metadata endpoint: {}", url); + return false; + } + + } catch (UnknownHostException e) { + log.debug("Failed to resolve hostname for SSRF check: {}", host, e); + return false; + } + + return true; + + } catch (Exception e) { + log.debug("Failed to parse URL for MEDIUM security check: {}", url, e); + return false; + } + } + + private boolean isPrivateAddress(InetAddress address) { + return address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || isPrivateIPv4Range(address.getHostAddress()); + } + + private boolean isPrivateIPv4Range(String ip) { + return ip.startsWith("10.") + || ip.startsWith("192.168.") + || (ip.startsWith("172.") && isInRange172(ip)) + || ip.startsWith("127.") + || ip.equals("0.0.0.0"); + } + + private boolean isInRange172(String ip) { + String[] parts = ip.split("\\."); + if (parts.length >= 2) { + try { + int secondOctet = Integer.parseInt(parts[1]); + return secondOctet >= 16 && secondOctet <= 31; + } catch (NumberFormatException e) { + return false; + } + } + return false; + } + + private boolean isCloudMetadataAddress(String ip) { + // AWS/GCP/Azure metadata endpoints + return ip.startsWith("169.254.169.254") || ip.startsWith("fd00:ec2::254"); + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java index e5fe0436a..8dc1e0fe9 100644 --- a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java +++ b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java @@ -1,21 +1,64 @@ package stirling.software.common.util; +import org.owasp.html.AttributePolicy; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import stirling.software.common.service.SsrfProtectionService; + +@Component public class CustomHtmlSanitizer { + + private static SsrfProtectionService ssrfProtectionService; + + @Autowired + public void setSsrfProtectionService(SsrfProtectionService ssrfProtectionService) { + CustomHtmlSanitizer.ssrfProtectionService = ssrfProtectionService; + } + + private static final AttributePolicy SSRF_SAFE_URL_POLICY = + new AttributePolicy() { + @Override + public String apply(String elementName, String attributeName, String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + + String trimmedValue = value.trim(); + + // Use the SSRF protection service to validate the URL + if (ssrfProtectionService != null + && !ssrfProtectionService.isUrlAllowed(trimmedValue)) { + return null; + } + + return trimmedValue; + } + }; + + private static final PolicyFactory SSRF_SAFE_IMAGES_POLICY = + new HtmlPolicyBuilder() + .allowElements("img") + .allowAttributes("alt", "width", "height", "title") + .onElements("img") + .allowAttributes("src") + .matching(SSRF_SAFE_URL_POLICY) + .onElements("img") + .toFactory(); + private static final PolicyFactory POLICY = Sanitizers.FORMATTING .and(Sanitizers.BLOCKS) .and(Sanitizers.STYLES) .and(Sanitizers.LINKS) .and(Sanitizers.TABLES) - .and(Sanitizers.IMAGES) + .and(SSRF_SAFE_IMAGES_POLICY) .and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory()); public static String sanitize(String html) { - String htmlAfter = POLICY.sanitize(html); - return htmlAfter; + return POLICY.sanitize(html); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 8efd983b0..5eff72a4a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -56,8 +56,8 @@ public class ConvertImgPDFController { summary = "Convert PDF to image(s)", description = "This endpoint converts a PDF file to image(s) with the specified image format," - + " color type, and DPI. Users can choose to get a single image or multiple" - + " images. Input:PDF Output:Image Type:SI-Conditional") + + " color type, and DPI. Users can choose to get a single image or multiple" + + " images. Input:PDF Output:Image Type:SI-Conditional") public ResponseEntity convertToImage(@ModelAttribute ConvertToImageRequest request) throws Exception { MultipartFile file = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java index 1a0ae7516..3992595ab 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImageScansController.java @@ -55,9 +55,9 @@ public class ExtractImageScansController { summary = "Extract image scans from an input file", description = "This endpoint extracts image scans from a given file based on certain" - + " parameters. Users can specify angle threshold, tolerance, minimum area," - + " minimum contour area, and border size. Input:PDF Output:IMAGE/ZIP" - + " Type:SIMO") + + " parameters. Users can specify angle threshold, tolerance, minimum area," + + " minimum contour area, and border size. Input:PDF Output:IMAGE/ZIP" + + " Type:SIMO") public ResponseEntity extractImageScans( @ModelAttribute ExtractImageScansRequest request) throws IOException, InterruptedException { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java index e572432df..fc7b7d298 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java @@ -47,7 +47,8 @@ public class PrintFileController { throws IOException { MultipartFile file = request.getFileInput(); String originalFilename = file.getOriginalFilename(); - if (originalFilename != null && (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) { + if (originalFilename != null + && (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) { throw new IOException("Invalid file path detected: " + originalFilename); } String printerName = request.getPrinterName(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java index 7d4b2e3c9..f5bc9dc65 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java @@ -42,7 +42,6 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; -import java.lang.IllegalArgumentException; @RestController @RequestMapping("/api/v1/misc") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java index d79105c26..44f2b892a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java @@ -331,7 +331,8 @@ public class PipelineProcessor { for (File file : files) { Path normalizedPath = Paths.get(file.getName()).normalize(); if (normalizedPath.startsWith("..")) { - throw new SecurityException("Potential path traversal attempt in file name: " + file.getName()); + throw new SecurityException( + "Potential path traversal attempt in file name: " + file.getName()); } Path path = Paths.get(file.getAbsolutePath()); // debug statement diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index fd5a9b288..484a1c116 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -83,7 +83,9 @@ public class WatermarkController { MultipartFile watermarkImage = request.getWatermarkImage(); if (watermarkImage != null) { String watermarkImageFileName = watermarkImage.getOriginalFilename(); - if (watermarkImageFileName != null && (watermarkImageFileName.contains("..") || watermarkImageFileName.startsWith("/"))) { + if (watermarkImageFileName != null + && (watermarkImageFileName.contains("..") + || watermarkImageFileName.startsWith("/"))) { throw new SecurityException("Invalid file path in watermarkImage"); } } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a26f256f7..38d79baea 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -108,6 +108,17 @@ system: enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) + html: + urlSecurity: + enabled: true # Enable URL security restrictions for HTML processing + level: MEDIUM # Security level: MAX (whitelist only), MEDIUM (block internal networks), OFF (no restrictions) + allowedDomains: [] # Whitelist of allowed domains (e.g. ['cdn.example.com', 'images.google.com']) + blockedDomains: [] # Additional domains to block (e.g. ['evil.com', 'malicious.org']) + internalTlds: ['.local', '.internal', '.corp', '.home'] # Block domains with these TLD patterns + blockPrivateNetworks: true # Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x) + blockLocalhost: true # Block localhost and loopback addresses (127.x.x.x, ::1) + blockLinkLocal: true # Block link-local addresses (169.254.x.x, fe80::/10) + blockCloudMetadata: true # Block cloud provider metadata endpoints (169.254.169.254) datasource: enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration customDatabaseUrl: '' # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used