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:
InstaZDLL
2026-03-24 18:00:33 +01:00
committed by GitHub
parent 7b3985e34a
commit 8bbfbd63d7
17 changed files with 1068 additions and 1 deletions

View File

@@ -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",

View File

@@ -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 "";
}
}
}

View File

@@ -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;
}

View File

@@ -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:

View File

@@ -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);
}
}
}
}