-
Notifications
You must be signed in to change notification settings - Fork 4.5k
fix(security): block SSRF via Git SSH connection — JGit code path bypasses WebClientUtils SSRF filter #41684
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
b3de523
398c8a6
86d3372
41e14ea
2071d21
80e6b7c
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 |
|---|---|---|
|
|
@@ -231,7 +231,8 @@ public Mono<? extends ArtifactImportDTO> importArtifactFromGit( | |
| Mono<GitAuth> gitAuthMonoCached = | ||
| gitHandlingService.getGitAuthForUser(gitConnectDTO).cache(); | ||
|
|
||
| Mono<? extends Artifact> blankArtifactForImportMono = isRepositoryLimitReachedForWorkspaceMono | ||
| Mono<? extends Artifact> blankArtifactForImportMono = GitUtils.validateGitSshUrl(gitConnectDTO.getRemoteUrl()) | ||
| .then(isRepositoryLimitReachedForWorkspaceMono) | ||
| .flatMap(isLimitReachedForPrivateRepositories -> { | ||
| if (!TRUE.equals(isLimitReachedForPrivateRepositories)) { | ||
| return gitAuthMonoCached; | ||
|
|
@@ -642,11 +643,18 @@ private Mono<? extends Artifact> checkoutRemoteReference( | |
| artifactMono = generateArtifactForRefCreation(baseArtifact, finalRemoteRefName, gitRefDTO.getRefType()); | ||
| } | ||
|
|
||
| Mono<? extends Artifact> checkedOutRemoteArtifactMono = gitHandlingService | ||
| .fetchRemoteReferences(jsonTransformationDTO, fetchRemoteDTO, baseGitMetadata.getGitAuth()) | ||
| Mono<? extends Artifact> checkedOutRemoteArtifactMono = GitUtils.validatePersistedGitSshUrl( | ||
| baseGitMetadata.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, fetchRemoteDTO, baseGitMetadata.getGitAuth())) | ||
| .flatMap(ignoredFetchString -> gitHandlingService.checkoutRemoteReference(jsonTransformationDTO)) | ||
| .onErrorResume(error -> Mono.error( | ||
| new AppsmithException(AppsmithError.GIT_ACTION_FAILED, "checkout branch", error.getMessage()))) | ||
| .onErrorResume(error -> { | ||
| if (error instanceof AppsmithException) { | ||
| return Mono.error(error); | ||
| } | ||
| return Mono.error(new AppsmithException( | ||
| AppsmithError.GIT_ACTION_FAILED, "checkout branch", error.getMessage())); | ||
| }) | ||
| .flatMap(ignoreRemoteChanges -> { | ||
| return gitHandlingService | ||
| .reconstructArtifactJsonFromGitRepository(jsonTransformationDTO) | ||
|
|
@@ -750,8 +758,8 @@ protected Mono<? extends Artifact> createReference( | |
| fetchRemoteDTO.setIsFetchAll(TRUE); | ||
|
|
||
| Mono<Boolean> removeDanglingLock = gitHandlingService.removeDanglingLocks(baseRefTransformationDTO); | ||
| Mono<String> fetchRemoteMono = | ||
| gitHandlingService.fetchRemoteReferences(baseRefTransformationDTO, fetchRemoteDTO, baseGitAuth); | ||
| Mono<String> fetchRemoteMono = GitUtils.validatePersistedGitSshUrl(baseGitMetadata.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteReferences(baseRefTransformationDTO, fetchRemoteDTO, baseGitAuth)); | ||
|
|
||
| Mono<? extends Artifact> createBranchMono = acquireGitLockMono | ||
| .flatMap(ignoreLockAcquisition -> removeDanglingLock | ||
|
|
@@ -1082,7 +1090,8 @@ public Mono<? extends Artifact> connectArtifactToGit( | |
| .getArtifactById(baseArtifactId, connectToGitPermission) | ||
| .flatMap(artifact -> manageSSHKey(gitConnectDTO, artifact)) | ||
| .cache(); | ||
| Mono<? extends Artifact> connectedArtifactMono = Mono.zip(profileMono, isPrivateRepoMono, artifactToConnectMono) | ||
| Mono<? extends Artifact> connectedArtifactMono = GitUtils.validateGitSshUrl(gitConnectDTO.getRemoteUrl()) | ||
| .then(Mono.zip(profileMono, isPrivateRepoMono, artifactToConnectMono)) | ||
| .flatMap(tuple -> { | ||
| Artifact artifact = tuple.getT3(); | ||
| Boolean isRepoPrivate = tuple.getT2(); | ||
|
|
@@ -1756,9 +1765,10 @@ protected Mono<GitStatusDTO> getStatus( | |
| return Mono.error(new AppsmithException(AppsmithError.INVALID_GIT_CONFIGURATION)); | ||
| } | ||
|
|
||
| fetchRemoteMono = Mono.defer(() -> gitHandlingService | ||
| .fetchRemoteReferences( | ||
| jsonTransformationDTO, fetchRemoteDTO, baseGitMetadata.getGitAuth()) | ||
| fetchRemoteMono = Mono.defer(() -> GitUtils.validatePersistedGitSshUrl( | ||
| baseGitMetadata.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, fetchRemoteDTO, baseGitMetadata.getGitAuth())) | ||
| .onErrorResume(error -> { | ||
| log.error( | ||
| "Error while fetching remote references for artifact: {}, branch: {}", | ||
|
|
@@ -2058,6 +2068,7 @@ public Mono<BranchTrackingStatus> fetchRemoteChanges( | |
|
|
||
| // current user mono has been zipped just to run in parallel. | ||
| Mono<BranchTrackingStatus> fetchRemoteMono = acquireGitLockMono | ||
| .then(GitUtils.validatePersistedGitSshUrl(baseArtifactGitData.getRemoteUrl())) | ||
| .then(Mono.defer(() -> gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, baseArtifactGitData.getGitAuth(), FALSE))) | ||
| .flatMap(fetchedRemoteString -> { | ||
|
|
@@ -2563,8 +2574,9 @@ private Mono<List<GitRefDTO>> handleRepoNotFoundException( | |
| GitConnectDTO gitConnectDTO = new GitConnectDTO(); | ||
| gitConnectDTO.setRemoteUrl(baseGitMetadata.getRemoteUrl()); | ||
|
|
||
| return gitHandlingService | ||
| .fetchRemoteRepository(gitConnectDTO, gitAuth, baseArtifact, baseGitMetadata.getRepoName()) | ||
| return GitUtils.validatePersistedGitSshUrl(baseGitMetadata.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteRepository( | ||
| gitConnectDTO, gitAuth, baseArtifact, baseGitMetadata.getRepoName())) | ||
| .flatMap(defaultBranch -> gitHandlingService.listReferences(jsonTransformationDTO, true)) | ||
| .flatMap(refDTOs -> { | ||
| ArtifactType artifactType = baseArtifact.getArtifactType(); | ||
|
|
@@ -2656,8 +2668,9 @@ private Mono<List<GitRefDTO>> getBranchListWithDefaultBranchName( | |
| Mono<String> fetchRemoteMono = Mono.just(""); | ||
|
|
||
| if (TRUE.equals(pruneBranches)) { | ||
| fetchRemoteMono = gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, baseGitData.getGitAuth(), TRUE); | ||
| fetchRemoteMono = GitUtils.validatePersistedGitSshUrl(baseGitData.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, baseGitData.getGitAuth(), TRUE)); | ||
| } | ||
|
|
||
| Mono<List<GitRefDTO>> listBranchesMono = | ||
|
|
@@ -3025,8 +3038,12 @@ public Mono<MergeStatusDTO> mergeBranch( | |
| GitHandlingService gitHandlingService = | ||
| gitHandlingServiceResolver.getGitHandlingService(gitType); | ||
|
|
||
| Mono<String> fetchingRemoteMono = gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, fetchRemoteDTO, baseGitMetadata.getGitAuth()); | ||
| Mono<String> fetchingRemoteMono = | ||
| GitUtils.validatePersistedGitSshUrl(baseGitMetadata.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, | ||
| fetchRemoteDTO, | ||
| baseGitMetadata.getGitAuth())); | ||
|
Comment on lines
+3041
to
+3046
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. Merge-related status paths still swallow validator failures. These fetch gates now emit Also applies to: 3263-3268 🤖 Prompt for AI Agents |
||
|
|
||
| Mono<Tuple2<GitStatusDTO, GitStatusDTO>> statusTupleMono = fetchingRemoteMono | ||
| .flatMap(remoteSpecs -> { | ||
|
|
@@ -3243,8 +3260,12 @@ public Mono<MergeStatusDTO> isBranchMergable( | |
| GitHandlingService gitHandlingService = | ||
| gitHandlingServiceResolver.getGitHandlingService(gitType); | ||
|
|
||
| Mono<String> fetchRemoteReferencesMono = gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, fetchRemoteDTO, baseGitMetadata.getGitAuth()); | ||
| Mono<String> fetchRemoteReferencesMono = | ||
| GitUtils.validatePersistedGitSshUrl(baseGitMetadata.getRemoteUrl()) | ||
| .then(gitHandlingService.fetchRemoteReferences( | ||
| jsonTransformationDTO, | ||
| fetchRemoteDTO, | ||
| baseGitMetadata.getGitAuth())); | ||
|
|
||
| Mono<Tuple2<GitStatusDTO, GitStatusDTO>> statusTupleMono = fetchRemoteReferencesMono | ||
| .flatMap(remoteSpecs -> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import net.minidev.json.JSONObject; | ||
| import org.eclipse.jgit.util.StringUtils; | ||
| import reactor.core.publisher.Mono; | ||
| import reactor.core.scheduler.Schedulers; | ||
| import reactor.netty.http.client.HttpClientRequest; | ||
|
|
||
| import java.io.IOException; | ||
|
|
@@ -42,6 +43,86 @@ public class GitUtils { | |
| public static final Pattern URL_PATTERN_WITHOUT_SCHEME = | ||
| Pattern.compile("^[a-z_][\\w-]+@(?<host>[\\w-.]+):/*(?<path>.+?)(\\.git)?$"); | ||
|
|
||
| /** | ||
| * Extracts the hostname from a Git SSH URL. | ||
| * Supports both scheme-based (ssh://git@host/path) and SCP-style (git@host:path) formats. | ||
| * | ||
| * @param url the Git remote URL | ||
| * @return the extracted hostname, or null if the URL doesn't match any known SSH format | ||
| */ | ||
| public static String extractHostFromGitUrl(String url) { | ||
| if (StringUtils.isEmptyOrNull(url)) { | ||
| return null; | ||
| } | ||
|
|
||
| Matcher match = URL_PATTERN_WITH_SCHEME.matcher(url); | ||
| if (match.matches()) { | ||
| return match.group("host"); | ||
| } | ||
|
|
||
| match = URL_PATTERN_WITHOUT_SCHEME.matcher(url); | ||
| if (match.matches()) { | ||
| return match.group("host"); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Validates that the host in a Git SSH URL is not a blocked internal/reserved address. | ||
| * Uses {@link WebClientUtils#resolveIfAllowed(String)} to resolve the hostname and check | ||
| * against loopback, link-local, multicast, cloud metadata, and ULA addresses. | ||
| * RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) are intentionally allowed | ||
| * to support self-hosted Git servers on private networks. | ||
| * | ||
| * @param remoteUrl the Git SSH remote URL to validate | ||
| * @return a Mono that completes empty if allowed, or errors with AppsmithException if blocked | ||
| */ | ||
| public static Mono<Void> validateGitSshUrl(String remoteUrl) { | ||
| if (StringUtils.isEmptyOrNull(remoteUrl)) { | ||
| return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "remote url")); | ||
| } | ||
|
|
||
| String host = extractHostFromGitUrl(remoteUrl); | ||
| if (host == null) { | ||
| return Mono.error(new AppsmithException( | ||
| AppsmithError.INVALID_GIT_CONFIGURATION, | ||
| "Remote URL is not a valid SSH URL. Example: git@example.com:username/reponame.git")); | ||
| } | ||
|
|
||
| return Mono.fromCallable(() -> WebClientUtils.resolveIfAllowed(host)) | ||
| .subscribeOn(Schedulers.boundedElastic()) | ||
| .flatMap(resolved -> resolved.isPresent() | ||
| ? Mono.<Void>empty() | ||
| : Mono.error(new AppsmithException(AppsmithError.GIT_REMOTE_URL_HOST_BLOCKED, host))); | ||
|
Comment on lines
+93
to
+97
Collaborator
Author
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. @coderabbitai I think this is impractical because JGit’s SSH session factory resolves hostnames internally - there’s no API to pass a pre-resolved IP for SSH connections. The attack window is negligible (validation and connect happen in the same reactive chain), and SSH host key verification provides additional protection against rebinding. WDYT?
Comment on lines
+93
to
+97
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. Separate DNS misses from blocked hosts.
Also applies to: 119-123 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Defense-in-depth validation for persisted remote URLs before outbound SSH operations. | ||
| * Unlike {@link #validateGitSshUrl(String)}, this method gracefully skips null/empty URLs | ||
| * and non-SSH URLs (e.g. HTTPS), since those don't trigger the SSH SSRF vector. | ||
| * | ||
| * @param remoteUrl the persisted Git remote URL to validate | ||
| * @return a Mono that completes empty if allowed or not an SSH URL, or errors if blocked | ||
| */ | ||
| public static Mono<Void> validatePersistedGitSshUrl(String remoteUrl) { | ||
| if (StringUtils.isEmptyOrNull(remoteUrl)) { | ||
| return Mono.empty(); | ||
| } | ||
|
|
||
| String host = extractHostFromGitUrl(remoteUrl); | ||
| if (host == null) { | ||
| // Not an SSH URL (e.g. HTTPS) — no SSH SSRF risk, skip validation | ||
| return Mono.empty(); | ||
| } | ||
|
|
||
| return Mono.fromCallable(() -> WebClientUtils.resolveIfAllowed(host)) | ||
| .subscribeOn(Schedulers.boundedElastic()) | ||
| .flatMap(resolved -> resolved.isPresent() | ||
| ? Mono.<Void>empty() | ||
| : Mono.error(new AppsmithException(AppsmithError.GIT_REMOTE_URL_HOST_BLOCKED, host))); | ||
| } | ||
|
|
||
| /** | ||
| * Sample repo urls : | ||
| * git@example.com:user/repoName.git | ||
|
|
||
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.
Preserve
GIT_REMOTE_URL_HOST_BLOCKEDincreateReference().fetchRemoteMonocan now fail withAppsmithException, but the downstreamonErrorResumeat Line 767 rewrites every failure asGIT_ACTION_FAILED. The branch creation flow still aborts, but clients lose the specific 400 and message introduced by this PR.🤖 Prompt for AI Agents