Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.appsmith.git.helpers;

import com.appsmith.external.git.utils.CryptoUtil;
import com.appsmith.util.WebClientUtils;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.crypto.params.AsymmetricKeyParameter;
import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil;
Expand Down Expand Up @@ -161,6 +162,18 @@ public boolean accept(
"Accepting server key: connectAddress={}, algorithm={}",
connectAddress,
serverKey != null ? serverKey.getAlgorithm() : "null");

if (remoteAddress != null) {
String resolvedIp = remoteAddress.getAddress().getHostAddress();
if (WebClientUtils.resolveIfAllowed(resolvedIp).isEmpty()) {
log.warn(
"Rejecting SSH connection to blocked address: connectAddress={}, resolvedIp={}",
connectAddress,
resolvedIp);
return false;
}
}

return true;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,14 @@ public enum AppsmithError {
"Invalid Git private key",
ErrorType.GIT_CONFIGURATION_ERROR,
null),
GIT_REMOTE_URL_HOST_BLOCKED(
400,
AppsmithErrorCode.GIT_REMOTE_URL_HOST_BLOCKED.getCode(),
"The Git remote URL host ''{0}'' is not allowed. Connections to internal or reserved addresses are blocked.",
AppsmithErrorAction.DEFAULT,
"Git remote URL host blocked",
ErrorType.GIT_CONFIGURATION_ERROR,
null),
;

private final Integer httpErrorCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ public enum AppsmithErrorCode {
"AE-TMR-4031", "Rate limit exhausted, blocking the host name failed"),
TRIGGER_PARAMETERS_EMPTY("AE-DS-4001", "Trigger parameters empty."),
INSUFFICIENT_PASSWORD_STRENGTH("AE-PSW-4002", "Insufficient password strength"),
GIT_REMOTE_URL_HOST_BLOCKED("AE-GIT-5021", "Git remote URL host blocked"),
;
private final String code;
private final String description;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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));
Comment on lines +761 to +762
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve GIT_REMOTE_URL_HOST_BLOCKED in createReference().

fetchRemoteMono can now fail with AppsmithException, but the downstream onErrorResume at Line 767 rewrites every failure as GIT_ACTION_FAILED. The branch creation flow still aborts, but clients lose the specific 400 and message introduced by this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCEImpl.java`
around lines 761 - 762, In createReference(), the fetchRemoteMono (produced by
GitUtils.validatePersistedGitSshUrl(...).then(...)) can throw an
AppsmithException with code GIT_REMOTE_URL_HOST_BLOCKED but the downstream
onErrorResume unconditionally maps all errors to GIT_ACTION_FAILED; update the
onErrorResume that handles fetchRemoteMono so it preserves and re-throws
existing AppsmithException instances (specifically when error.getErrorCode() ==
GIT_REMOTE_URL_HOST_BLOCKED) instead of wrapping them—i.e., in the onErrorResume
for fetchRemoteMono check if the throwable is an AppsmithException and return
Mono.error(throwable) (or rethrow with the same code/message) otherwise map to a
new AppsmithException with GIT_ACTION_FAILED; reference fetchRemoteMono,
createReference(), and the onErrorResume handler to locate the change.


Mono<? extends Artifact> createBranchMono = acquireGitLockMono
.flatMap(ignoreLockAcquisition -> removeDanglingLock
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: {}",
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Merge-related status paths still swallow validator failures.

These fetch gates now emit AppsmithException, but the downstream handlers at Line 3107 and Line 3354 call Mono.error(error) without returning it. A blocked host is therefore rewritten as a generic status failure in mergeBranch() and as mergeAble=false in isBranchMergable().

Also applies to: 3263-3268

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-server/src/main/java/com/appsmith/server/git/central/CentralGitServiceCEImpl.java`
around lines 3041 - 3046, The validator errors from
GitUtils.validatePersistedGitSshUrl are being swallowed because the downstream
error handlers in mergeBranch() and isBranchMergable() call Mono.error(error)
without returning it; update those reactive chains (the handlers that currently
invoke Mono.error(error)) to return Mono.error(error) (i.e., use "return
Mono.error(error)" or directly "Mono.error(error)" as the last expression in the
lambda) so the error is propagated, and apply the same fix to the similar
fetch/fetchingRemoteMono blocks referenced around the other handler (the
identical pattern at the other fetch-related block). Ensure each
flatMap/handle/otherwise lambda returns the Mono.error instead of just invoking
it so blocked-host/AppsmithException flows reach the caller.


