-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix(security): prevent git import symlink path traversal → arbitrary file read/write (GHSA-fqwc-g9wm-5895) #41993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ | |
| import org.eclipse.jgit.api.errors.CheckoutConflictException; | ||
| import org.eclipse.jgit.api.errors.GitAPIException; | ||
| import org.eclipse.jgit.lib.BranchTrackingStatus; | ||
| import org.eclipse.jgit.lib.ConfigConstants; | ||
| import org.eclipse.jgit.lib.PersonIdent; | ||
| import org.eclipse.jgit.lib.Ref; | ||
| import org.eclipse.jgit.lib.StoredConfig; | ||
|
|
@@ -53,6 +54,7 @@ | |
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.Paths; | ||
| import java.time.Duration; | ||
|
|
@@ -317,6 +319,11 @@ public Mono<String> cloneRemoteIntoArtifactRepo( | |
| .call()) { | ||
| branchName = git.getRepository().getBranch(); | ||
|
|
||
| // SECURITY (GHSA-fqwc-g9wm-5895): disable symlink materialization and | ||
| // re-checkout any symlinks as regular files. A malicious repo can commit | ||
| // symlink entries (git mode 120000) pointing outside the Git root. | ||
| removeSymlinksAfterClone(git); | ||
|
|
||
| repositoryHelper.updateRemoteBranchTrackingConfig(branchName, git); | ||
| } | ||
| processStopwatch.stopAndLogTimeInMillis(); | ||
|
|
@@ -329,6 +336,34 @@ public Mono<String> cloneRemoteIntoArtifactRepo( | |
| .subscribeOn(scheduler); | ||
| } | ||
|
|
||
| /** | ||
| * Disables symlink support and re-materializes any symlinks as regular files | ||
| * (GHSA-fqwc-g9wm-5895). | ||
| * | ||
| * <p>After a normal clone, any git mode {@code 120000} entries are real OS symlinks on disk. | ||
| * Setting {@code core.symlinks=false} and then deleting and re-checking-out those symlinks | ||
| * converts them to regular files containing the link target text. | ||
| */ | ||
| protected void removeSymlinksAfterClone(Git git) throws GitAPIException, IOException { | ||
| StoredConfig config = git.getRepository().getConfig(); | ||
| config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, ConfigConstants.CONFIG_KEY_SYMLINKS, false); | ||
| config.save(); | ||
|
|
||
| Path workTree = git.getRepository().getWorkTree().toPath(); | ||
| try (Stream<Path> paths = Files.walk(workTree)) { | ||
| paths.filter(Files::isSymbolicLink) | ||
| .filter(p -> !p.startsWith(workTree.resolve(".git"))) | ||
| .forEach(p -> { | ||
| try { | ||
| Files.delete(p); | ||
| } catch (IOException e) { | ||
| log.warn("Failed to delete symlink: {}", p, e); | ||
| } | ||
| }); | ||
| } | ||
| git.checkout().setAllPaths(true).call(); | ||
| } | ||
|
Comment on lines
+339
to
+365
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win Symlink remediation must fail closed. Both clone implementations ignore deletion failures and then report a successfully remediated clone.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| @Override | ||
| public Mono<String> createAndCheckoutToBranch(Path repoSuffix, String branchName) { | ||
| // We can safely assume that repo has been already initialised either in commit or clone flow and can directly | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -333,6 +333,58 @@ public void saveApplicationRef_legitimateWidgetName_doesNotThrow_GHSA_r553() thr | |
| Assertions.assertTrue(Files.exists(widgetFile), "Legitimate widget file should exist"); | ||
| } | ||
|
|
||
| /** | ||
| * GHSA-fqwc-g9wm-5895: Symlink path traversal in Git import. | ||
| * A file inside the git root whose lexical path is contained in the git root, but which is a | ||
| * symbolic link pointing OUTSIDE the git root, must be rejected by validatePathIsWithinGitRoot. | ||
| * The lexical Path.normalize() guard does NOT resolve symlinks, so before the fix the read | ||
| * follows the symlink and leaks the target file's content. After the fix the real-path | ||
| * containment check detects the escape and throws. | ||
| */ | ||
| @Test | ||
| public void reconstructMetadata_symlinkEscapesGitRoot_throwsSecurityException_GHSA_fqwc() throws IOException { | ||
| // A "secret" file located OUTSIDE the git root. | ||
| Path externalDir = Files.createTempDirectory("ghsa-fqwc-external"); | ||
| try { | ||
| Path secretFile = externalDir.resolve("secret.json"); | ||
| Files.writeString(secretFile, "{\"fileFormatVersion\": 99, \"leaked\": \"top-secret\"}"); | ||
|
|
||
| // Set up a repo directory inside the git root, then replace metadata.json with a symlink | ||
| // pointing to the external secret. The symlink's own path is lexically inside the git root. | ||
| Path repoSuffix = Path.of("workspace-symlink", "app", "repo"); | ||
| Path fullRepoPath = localTestDirectoryPath.resolve(repoSuffix); | ||
| Files.createDirectories(fullRepoPath); | ||
| Path metadataPath = fullRepoPath.resolve("metadata.json"); | ||
| Files.createSymbolicLink(metadataPath, secretFile); | ||
|
|
||
| // Sanity: the symlink lexically resolves inside the git root (this is what fools the | ||
| // old lexical guard), but its real target is outside. | ||
| Assertions.assertTrue(Files.isSymbolicLink(metadataPath)); | ||
|
|
||
| Assertions.assertThrows(RuntimeException.class, () -> { | ||
| fileUtils.reconstructMetadataFromGitRepository(repoSuffix).block(); | ||
| }); | ||
| } finally { | ||
| FileUtils.deleteDirectory(externalDir.toFile()); | ||
| } | ||
| } | ||
|
Comment on lines
+336
to
+370
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Assert the specific containment exception.
🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * GHSA-fqwc-g9wm-5895: A legitimate (non-symlink) file inside the git root must still be readable. | ||
| * Regression guard ensuring the symlink-aware check does not break normal reads. | ||
| */ | ||
| @Test | ||
| public void reconstructMetadata_regularFileWithinGitRoot_doesNotThrow_GHSA_fqwc() throws IOException { | ||
| Path repoSuffix = Path.of("workspace-regular", "app", "repo"); | ||
| Path fullRepoPath = localTestDirectoryPath.resolve(repoSuffix); | ||
| Files.createDirectories(fullRepoPath); | ||
| Files.writeString(fullRepoPath.resolve("metadata.json"), "{\"fileFormatVersion\": 5}"); | ||
|
|
||
| Object result = | ||
| fileUtils.reconstructMetadataFromGitRepository(repoSuffix).block(); | ||
| Assertions.assertNotNull(result); | ||
| } | ||
|
|
||
| /** | ||
| * This will delete localTestDirectory and its contents after the test is executed. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package com.appsmith.git.service.ce; | ||
|
|
||
| import com.appsmith.external.helpers.ObservationHelper; | ||
| import com.appsmith.git.configurations.GitServiceConfig; | ||
| import io.micrometer.observation.ObservationRegistry; | ||
| import org.apache.commons.io.FileUtils; | ||
| import org.eclipse.jgit.api.Git; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.io.File; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.LinkOption; | ||
| import java.nio.file.Path; | ||
|
|
||
| /** | ||
| * GHSA-fqwc-g9wm-5895: Git import symlink path traversal. | ||
| * | ||
| * <p>A malicious repository can commit a symbolic link (git mode 120000) pointing at an arbitrary | ||
| * server path. When cloned, JGit's default behaviour materializes it as a real filesystem symlink | ||
| * inside the Git root; subsequent file reads/writes follow it, enabling arbitrary file read/write. | ||
| * The fix disables symlink support ({@code core.symlinks=false}) before checkout so the entry is | ||
| * written as an inert regular file. | ||
| */ | ||
| public class GitExecutorSymlinkCloneTest { | ||
|
|
||
| private GitExecutorCEImpl newGitExecutor(Path gitRoot) { | ||
| GitServiceConfig gitServiceConfig = new GitServiceConfig(); | ||
| gitServiceConfig.setGitRootPath(gitRoot.toString()); | ||
| return new GitExecutorCEImpl(gitServiceConfig, null, ObservationRegistry.NOOP, ObservationHelper.NOOP, null); | ||
| } | ||
|
|
||
| private String buildSourceRepoWithSymlink(Path srcDir) throws Exception { | ||
| try (Git srcGit = Git.init().setDirectory(srcDir.toFile()).call()) { | ||
| Files.writeString(srcDir.resolve("normal.txt"), "hello"); | ||
| // Symlink committed as git mode 120000; target need not exist for the entry to be stored. | ||
| Files.createSymbolicLink(srcDir.resolve("evil.txt"), Path.of("/etc/passwd")); | ||
| srcGit.add().addFilepattern(".").call(); | ||
| srcGit.commit() | ||
| .setMessage("init") | ||
| .setSign(false) | ||
| .setAuthor("tester", "tester@appsmith.test") | ||
| .setCommitter("tester", "tester@appsmith.test") | ||
| .call(); | ||
| } | ||
| return srcDir.toUri().toString(); | ||
| } | ||
|
|
||
| @Test | ||
| public void cloneRepoWithSymlinkEntry_isCheckedOutAsRegularFile_GHSA_fqwc() throws Exception { | ||
| Path srcDir = Files.createTempDirectory("ghsa-fqwc-src"); | ||
| Path controlRoot = Files.createTempDirectory("ghsa-fqwc-control"); | ||
| Path fixRoot = Files.createTempDirectory("ghsa-fqwc-fix"); | ||
| try { | ||
| String uri = buildSourceRepoWithSymlink(srcDir); | ||
|
|
||
| // Control: a default checkout materializes the entry as a REAL symlink (the vulnerability). | ||
| File controlDest = controlRoot.resolve("clone").toFile(); | ||
| try (Git control = | ||
| Git.cloneRepository().setURI(uri).setDirectory(controlDest).call()) { | ||
| Path controlEvil = controlDest.toPath().resolve("evil.txt"); | ||
| Assertions.assertTrue( | ||
| Files.isSymbolicLink(controlEvil), | ||
| "Sanity check: without hardening JGit materializes a real symlink"); | ||
| } | ||
|
|
||
| // Fix: normal clone, then removeSymlinksAfterClone converts them to regular files. | ||
| File dest = fixRoot.resolve("clone").toFile(); | ||
| try (Git cloned = | ||
| Git.cloneRepository().setURI(uri).setDirectory(dest).call()) { | ||
|
|
||
| newGitExecutor(fixRoot).removeSymlinksAfterClone(cloned); | ||
|
|
||
| Path evil = dest.toPath().resolve("evil.txt"); | ||
| Assertions.assertTrue(Files.exists(evil, LinkOption.NOFOLLOW_LINKS), "The entry should be checked out"); | ||
| Assertions.assertFalse( | ||
| Files.isSymbolicLink(evil), | ||
| "With core.symlinks=false the entry must be a regular file, not a symlink"); | ||
| Assertions.assertTrue(Files.isRegularFile(evil, LinkOption.NOFOLLOW_LINKS)); | ||
| Assertions.assertEquals( | ||
| "/etc/passwd", | ||
| Files.readString(evil).trim(), | ||
| "The symlink target text is stored as plain file content"); | ||
| // Legitimate regular files are unaffected. | ||
| Assertions.assertEquals("hello", Files.readString(dest.toPath().resolve("normal.txt"))); | ||
| } | ||
| } finally { | ||
| FileUtils.deleteDirectory(srcDir.toFile()); | ||
| FileUtils.deleteDirectory(controlRoot.toFile()); | ||
| FileUtils.deleteDirectory(fixRoot.toFile()); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
Validate every concrete write target, not only string-body files.
saveActionCollectionandsaveActionsstill passmetadataPathdirectly tofileOperations.writeToFileafter validating only the parent directory. An existingmetadata.jsonsymlink can therefore redirect those writes outside the Git root. ApplyvalidatePathIsWithinGitRoot(metadataPath)before both metadata writes.🤖 Prompt for AI Agents