fix(security): Harden website-to-PDF conversion (#4638)

# Description of Changes

**What was changed**
- Fetch remote HTML content via `HttpClient` before invoking WeasyPrint
to inspect and sanitize input.
- Reject conversions when downloaded HTML contains disallowed `file:`
scheme references (including encoded/obfuscated variants) using a
compiled `Pattern`.
- Write fetched HTML to a secured temporary file and pass that path to
WeasyPrint instead of the remote URL.
- Provide `--base-url` to WeasyPrint so relative resources resolve
correctly while avoiding direct remote fetching as the primary input.
- Add comprehensive unit tests:
- Ensure command invocation uses local temp HTML + `--base-url` and
cleans up temp files.
  - Verify redirect with error when disallowed content is detected.
  - Cover temp file deletion behavior and error handling paths.
- Improve resource cleanup in `finally` blocks for both temp HTML and
output PDF artifacts.

**Why the change was made**
- Prevents traversal/local file exposure risks by blocking `file:` (and
encoded equivalents) discovered in fetched HTML.
- Reduces attack surface of URL-to-PDF by avoiding direct handing of
remote URLs to the renderer and enabling pre-validation.
- Strengthens deterministic behavior of conversions and improves safety
against SSRF-like vectors.

---

## 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.
This commit is contained in:
Ludy 2025-10-16 23:41:04 +02:00 committed by GitHub
parent e40f41d79a
commit 955a26f32b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 216 additions and 28 deletions

View File

@ -2,10 +2,18 @@ package stirling.software.SPDF.controller.api.converters;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -44,6 +52,11 @@ public class ConvertWebsiteToPDF {
private final RuntimePathConfig runtimePathConfig; private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
private static final Pattern FILE_SCHEME_PATTERN =
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]+);");
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@Operation( @Operation(
summary = "Convert a URL to a PDF", summary = "Convert a URL to a PDF",
@ -91,14 +104,33 @@ public class ConvertWebsiteToPDF {
} }
Path tempOutputFile = null; Path tempOutputFile = null;
Path tempHtmlInput = null;
PDDocument doc = null; PDDocument doc = null;
try { try {
// Download the remote content first to ensure we don't allow dangerous schemes
String htmlContent = fetchRemoteHtml(URL);
if (containsDisallowedUriScheme(htmlContent)) {
URI rejectionLocation =
uriComponentsBuilder
.queryParam("error", "error.disallowedUrlContent")
.build()
.toUri();
log.warn("Rejected URL to PDF conversion due to disallowed content references");
return ResponseEntity.status(status).location(rejectionLocation).build();
}
tempHtmlInput = Files.createTempFile("url_input_", ".html");
Files.writeString(tempHtmlInput, htmlContent, StandardCharsets.UTF_8);
// Prepare the output file path // Prepare the output file path
tempOutputFile = Files.createTempFile("output_", ".pdf"); tempOutputFile = Files.createTempFile("output_", ".pdf");
// Prepare the WeasyPrint command // Prepare the WeasyPrint command
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getWeasyPrintPath()); command.add(runtimePathConfig.getWeasyPrintPath());
command.add(tempHtmlInput.toString());
command.add("--base-url");
command.add(URL); command.add(URL);
command.add("--pdf-forms"); command.add("--pdf-forms");
command.add(tempOutputFile.toString()); command.add(tempOutputFile.toString());
@ -120,6 +152,13 @@ public class ConvertWebsiteToPDF {
} }
return response; return response;
} finally { } finally {
if (tempHtmlInput != null) {
try {
Files.deleteIfExists(tempHtmlInput);
} catch (IOException e) {
log.error("Error deleting temporary HTML input file", e);
}
}
if (tempOutputFile != null) { if (tempOutputFile != null) {
try { try {
@ -131,6 +170,90 @@ public class ConvertWebsiteToPDF {
} }
} }
private String fetchRemoteHtml(String url) throws IOException, InterruptedException {
HttpClient client =
HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.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();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400 || response.body() == null) {
throw new IOException(
"Failed to retrieve remote HTML. Status: " + response.statusCode());
}
return response.body();
}
private boolean containsDisallowedUriScheme(String htmlContent) {
if (htmlContent == null || htmlContent.isEmpty()) {
return false;
}
String normalized = normalizeForSchemeDetection(htmlContent);
return FILE_SCHEME_PATTERN.matcher(normalized).find();
}
private String normalizeForSchemeDetection(String htmlContent) {
String lowerCaseContent = htmlContent.toLowerCase(Locale.ROOT);
String decodedHtmlEntities = decodeNumericHtmlEntities(lowerCaseContent);
decodedHtmlEntities =
decodedHtmlEntities
.replace("&colon;", ":")
.replace("&sol;", "/")
.replace("&frasl;", "/");
return percentDecode(decodedHtmlEntities);
}
private String percentDecode(String content) {
StringBuilder result = new StringBuilder(content.length());
for (int i = 0; i < content.length(); i++) {
char current = content.charAt(i);
if (current == '%' && i + 2 < content.length()) {
String hex = content.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
continue;
} catch (NumberFormatException ignored) {
// Fall through to append the literal characters when parsing fails
}
}
result.append(current);
}
return result.toString();
}
private String decodeNumericHtmlEntities(String content) {
Matcher matcher = NUMERIC_HTML_ENTITY_PATTERN.matcher(content);
StringBuffer decoded = new StringBuffer();
while (matcher.find()) {
String entityBody = matcher.group(1);
try {
int radix = entityBody.startsWith("x") ? 16 : 10;
int codePoint =
Integer.parseInt(radix == 16 ? entityBody.substring(1) : entityBody, radix);
matcher.appendReplacement(
decoded, Matcher.quoteReplacement(Character.toString((char) codePoint)));
} catch (NumberFormatException ex) {
matcher.appendReplacement(decoded, matcher.group(0));
}
}
matcher.appendTail(decoded);
return decoded.toString();
}
private String convertURLToFileName(String url) { private String convertURLToFileName(String url) {
String safeName = GeneralUtils.convertToFileName(url); String safeName = GeneralUtils.convertToFileName(url);
if (safeName == null || safeName.isBlank()) { if (safeName == null || safeName.isBlank()) {

View File

@ -194,6 +194,7 @@ error.fileFormatRequired=File must be in {0} format
error.invalidFormat=Invalid {0} format: {1} error.invalidFormat=Invalid {0} format: {1}
error.endpointDisabled=This endpoint has been disabled by the admin error.endpointDisabled=This endpoint has been disabled by the admin
error.urlNotReachable=URL is not reachable, please provide a valid URL error.urlNotReachable=URL is not reachable, please provide a valid URL
error.disallowedUrlContent=URL content references disallowed resources and cannot be converted
error.invalidUrlFormat=Invalid URL format provided. The provided format is invalid. error.invalidUrlFormat=Invalid URL format provided. The provided format is invalid.
# DPI and image rendering messages - used by frontend for dynamic translation # DPI and image rendering messages - used by frontend for dynamic translation

View File

@ -3,14 +3,19 @@ package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.util.List; import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@ -51,18 +56,18 @@ public class ConvertWebsiteToPdfTest {
void setUp() throws Exception { void setUp() throws Exception {
mocks = MockitoAnnotations.openMocks(this); mocks = MockitoAnnotations.openMocks(this);
// Feature einschalten (ggf. Struktur an dein Projekt anpassen) // Enable feature (adjust structure for your project if necessary)
applicationProperties = new ApplicationProperties(); applicationProperties = new ApplicationProperties();
applicationProperties.getSystem().setEnableUrlToPDF(true); applicationProperties.getSystem().setEnableUrlToPDF(true);
// Stubs, falls der Code weiterlaufen sollte // Stubs in case the code continues to run
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint"); when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
when(pdfDocumentFactory.load(any(File.class))).thenReturn(new PDDocument()); when(pdfDocumentFactory.load(any(File.class))).thenReturn(new PDDocument());
// SUT bauen // Build SUT
sut = new ConvertWebsiteToPDF(pdfDocumentFactory, runtimePathConfig, applicationProperties); sut = new ConvertWebsiteToPDF(pdfDocumentFactory, runtimePathConfig, applicationProperties);
// RequestContext für ServletUriComponentsBuilder bereitstellen // Provide RequestContext for ServletUriComponentsBuilder
MockHttpServletRequest req = new MockHttpServletRequest(); MockHttpServletRequest req = new MockHttpServletRequest();
req.setScheme("http"); req.setScheme("http");
req.setServerName("localhost"); req.setServerName("localhost");
@ -94,7 +99,7 @@ public class ConvertWebsiteToPdfTest {
@Test @Test
void redirect_with_error_when_url_is_not_reachable() throws Exception { void redirect_with_error_when_url_is_not_reachable() throws Exception {
UrlToPdfRequest request = new UrlToPdfRequest(); UrlToPdfRequest request = new UrlToPdfRequest();
// .invalid ist per RFC reserviert und nicht auflösbar // .invalid is reserved by RFC and not resolvable
request.setUrlInput("https://nonexistent.invalid/"); request.setUrlInput("https://nonexistent.invalid/");
ResponseEntity<?> resp = sut.urlToPdf(request); ResponseEntity<?> resp = sut.urlToPdf(request);
@ -109,7 +114,7 @@ public class ConvertWebsiteToPdfTest {
@Test @Test
void redirect_with_error_when_endpoint_disabled() throws Exception { void redirect_with_error_when_endpoint_disabled() throws Exception {
// Feature deaktivieren // Disable feature
applicationProperties.getSystem().setEnableUrlToPDF(false); applicationProperties.getSystem().setEnableUrlToPDF(false);
UrlToPdfRequest request = new UrlToPdfRequest(); UrlToPdfRequest request = new UrlToPdfRequest();
@ -135,9 +140,9 @@ public class ConvertWebsiteToPdfTest {
String out = (String) m.invoke(sut, in); String out = (String) m.invoke(sut, in);
assertTrue(out.endsWith(".pdf")); assertTrue(out.endsWith(".pdf"));
// Nur AZ, az, 09, Unterstrich und Punkt erlaubt // Only AZ, az, 09, underscore and dot allowed
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf")); assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
// keine Truncation hier (Quelle ist nicht so lang) // no truncation here (source not that long)
assertTrue(out.length() <= 54); assertTrue(out.length() <= 54);
} }
@ -147,14 +152,14 @@ public class ConvertWebsiteToPdfTest {
ConvertWebsiteToPDF.class.getDeclaredMethod("convertURLToFileName", String.class); ConvertWebsiteToPDF.class.getDeclaredMethod("convertURLToFileName", String.class);
m.setAccessible(true); m.setAccessible(true);
// Sehr lange URL löst Truncation aus // Very long URL -> triggers truncation
String longUrl = String longUrl =
"https://very-very-long-domain.example.com/some/really/long/path/with?many=params&and=chars"; "https://very-very-long-domain.example.com/some/really/long/path/with?many=params&and=chars";
String out = (String) m.invoke(sut, longUrl); String out = (String) m.invoke(sut, longUrl);
assertTrue(out.endsWith(".pdf")); assertTrue(out.endsWith(".pdf"));
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf")); assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
// safeName ist auf 50 begrenzt total max 54 inkl. ".pdf" // safeName limited to 50 -> total max 54 including '.pdf'
assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'"); assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'");
} }
@ -165,25 +170,26 @@ public class ConvertWebsiteToPdfTest {
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class); try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class); MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) { MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<HttpClient> httpClient = mockHttpClientReturning("<html></html>")) {
// URL-Checks positiv erzwingen // Force URL checks to be positive
gu.when(() -> GeneralUtils.isValidURL("https://example.com")).thenReturn(true); gu.when(() -> GeneralUtils.isValidURL("https://example.com")).thenReturn(true);
gu.when(() -> GeneralUtils.isURLReachable("https://example.com")).thenReturn(true); gu.when(() -> GeneralUtils.isURLReachable("https://example.com")).thenReturn(true);
// richtiger ProcessExecutor! // correct ProcessExecutor!
ProcessExecutor mockExec = Mockito.mock(ProcessExecutor.class); ProcessExecutor mockExec = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.WEASYPRINT)).thenReturn(mockExec); pe.when(() -> ProcessExecutor.getInstance(Processes.WEASYPRINT)).thenReturn(mockExec);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> cmdCaptor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<String>> cmdCaptor = ArgumentCaptor.forClass(List.class);
// Rückgabewert typgerecht // Return value of correct type
ProcessExecutorResult dummyResult = Mockito.mock(ProcessExecutorResult.class); ProcessExecutorResult dummyResult = Mockito.mock(ProcessExecutorResult.class);
when(mockExec.runCommandWithOutputHandling(cmdCaptor.capture())) when(mockExec.runCommandWithOutputHandling(cmdCaptor.capture()))
.thenReturn(dummyResult); .thenReturn(dummyResult);
// WebResponseUtils mocken // Mock WebResponseUtils
ResponseEntity<byte[]> fakeResponse = ResponseEntity.ok(new byte[0]); ResponseEntity<byte[]> fakeResponse = ResponseEntity.ok(new byte[0]);
wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString())) wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString()))
.thenReturn(fakeResponse); .thenReturn(fakeResponse);
@ -194,20 +200,23 @@ public class ConvertWebsiteToPdfTest {
// Assert Response OK // Assert Response OK
assertEquals(HttpStatus.OK, resp.getStatusCode()); assertEquals(HttpStatus.OK, resp.getStatusCode());
// Assert WeasyPrint-Kommando korrekt // Assert WeasyPrint command correct
List<String> cmd = cmdCaptor.getValue(); List<String> cmd = cmdCaptor.getValue();
assertNotNull(cmd); assertNotNull(cmd);
assertEquals("/usr/bin/weasyprint", cmd.get(0)); assertEquals("/usr/bin/weasyprint", cmd.get(0));
assertEquals("https://example.com", cmd.get(1)); assertTrue(cmd.size() >= 6, "WeasyPrint should receive HTML input and output path");
assertEquals("--pdf-forms", cmd.get(2)); String htmlPathStr = cmd.get(1);
assertTrue(cmd.size() >= 4, "WeasyPrint sollte einen Output-Pfad erhalten"); assertEquals("--base-url", cmd.get(2));
String outPathStr = cmd.get(3); assertEquals("https://example.com", cmd.get(3));
assertEquals("--pdf-forms", cmd.get(4));
String outPathStr = cmd.get(5);
assertNotNull(outPathStr); assertNotNull(outPathStr);
// Temp-Datei muss im finally gelöscht sein // Temp file must be deleted in finally
Path outPath = Path.of(outPathStr); Path outPath = Path.of(outPathStr);
assertFalse( assertFalse(
Files.exists(outPath), "Temp-Output-Datei sollte nach dem Call gelöscht sein"); Files.exists(Path.of(htmlPathStr)),
"Temp HTML file should be deleted after the call");
} }
} }
@ -218,21 +227,32 @@ public class ConvertWebsiteToPdfTest {
request.setUrlInput("https://example.com"); request.setUrlInput("https://example.com");
Path preCreatedTemp = java.nio.file.Files.createTempFile("test_output_", ".pdf"); Path preCreatedTemp = java.nio.file.Files.createTempFile("test_output_", ".pdf");
Path htmlTemp = java.nio.file.Files.createTempFile("test_input_", ".html");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class); try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class); MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class); MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
MockedStatic<Files> files = Mockito.mockStatic(Files.class)) { MockedStatic<Files> files = Mockito.mockStatic(Files.class);
MockedStatic<HttpClient> httpClient = mockHttpClientReturning("<html></html>")) {
// URL-Checks positiv // Force URL checks to be positive
gu.when(() -> GeneralUtils.isValidURL("https://example.com")).thenReturn(true); gu.when(() -> GeneralUtils.isValidURL("https://example.com")).thenReturn(true);
gu.when(() -> GeneralUtils.isURLReachable("https://example.com")).thenReturn(true); gu.when(() -> GeneralUtils.isURLReachable("https://example.com")).thenReturn(true);
// Temp-Datei erzwingen + Delete-Fehler provozieren // Force temp files + provoke delete error
files.when(() -> Files.createTempFile("url_input_", ".html")).thenReturn(htmlTemp);
files.when(() -> Files.createTempFile("output_", ".pdf")).thenReturn(preCreatedTemp); files.when(() -> Files.createTempFile("output_", ".pdf")).thenReturn(preCreatedTemp);
files.when(
() ->
Files.writeString(
eq(htmlTemp),
anyString(),
eq(java.nio.charset.StandardCharsets.UTF_8)))
.thenReturn(htmlTemp);
files.when(() -> Files.deleteIfExists(htmlTemp)).thenReturn(true);
files.when(() -> Files.deleteIfExists(preCreatedTemp)) files.when(() -> Files.deleteIfExists(preCreatedTemp))
.thenThrow(new IOException("fail delete")); .thenThrow(new IOException("fail delete"));
files.when(() -> Files.exists(preCreatedTemp)).thenReturn(true); // für den Assert files.when(() -> Files.exists(preCreatedTemp)).thenReturn(true); // for the assert
// ProcessExecutor // ProcessExecutor
ProcessExecutor mockExec = Mockito.mock(ProcessExecutor.class); ProcessExecutor mockExec = Mockito.mock(ProcessExecutor.class);
@ -245,7 +265,7 @@ public class ConvertWebsiteToPdfTest {
wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString())) wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString()))
.thenReturn(fakeResponse); .thenReturn(fakeResponse);
// Act: darf keine Exception werfen und soll eine Response liefern // Act: should not throw and should return a Response
ResponseEntity<?> resp = assertDoesNotThrow(() -> sut.urlToPdf(request)); ResponseEntity<?> resp = assertDoesNotThrow(() -> sut.urlToPdf(request));
// Assert // Assert
@ -253,12 +273,56 @@ public class ConvertWebsiteToPdfTest {
assertEquals(HttpStatus.OK, resp.getStatusCode()); assertEquals(HttpStatus.OK, resp.getStatusCode());
assertTrue( assertTrue(
java.nio.file.Files.exists(preCreatedTemp), java.nio.file.Files.exists(preCreatedTemp),
"Temp-Datei sollte trotz Lösch-IOException noch existieren"); "Temp file should still exist despite delete IOException");
} finally { } finally {
try { try {
java.nio.file.Files.deleteIfExists(preCreatedTemp); java.nio.file.Files.deleteIfExists(preCreatedTemp);
java.nio.file.Files.deleteIfExists(htmlTemp);
} catch (IOException ignore) { } catch (IOException ignore) {
} }
} }
} }
@Test
void redirect_with_error_when_disallowed_content_detected() throws Exception {
UrlToPdfRequest request = new UrlToPdfRequest();
request.setUrlInput("https://example.com");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<HttpClient> httpClient =
mockHttpClientReturning(
"<link rel=\"attachment\" href=\"file:///etc/passwd\">"); ) {
gu.when(() -> GeneralUtils.isValidURL("https://example.com")).thenReturn(true);
gu.when(() -> GeneralUtils.isURLReachable("https://example.com")).thenReturn(true);
ResponseEntity<?> resp = sut.urlToPdf(request);
assertEquals(HttpStatus.SEE_OTHER, resp.getStatusCode());
URI location = resp.getHeaders().getLocation();
assertNotNull(location, "Location header expected");
assertTrue(
location.getQuery() != null
&& location.getQuery().contains("error=error.disallowedUrlContent"));
}
}
private MockedStatic<HttpClient> mockHttpClientReturning(String body) throws Exception {
MockedStatic<HttpClient> httpClientStatic = Mockito.mockStatic(HttpClient.class);
HttpClient.Builder builder = Mockito.mock(HttpClient.Builder.class);
HttpClient client = Mockito.mock(HttpClient.class);
HttpResponse<String> response = Mockito.mock(HttpResponse.class);
httpClientStatic.when(HttpClient::newBuilder).thenReturn(builder);
when(builder.followRedirects(HttpClient.Redirect.NORMAL)).thenReturn(builder);
when(builder.connectTimeout(any(Duration.class))).thenReturn(builder);
when(builder.build()).thenReturn(client);
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenReturn(response);
when(response.statusCode()).thenReturn(200);
when(response.body()).thenReturn(body);
return httpClientStatic;
}
} }