mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-16 23:08:38 +02:00
feat(security): add RFC 3161 PDF timestamp tool (#5855)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.SPDF.controller.api.security.TimestampController;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -245,6 +246,13 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
applicationProperties.getSecurity().getTimestamp();
|
||||
configData.put("timestampDefaultTsaUrl", tsConfig.getDefaultTsaUrl());
|
||||
configData.put("timestampCustomTsaUrls", tsConfig.getCustomTsaUrls());
|
||||
configData.put("timestampTsaPresets", TimestampController.TSA_PRESETS);
|
||||
|
||||
// Server certificate settings
|
||||
configData.put(
|
||||
"serverCertificateEnabled",
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Security;
|
||||
import java.util.Calendar;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.tsp.TimeStampRequest;
|
||||
import org.bouncycastle.tsp.TimeStampRequestGenerator;
|
||||
import org.bouncycastle.tsp.TimeStampResponse;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.TimestampPdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@Slf4j
|
||||
@SecurityApi
|
||||
@RequiredArgsConstructor
|
||||
public class TimestampController {
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
/** Built-in TSA presets with labels — single source of truth for backend + frontend. */
|
||||
public static final List<Map<String, String>> TSA_PRESETS =
|
||||
List.of(
|
||||
Map.of("label", "DigiCert", "url", "http://timestamp.digicert.com"),
|
||||
Map.of("label", "Sectigo", "url", "http://timestamp.sectigo.com"),
|
||||
Map.of("label", "SSL.com", "url", "http://ts.ssl.com"),
|
||||
Map.of("label", "FreeTSA", "url", "https://freetsa.org/tsr"),
|
||||
Map.of("label", "MeSign", "url", "http://tsa.mesign.com"));
|
||||
|
||||
private static final Set<String> ALLOWED_TSA_PRESET_URLS =
|
||||
TSA_PRESETS.stream().map(p -> p.get("url")).collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private static final int MAX_TSA_RESPONSE_SIZE = 1024 * 1024; // 1 MB
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/timestamp-pdf")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Add RFC 3161 document timestamp to a PDF",
|
||||
description =
|
||||
"Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161"
|
||||
+ " document timestamp into the PDF. Only a SHA-256 hash of the"
|
||||
+ " document is sent to the TSA — the PDF itself never leaves the"
|
||||
+ " server. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> timestampPdf(@ModelAttribute TimestampPdfRequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
applicationProperties.getSecurity().getTimestamp();
|
||||
|
||||
// Determine effective TSA URL: use request value if provided, otherwise config default
|
||||
String tsaUrl =
|
||||
(request.getTsaUrl() != null && !request.getTsaUrl().isBlank())
|
||||
? request.getTsaUrl()
|
||||
: tsConfig.getDefaultTsaUrl();
|
||||
|
||||
// Build allowed set: built-in presets + admin-configured custom URLs
|
||||
// Filter null/blank entries and validate protocol (TASK-6)
|
||||
Set<String> allowedUrls = new HashSet<>(ALLOWED_TSA_PRESET_URLS);
|
||||
if (tsConfig.getDefaultTsaUrl() != null
|
||||
&& !tsConfig.getDefaultTsaUrl().isBlank()
|
||||
&& isValidTsaUrlProtocol(tsConfig.getDefaultTsaUrl())) {
|
||||
allowedUrls.add(tsConfig.getDefaultTsaUrl());
|
||||
}
|
||||
List<String> customUrls = tsConfig.getCustomTsaUrls();
|
||||
if (customUrls != null) {
|
||||
customUrls.stream()
|
||||
.filter(u -> u != null && !u.isBlank() && isValidTsaUrlProtocol(u))
|
||||
.forEach(allowedUrls::add);
|
||||
}
|
||||
|
||||
// Normalize for case-insensitive comparison (TASK-12)
|
||||
Set<String> normalizedAllowed =
|
||||
allowedUrls.stream()
|
||||
.map(TimestampController::normalizeTsaUrl)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Validate TSA URL against allowed set to prevent SSRF
|
||||
if (!normalizedAllowed.contains(normalizeTsaUrl(tsaUrl))) {
|
||||
throw new IllegalArgumentException(
|
||||
"TSA URL is not in the allowed list. Contact your administrator to add it"
|
||||
+ " via settings.yml (security.timestamp.customTsaUrls).");
|
||||
}
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
|
||||
PDSignature signature = new PDSignature();
|
||||
signature.setType(COSName.DOC_TIME_STAMP);
|
||||
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
|
||||
signature.setSubFilter(COSName.getPDFName("ETSI.RFC3161"));
|
||||
signature.setSignDate(Calendar.getInstance());
|
||||
|
||||
document.addSignature(signature, content -> requestTimestampToken(content, tsaUrl));
|
||||
|
||||
document.saveIncremental(outputStream);
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(),
|
||||
GeneralUtils.generateFilename(inputFile.getOriginalFilename(), "_timestamped.pdf"));
|
||||
}
|
||||
|
||||
private byte[] requestTimestampToken(InputStream content, String tsaUrl) throws IOException {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
// Hash the PDF content byte range with SHA-256
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = content.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
byte[] hash = digest.digest();
|
||||
|
||||
// Build the RFC 3161 timestamp request
|
||||
TimeStampRequestGenerator generator = new TimeStampRequestGenerator();
|
||||
generator.setCertReq(true);
|
||||
BigInteger nonce = BigInteger.valueOf(SECURE_RANDOM.nextLong() & Long.MAX_VALUE);
|
||||
ASN1ObjectIdentifier digestAlgorithm = NISTObjectIdentifiers.id_sha256;
|
||||
TimeStampRequest tsaRequest = generator.generate(digestAlgorithm, hash, nonce);
|
||||
byte[] requestBytes = tsaRequest.getEncoded();
|
||||
|
||||
// Contact the TSA server (redirects disabled to prevent SSRF via redirect)
|
||||
connection = (HttpURLConnection) URI.create(tsaUrl).toURL().openConnection();
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setDoOutput(true);
|
||||
connection.setDoInput(true);
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/timestamp-query");
|
||||
connection.setRequestProperty("Content-Length", String.valueOf(requestBytes.length));
|
||||
connection.setConnectTimeout(30_000);
|
||||
connection.setReadTimeout(30_000);
|
||||
|
||||
try (OutputStream out = connection.getOutputStream()) {
|
||||
out.write(requestBytes);
|
||||
}
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
// Read error stream for debugging (TASK-5)
|
||||
String errorBody = readErrorStream(connection);
|
||||
throw new IOException(
|
||||
"TSA server returned HTTP "
|
||||
+ responseCode
|
||||
+ " for URL: "
|
||||
+ tsaUrl
|
||||
+ (errorBody.isEmpty() ? "" : " — " + errorBody));
|
||||
}
|
||||
|
||||
// Read response with size limit to prevent OOM (TASK-4)
|
||||
byte[] responseBytes;
|
||||
try (InputStream in = connection.getInputStream()) {
|
||||
responseBytes = in.readNBytes(MAX_TSA_RESPONSE_SIZE);
|
||||
if (in.read() != -1) {
|
||||
throw new IOException(
|
||||
"TSA response exceeds maximum allowed size of "
|
||||
+ MAX_TSA_RESPONSE_SIZE
|
||||
+ " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate the TSA response
|
||||
TimeStampResponse tsaResponse = new TimeStampResponse(responseBytes);
|
||||
tsaResponse.validate(tsaRequest);
|
||||
|
||||
TimeStampToken token = tsaResponse.getTimeStampToken();
|
||||
if (token == null) {
|
||||
throw new IOException(
|
||||
"TSA server did not return a timestamp token. Status: "
|
||||
+ tsaResponse.getStatus());
|
||||
}
|
||||
|
||||
log.info(
|
||||
"RFC 3161 timestamp obtained from {} at {}",
|
||||
tsaUrl,
|
||||
token.getTimeStampInfo().getGenTime());
|
||||
|
||||
return token.getEncoded();
|
||||
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IOException(
|
||||
"Failed to obtain RFC 3161 timestamp from " + tsaUrl + ": " + e.getMessage(),
|
||||
e);
|
||||
} finally {
|
||||
// Always disconnect to release the underlying socket (TASK-1)
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidTsaUrlProtocol(String url) {
|
||||
String lower = url.toLowerCase(Locale.ROOT);
|
||||
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||
}
|
||||
|
||||
private static String normalizeTsaUrl(String url) {
|
||||
try {
|
||||
URI uri = URI.create(url.trim());
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
|
||||
int port = uri.getPort();
|
||||
String path = uri.getPath() == null ? "" : uri.getPath();
|
||||
return scheme + "://" + host + (port == -1 ? "" : ":" + port) + path;
|
||||
} catch (Exception e) {
|
||||
return url.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readErrorStream(HttpURLConnection connection) {
|
||||
try (InputStream err = connection.getErrorStream()) {
|
||||
if (err == null) return "";
|
||||
byte[] body = err.readNBytes(2048);
|
||||
return new String(body, StandardCharsets.UTF_8).trim();
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class TimestampPdfRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"URL of the RFC 3161 Time Stamp Authority (TSA) server."
|
||||
+ " Must be one of the built-in presets (DigiCert, Sectigo, SSL.com,"
|
||||
+ " FreeTSA, MeSign) or an admin-configured URL in"
|
||||
+ " settings.yml (security.timestamp.customTsaUrls)."
|
||||
+ " If omitted, the server default is used.",
|
||||
defaultValue = "http://timestamp.digicert.com",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String tsaUrl;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ security:
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
|
||||
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
|
||||
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
|
||||
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
|
||||
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
|
||||
validation: # PDF signature validation settings
|
||||
@@ -84,6 +84,9 @@ security:
|
||||
revocation:
|
||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
||||
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||
timestamp:
|
||||
defaultTsaUrl: http://timestamp.digicert.com # Default TSA server for RFC 3161 document timestamps
|
||||
customTsaUrls: [] # Admin-configured additional TSA URLs (e.g. ['https://internal-tsa.corp.com/timestamp']). Users can only select from built-in presets and these URLs.
|
||||
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
|
||||
|
||||
premium:
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.TimestampPdfRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@DisplayName("TimestampController security tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TimestampControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
|
||||
@InjectMocks private TimestampController controller;
|
||||
|
||||
private ApplicationProperties.Security security;
|
||||
private ApplicationProperties.Security.Timestamp tsConfig;
|
||||
private MockMultipartFile mockPdfFile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
security = new ApplicationProperties.Security();
|
||||
tsConfig = new ApplicationProperties.Security.Timestamp();
|
||||
security.setTimestamp(tsConfig);
|
||||
|
||||
when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
|
||||
mockPdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput",
|
||||
"test.pdf",
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
new byte[] {0x25, 0x50, 0x44, 0x46}); // %PDF header
|
||||
}
|
||||
|
||||
private TimestampPdfRequest createRequest(String tsaUrl) {
|
||||
TimestampPdfRequest request = new TimestampPdfRequest();
|
||||
request.setFileInput(mockPdfFile);
|
||||
request.setTsaUrl(tsaUrl);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("URL Allowlist Validation")
|
||||
class AllowlistTests {
|
||||
|
||||
@ParameterizedTest
|
||||
@DisplayName("Should accept built-in preset URLs")
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"http://timestamp.digicert.com",
|
||||
"http://timestamp.sectigo.com",
|
||||
"http://ts.ssl.com",
|
||||
"https://freetsa.org/tsr",
|
||||
"http://tsa.mesign.com"
|
||||
})
|
||||
void shouldAcceptPresetUrls(String presetUrl) throws Exception {
|
||||
// Mock PDF loading to avoid actual TSA call — we only test validation here
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||
doAnswer(
|
||||
inv -> {
|
||||
ByteArrayOutputStream baos = inv.getArgument(0);
|
||||
baos.write(new byte[] {0x25, 0x50, 0x44, 0x46});
|
||||
return null;
|
||||
})
|
||||
.when(mockDoc)
|
||||
.saveIncremental(any(ByteArrayOutputStream.class));
|
||||
doNothing().when(mockDoc).close();
|
||||
|
||||
TimestampPdfRequest request = createRequest(presetUrl);
|
||||
|
||||
// The method should NOT throw IllegalArgumentException for preset URLs
|
||||
// It may throw IOException when contacting the TSA — that's expected
|
||||
try {
|
||||
controller.timestampPdf(request);
|
||||
} catch (IllegalArgumentException e) {
|
||||
fail("Preset URL should be in the allowlist: " + presetUrl);
|
||||
} catch (Exception e) {
|
||||
// IOException from TSA contact is expected in test env — validation passed
|
||||
assertFalse(
|
||||
e instanceof IllegalArgumentException,
|
||||
"Should not reject preset URL: " + presetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject arbitrary URL not in allowlist")
|
||||
void shouldRejectArbitraryUrl() {
|
||||
TimestampPdfRequest request = createRequest("http://evil.internal.corp/ssrf");
|
||||
|
||||
IllegalArgumentException ex =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||
assertTrue(ex.getMessage().contains("not in the allowed list"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@DisplayName("Should reject SSRF-prone URLs")
|
||||
@ValueSource(
|
||||
strings = {
|
||||
"http://localhost/timestamp",
|
||||
"http://127.0.0.1:8080/internal",
|
||||
"http://192.168.1.1/admin",
|
||||
"http://10.0.0.1/metadata",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"file:///etc/passwd",
|
||||
"ftp://internal-server/data",
|
||||
"gopher://internal:25/",
|
||||
})
|
||||
void shouldRejectSsrfUrls(String ssrfUrl) {
|
||||
TimestampPdfRequest request = createRequest(ssrfUrl);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> controller.timestampPdf(request),
|
||||
"Should reject SSRF URL: " + ssrfUrl);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should accept admin-configured custom URL")
|
||||
void shouldAcceptAdminCustomUrl() throws Exception {
|
||||
String customUrl = "https://internal-tsa.corp.com/timestamp";
|
||||
tsConfig.setCustomTsaUrls(new ArrayList<>(List.of(customUrl)));
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||
doNothing().when(mockDoc).close();
|
||||
|
||||
TimestampPdfRequest request = createRequest(customUrl);
|
||||
|
||||
try {
|
||||
controller.timestampPdf(request);
|
||||
} catch (IllegalArgumentException e) {
|
||||
fail("Admin-configured custom URL should be accepted: " + customUrl);
|
||||
} catch (Exception e) {
|
||||
// IOException from TSA contact is expected
|
||||
assertFalse(e instanceof IllegalArgumentException);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject URL not in admin custom list")
|
||||
void shouldRejectUrlNotInAdminList() {
|
||||
tsConfig.setCustomTsaUrls(
|
||||
new ArrayList<>(List.of("https://allowed-tsa.corp.com/timestamp")));
|
||||
|
||||
TimestampPdfRequest request =
|
||||
createRequest("https://not-allowed-tsa.evil.com/timestamp");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Default TSA URL Fallback")
|
||||
class DefaultFallbackTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use config default when tsaUrl is null")
|
||||
void shouldFallbackToConfigDefault() throws Exception {
|
||||
tsConfig.setDefaultTsaUrl("http://timestamp.digicert.com");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||
doNothing().when(mockDoc).close();
|
||||
|
||||
TimestampPdfRequest request = createRequest(null);
|
||||
|
||||
// Should not throw IllegalArgumentException — default is in presets
|
||||
try {
|
||||
controller.timestampPdf(request);
|
||||
} catch (IllegalArgumentException e) {
|
||||
fail("Default TSA URL should be accepted");
|
||||
} catch (Exception e) {
|
||||
// IOException expected
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use config default when tsaUrl is blank")
|
||||
void shouldFallbackToConfigDefaultWhenBlank() throws Exception {
|
||||
tsConfig.setDefaultTsaUrl("http://timestamp.digicert.com");
|
||||
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||
doNothing().when(mockDoc).close();
|
||||
|
||||
TimestampPdfRequest request = createRequest(" ");
|
||||
|
||||
try {
|
||||
controller.timestampPdf(request);
|
||||
} catch (IllegalArgumentException e) {
|
||||
fail("Should fallback to config default when blank");
|
||||
} catch (Exception e) {
|
||||
// IOException expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Config URL Validation (TASK-6)")
|
||||
class ConfigValidationTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should filter blank entries from admin custom URLs")
|
||||
void shouldFilterBlankCustomUrls() {
|
||||
List<String> customUrls = new ArrayList<>(List.of("", " ", "http://valid-tsa.com/ts"));
|
||||
tsConfig.setCustomTsaUrls(customUrls);
|
||||
|
||||
TimestampPdfRequest request = createRequest("http://evil.com/ssrf");
|
||||
|
||||
// Blank entries should not expand the allowlist
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject file:// protocol in admin config")
|
||||
void shouldRejectFileProtocolInConfig() {
|
||||
tsConfig.setDefaultTsaUrl("file:///etc/passwd");
|
||||
tsConfig.setCustomTsaUrls(new ArrayList<>());
|
||||
|
||||
TimestampPdfRequest request = createRequest(null);
|
||||
|
||||
// file:// default should be filtered out, leaving no valid default
|
||||
// The request falls back to "file:///etc/passwd" which is not in allowed set
|
||||
assertThrows(Exception.class, () -> controller.timestampPdf(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject ftp:// protocol in custom URLs")
|
||||
void shouldRejectFtpProtocolInCustomUrls() {
|
||||
tsConfig.setCustomTsaUrls(new ArrayList<>(List.of("ftp://internal-server/timestamp")));
|
||||
|
||||
TimestampPdfRequest request = createRequest("ftp://internal-server/timestamp");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Case-insensitive URL matching (TASK-12)")
|
||||
class CaseInsensitiveTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should match URLs regardless of case")
|
||||
void shouldMatchCaseInsensitive() throws Exception {
|
||||
PDDocument mockDoc = mock(PDDocument.class);
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||
doNothing().when(mockDoc).close();
|
||||
|
||||
TimestampPdfRequest request = createRequest("HTTP://TIMESTAMP.DIGICERT.COM");
|
||||
|
||||
try {
|
||||
controller.timestampPdf(request);
|
||||
} catch (IllegalArgumentException e) {
|
||||
fail("Case-insensitive URL should match preset");
|
||||
} catch (Exception e) {
|
||||
// IOException expected
|
||||
assertFalse(e instanceof IllegalArgumentException);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user