From 7d6b70871bad2a3ff810825f7382c49f55293943 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:53:21 +0100 Subject: [PATCH] url fixes for access issues (#4013) # Description of Changes This pull request introduces a new SSRF (Server-Side Request Forgery) protection mechanism for URL handling in the application. Key changes include adding a dedicated `SsrfProtectionService`, integrating SSRF-safe policies into HTML sanitization, and extending application settings to support configurable URL security options. ### SSRF Protection Implementation: * **`SsrfProtectionService`**: Added a new service to handle SSRF protection with configurable levels (`OFF`, `MEDIUM`, `MAX`) and checks for private networks, localhost, link-local addresses, and cloud metadata endpoints (`app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java`). ### Application Configuration Enhancements: * **`ApplicationProperties`**: Introduced a new `Html` configuration class with nested `UrlSecurity` settings, allowing fine-grained control over URL security, including allowed/blocked domains and internal TLDs (`app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java`). [[1]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R293) [[2]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R346-R364) * **`settings.yml.template`**: Updated the configuration template to include the new `html.urlSecurity` settings, enabling users to customize SSRF protection behavior (`app/core/src/main/resources/settings.yml.template`). ### HTML Sanitization Updates: * **`CustomHtmlSanitizer`**: Integrated SSRF-safe URL validation into the HTML sanitizer by using the `SsrfProtectionService`. Added a custom policy for validating `img` tags' `src` attributes (`app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java`). --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: a Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../common/model/ApplicationProperties.java | 20 ++ .../common/service/SsrfProtectionService.java | 208 ++++++++++++++++++ .../common/util/CustomHtmlSanitizer.java | 60 ++++- .../software/common/util/EmlToPdf.java | 22 +- .../software/common/util/FileToPdf.java | 21 +- .../common/util/CustomHtmlSanitizerTest.java | 53 +++-- .../software/common/util/EmlToPdfTest.java | 63 ++++-- .../software/common/util/FileToPdfTest.java | 25 ++- .../api/converters/ConvertEmlToPDF.java | 6 +- .../api/converters/ConvertHtmlToPDF.java | 13 +- .../api/converters/ConvertMarkdownToPdf.java | 13 +- .../converters/ConvertOfficeController.java | 15 +- .../src/main/resources/settings.yml.template | 11 + testing/allEndpointsRemovedSettings.yml | 16 +- 14 files changed, 462 insertions(+), 84 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..97c2da12e --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java @@ -0,0 +1,208 @@ +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.") + || "0.0.0.0".equals(ip); + } + + 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) { + // Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud + return ip.startsWith("169.254.169.254") // AWS/GCP/Azure + || ip.startsWith("fd00:ec2::254") // AWS IPv6 + || ip.startsWith("169.254.169.253") // Oracle Cloud + || ip.startsWith("169.254.169.250"); // IBM Cloud + } +} 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..05d9b73a6 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,71 @@ 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.model.ApplicationProperties; +import stirling.software.common.service.SsrfProtectionService; + +@Component public class CustomHtmlSanitizer { - private static final PolicyFactory POLICY = + + private final SsrfProtectionService ssrfProtectionService; + private final ApplicationProperties applicationProperties; + + @Autowired + public CustomHtmlSanitizer( + SsrfProtectionService ssrfProtectionService, + ApplicationProperties applicationProperties) { + this.ssrfProtectionService = ssrfProtectionService; + this.applicationProperties = applicationProperties; + } + + private 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 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 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; + public String sanitize(String html) { + boolean disableSanitize = + Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize()); + return disableSanitize ? html : POLICY.sanitize(html); } } diff --git a/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java b/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java index 05e9cec5c..6b28dc683 100644 --- a/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java +++ b/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java @@ -133,9 +133,9 @@ public class EmlToPdf { EmlToPdfRequest request, byte[] emlBytes, String fileName, - boolean disableSanitize, stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory, - TempFileManager tempFileManager) + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException, InterruptedException { validateEmlInput(emlBytes); @@ -155,7 +155,11 @@ public class EmlToPdf { // Convert HTML to PDF byte[] pdfBytes = convertHtmlToPdf( - weasyprintPath, request, htmlContent, disableSanitize, tempFileManager); + weasyprintPath, + request, + htmlContent, + tempFileManager, + customHtmlSanitizer); // Attach files if available and requested if (shouldAttachFiles(emailContent, request)) { @@ -196,8 +200,8 @@ public class EmlToPdf { String weasyprintPath, EmlToPdfRequest request, String htmlContent, - boolean disableSanitize, - TempFileManager tempFileManager) + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException, InterruptedException { HTMLToPdfRequest htmlRequest = createHtmlRequest(request); @@ -208,8 +212,8 @@ public class EmlToPdf { htmlRequest, htmlContent.getBytes(StandardCharsets.UTF_8), "email.html", - disableSanitize, - tempFileManager); + tempFileManager, + customHtmlSanitizer); } catch (IOException | InterruptedException e) { log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML"); String simplifiedHtml = simplifyHtmlContent(htmlContent); @@ -218,8 +222,8 @@ public class EmlToPdf { htmlRequest, simplifiedHtml.getBytes(StandardCharsets.UTF_8), "email.html", - disableSanitize, - tempFileManager); + tempFileManager, + customHtmlSanitizer); } } diff --git a/app/common/src/main/java/stirling/software/common/util/FileToPdf.java b/app/common/src/main/java/stirling/software/common/util/FileToPdf.java index c735e5287..799f91e05 100644 --- a/app/common/src/main/java/stirling/software/common/util/FileToPdf.java +++ b/app/common/src/main/java/stirling/software/common/util/FileToPdf.java @@ -26,8 +26,8 @@ public class FileToPdf { HTMLToPdfRequest request, byte[] fileBytes, String fileName, - boolean disableSanitize, - TempFileManager tempFileManager) + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException, InterruptedException { try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) { @@ -39,14 +39,15 @@ public class FileToPdf { if (fileName.toLowerCase().endsWith(".html")) { String sanitizedHtml = sanitizeHtmlContent( - new String(fileBytes, StandardCharsets.UTF_8), disableSanitize); + new String(fileBytes, StandardCharsets.UTF_8), + customHtmlSanitizer); Files.write( tempInputFile.getPath(), sanitizedHtml.getBytes(StandardCharsets.UTF_8)); } else if (fileName.toLowerCase().endsWith(".zip")) { Files.write(tempInputFile.getPath(), fileBytes); sanitizeHtmlFilesInZip( - tempInputFile.getPath(), disableSanitize, tempFileManager); + tempInputFile.getPath(), tempFileManager, customHtmlSanitizer); } else { throw ExceptionUtils.createHtmlFileRequiredException(); } @@ -78,12 +79,15 @@ public class FileToPdf { } // tempOutputFile auto-closed } - private static String sanitizeHtmlContent(String htmlContent, boolean disableSanitize) { - return (!disableSanitize) ? CustomHtmlSanitizer.sanitize(htmlContent) : htmlContent; + private static String sanitizeHtmlContent( + String htmlContent, CustomHtmlSanitizer customHtmlSanitizer) { + return customHtmlSanitizer.sanitize(htmlContent); } private static void sanitizeHtmlFilesInZip( - Path zipFilePath, boolean disableSanitize, TempFileManager tempFileManager) + Path zipFilePath, + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException { try (TempDirectory tempUnzippedDir = new TempDirectory(tempFileManager)) { try (ZipInputStream zipIn = @@ -99,7 +103,8 @@ public class FileToPdf { || entry.getName().toLowerCase().endsWith(".htm")) { String content = new String(zipIn.readAllBytes(), StandardCharsets.UTF_8); - String sanitizedContent = sanitizeHtmlContent(content, disableSanitize); + String sanitizedContent = + sanitizeHtmlContent(content, customHtmlSanitizer); Files.write( filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8)); } else { diff --git a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java index 65bffe05e..59e5f81b1 100644 --- a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java +++ b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java @@ -3,21 +3,42 @@ package stirling.software.common.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import stirling.software.common.service.SsrfProtectionService; + class CustomHtmlSanitizerTest { + private CustomHtmlSanitizer customHtmlSanitizer; + + @BeforeEach + void setUp() { + SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class); + stirling.software.common.model.ApplicationProperties mockApplicationProperties = mock(stirling.software.common.model.ApplicationProperties.class); + stirling.software.common.model.ApplicationProperties.System mockSystem = mock(stirling.software.common.model.ApplicationProperties.System.class); + + // Allow all URLs by default for basic tests + when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); + when(mockSystem.getDisableSanitize()).thenReturn(false); // Enable sanitization for tests + + customHtmlSanitizer = new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); + } + @ParameterizedTest @MethodSource("provideHtmlTestCases") void testSanitizeHtml(String inputHtml, String[] expectedContainedTags) { // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(inputHtml); + String sanitizedHtml = customHtmlSanitizer.sanitize(inputHtml); // Assert for (String tag : expectedContainedTags) { @@ -58,7 +79,7 @@ class CustomHtmlSanitizerTest { "

Styled text

"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithStyles); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithStyles); // Assert // The OWASP HTML Sanitizer might filter some specific styles, so we only check that @@ -75,7 +96,7 @@ class CustomHtmlSanitizerTest { "
Example Link"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithLink); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithLink); // Assert // The most important aspect is that the link content is preserved @@ -97,7 +118,7 @@ class CustomHtmlSanitizerTest { String htmlWithJsLink = "Malicious Link"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithJsLink); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithJsLink); // Assert assertFalse(sanitizedHtml.contains("javascript:"), "JavaScript URLs should be removed"); @@ -116,7 +137,7 @@ class CustomHtmlSanitizerTest { + ""; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithTable); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithTable); // Assert assertTrue(sanitizedHtml.contains(""; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithImage); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithImage); // Assert assertTrue(sanitizedHtml.contains(""; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithDataUrlImage); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithDataUrlImage); // Assert assertFalse( @@ -175,7 +196,7 @@ class CustomHtmlSanitizerTest { "Click me"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithJsEvent); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithJsEvent); // Assert assertFalse( @@ -192,7 +213,7 @@ class CustomHtmlSanitizerTest { String htmlWithScript = "

Safe content

"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithScript); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithScript); // Assert assertFalse(sanitizedHtml.contains("