Mono<Tuple2<GitStatusDTO, GitStatusDTO>> statusTupleMono = fetchingRemoteMono
.flatMap(remoteSpecs -> {
Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,8 @@ public Mono<? extends Artifact> connectArtifactToGit(
Mono<? extends Artifact> artifactToConnectMono =
gitArtifactHelper.getArtifactById(baseArtifactId, connectToGitPermission);

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();
Expand Down Expand Up @@ -2892,12 +2893,12 @@ private Mono<List<GitBranchDTO>> handleRepoNotFoundException(String baseArtifact
Path repoPath = gitArtifactHelper.getRepoSuffixPath(
baseArtifact.getWorkspaceId(), baseArtifact.getId(), gitArtifactMetadata.getRepoName());
GitAuth gitAuth = gitArtifactMetadata.getGitAuth();
return gitExecutor
.cloneRemoteIntoArtifactRepo(
return GitUtils.validateGitSshUrl(gitArtifactMetadata.getRemoteUrl())
.then(gitExecutor.cloneRemoteIntoArtifactRepo(
repoPath,
gitArtifactMetadata.getRemoteUrl(),
gitAuth.getPrivateKey(),
gitAuth.getPublicKey())
gitAuth.getPublicKey()))
.flatMap(defaultBranch -> gitExecutor.listBranches(repoPath))
.flatMap(gitBranchDTOList -> {
List<String> branchesToCheckout = new ArrayList<>();
Expand Down Expand Up @@ -3176,7 +3177,9 @@ public Mono<? extends ArtifactImportDTO> importArtifactFromGit(
Mono<Boolean> isPrivateRepoMono = GitUtils.isRepoPrivate(
GitUtils.convertSshUrlToBrowserSupportedUrl(gitConnectDTO.getRemoteUrl()))
.cache();
Mono<? extends ArtifactImportDTO> importedArtifactMono = workspaceMono
Mono<? extends ArtifactImportDTO> importedArtifactMono = GitUtils.validateGitSshUrl(
gitConnectDTO.getRemoteUrl())
.then(workspaceMono)
.then(getSSHKeyForCurrentUser())
.zipWith(isPrivateRepoMono)
.switchIfEmpty(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,16 @@ public Mono<String> fetchRemoteRepository(
String repoName = jsonTransformationDTO.getRepoName();
Path temporaryStorage = Path.of(workspaceId, placeHolder, repoName);

return fsGitHandler
.cloneRemoteIntoArtifactRepo(
temporaryStorage, gitConnectDTO.getRemoteUrl(), gitAuth.getPrivateKey(), gitAuth.getPublicKey())
return GitUtils.validateGitSshUrl(gitConnectDTO.getRemoteUrl())
.then(fsGitHandler.cloneRemoteIntoArtifactRepo(
temporaryStorage,
gitConnectDTO.getRemoteUrl(),
gitAuth.getPrivateKey(),
gitAuth.getPublicKey()))
.onErrorResume(error -> {
if (error instanceof AppsmithException) {
return Mono.error(error);
}
log.error("Error in cloning the remote repo, {}", error.getMessage());
return gitAnalyticsUtils
.addAnalyticsForGitOperation(
Expand Down Expand Up @@ -243,10 +249,13 @@ public Mono<String> fetchRemoteRepository(
gitArtifactHelperResolver.getArtifactHelper(artifact.getArtifactType());
Path repoSuffix = gitArtifactHelper.getRepoSuffixPath(artifact.getWorkspaceId(), artifact.getId(), repoName);

return fsGitHandler
.cloneRemoteIntoArtifactRepo(
repoSuffix, gitConnectDTO.getRemoteUrl(), gitAuth.getPrivateKey(), gitAuth.getPublicKey())
return GitUtils.validateGitSshUrl(gitConnectDTO.getRemoteUrl())
.then(fsGitHandler.cloneRemoteIntoArtifactRepo(
repoSuffix, gitConnectDTO.getRemoteUrl(), gitAuth.getPrivateKey(), gitAuth.getPublicKey()))
.onErrorResume(error -> {
if (error instanceof AppsmithException) {
return Mono.error(error);
}
log.error("Error while cloning the remote repo, {}", error.getMessage());

Mono<Boolean> deleteLocalRepoMono = commonGitFileUtils.deleteLocalRepo(repoSuffix);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Separate DNS misses from blocked hosts.

WebClientUtils.resolveIfAllowed() also returns Optional.empty() when the host cannot be resolved, so these branches turn typos or transient resolver failures on public remotes into GIT_REMOTE_URL_HOST_BLOCKED. That changes a retryable/network failure into a 400 client error.

Also applies to: 119-123

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/GitUtils.java`
around lines 93 - 97, The code treats Optional.empty() from
WebClientUtils.resolveIfAllowed(...) as "host blocked" but that same return
value can mean DNS resolution failed; change the logic to first attempt explicit
DNS resolution and only then check allowlist: call a resolution API (e.g.,
WebClientUtils.resolve(host) or add a resolve-only variant) and if resolution
fails return a retryable/network error (not
AppsmithError.GIT_REMOTE_URL_HOST_BLOCKED), otherwise if resolved but not
allowed throw AppsmithException(AppsmithError.GIT_REMOTE_URL_HOST_BLOCKED,
host); apply the same change to both occurrences that use resolveIfAllowed (the
snippet around WebClientUtils.resolveIfAllowed and the later block at 119-123)
so typos/transient resolver failures are not turned into client 400 errors.

}

/**
* 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
Expand Down
Loading
Loading