mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
feat(security): add RFC 3161 PDF timestamp tool (#5855)
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
7b3985e34a
commit
8bbfbd63d7
+8
@@ -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",
|
||||
|
||||
+264
@@ -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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user