retryAndRedirectOnAuthenticationFailure(
getOriginFromReferer(exchange.getRequest().getHeaders().getOrigin());
}
+ // Sanitize originHeader to prevent open redirect via crafted state parameter
+ originHeader = RedirectHelper.sanitizeRedirectUrl(
+ originHeader, exchange.getRequest().getHeaders());
+
// Construct the redirect URL based on the exception type
String url = constructRedirectUrl(exception, originHeader, redirectUrl);
@@ -102,7 +107,7 @@ private String constructRedirectUrl(AuthenticationException exception, String or
}
}
if (redirectUrl != null && !redirectUrl.trim().isEmpty()) {
- url = url + "&" + REDIRECT_URL_QUERY_PARAM + "=" + redirectUrl;
+ url = url + "&" + REDIRECT_URL_QUERY_PARAM + "=" + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
}
return url;
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java
index 62ddf69410f3..b975924e99bc 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/ce/CustomCookieWebSessionIdResolverCE.java
@@ -23,6 +23,7 @@ public CustomCookieWebSessionIdResolverCE() {
// If the max age is not set, some browsers will default to deleting the cookies on session close.
this.setCookieMaxAge(Duration.of(30, DAYS));
this.addCookieInitializer((builder) -> builder.path("/"));
+ this.addCookieInitializer((builder) -> builder.sameSite(LAX));
}
@Override
@@ -31,8 +32,16 @@ public void setSessionId(ServerWebExchange exchange, String id) {
super.setSessionId(exchange, id);
}
+ /**
+ * Hook for subclasses to apply per-request cookie attributes.
+ *
+ * WARNING: Implementations must NOT call {@link #addCookieInitializer} here.
+ * That method permanently appends to the singleton's Consumer chain via
+ * {@code Consumer.andThen()}, causing unbounded growth and eventually a
+ * {@link StackOverflowError}. Instead, modify cookies on the response after
+ * {@code super.setSessionId()} builds them.
+ */
protected void addCookieInitializers(ServerWebExchange exchange) {
- // Add the appropriate SameSite attribute based on the exchange attribute
- addCookieInitializer((builder) -> builder.sameSite(LAX));
+ // No-op in CE. SameSite=Lax is set once in the constructor.
}
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java
index faed60b418e3..df45b419343e 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java
@@ -2,6 +2,7 @@
import com.appsmith.external.views.Views;
import com.appsmith.server.constants.Url;
+import com.appsmith.server.domains.Application;
import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserData;
import com.appsmith.server.dtos.InviteUsersDTO;
@@ -206,4 +207,29 @@ public Mono> resendEmailVerification(
public Mono verifyEmailVerificationToken(ServerWebExchange exchange) {
return service.verifyEmailVerificationToken(exchange);
}
+
+ /**
+ * Toggle favorite status for an application
+ * @param applicationId Application ID to toggle favorite status for
+ * @return Updated user data with modified favorites list
+ */
+ @JsonView(Views.Public.class)
+ @PutMapping("/applications/{applicationId}/favorite")
+ public Mono> toggleFavoriteApplication(@PathVariable String applicationId) {
+ return userDataService
+ .toggleFavoriteApplication(applicationId)
+ .map(userData -> new ResponseDTO<>(HttpStatus.OK, userData));
+ }
+
+ /**
+ * Get all favorite applications for the current user
+ * @return List of favorited applications
+ */
+ @JsonView(Views.Public.class)
+ @GetMapping("/favoriteApplications")
+ public Mono>> getFavoriteApplications() {
+ return userDataService
+ .getFavoriteApplications()
+ .map(applications -> new ResponseDTO<>(HttpStatus.OK, applications));
+ }
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java
index 6daca7445769..1ffc658cd479 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/UserData.java
@@ -63,6 +63,10 @@ public class UserData extends BaseDomain {
@JsonView(Views.Public.class)
private List recentlyUsedEntityIds;
+ // List of application IDs favorited by the user
+ @JsonView(Views.Public.class)
+ private List favoriteApplicationIds;
+
// Map of defaultApplicationIds with the GitProfiles. For fallback/default git profile per user default will be the
// the key for the map
@JsonView(Views.Internal.class)
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java
index 081321e6f10a..f79ef6640a2f 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/RedirectHelper.java
@@ -7,6 +7,7 @@
import com.appsmith.server.repositories.ApplicationRepository;
import com.appsmith.server.solutions.ApplicationPermission;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
@@ -23,6 +24,7 @@
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+@Slf4j
@Component
@RequiredArgsConstructor
public class RedirectHelper {
@@ -60,7 +62,11 @@ public Mono getRedirectUrl(ServerHttpRequest request) {
} else if (queryParams.getFirst(FORK_APP_ID_QUERY_PARAM) != null) {
final String forkAppId = queryParams.getFirst(FORK_APP_ID_QUERY_PARAM);
- final String defaultRedirectUrl = httpHeaders.getOrigin() + DEFAULT_REDIRECT_URL;
+ final String origin = httpHeaders.getOrigin();
+ if (origin == null) {
+ return Mono.just(DEFAULT_REDIRECT_URL);
+ }
+ final String defaultRedirectUrl = origin + DEFAULT_REDIRECT_URL;
return applicationRepository
.findByClonedFromApplicationId(forkAppId, applicationPermission.getReadPermission())
.map(application -> {
@@ -123,6 +129,8 @@ private static String getRedirectUrlFromHeader(HttpHeaders httpHeaders) {
/**
* If redirectUrl is empty, it'll be set to DEFAULT_REDIRECT_URL.
* If the redirectUrl does not have the base url, it'll prepend that from header origin.
+ * If the redirectUrl is an absolute URL pointing to a different host, it is rejected
+ * to prevent open redirect attacks.
*
* @param redirectUrl
* @param httpHeaders
@@ -138,9 +146,128 @@ private static String fulfillRedirectUrl(String redirectUrl, HttpHeaders httpHea
redirectUrl = httpHeaders.getOrigin() + redirectUrl;
}
+ // Validate that absolute redirect URLs point to the same origin as the request.
+ // This prevents open redirect attacks where an attacker supplies an external URL
+ // (e.g., https://evil.com) as the redirectUrl parameter.
+ redirectUrl = sanitizeRedirectUrl(redirectUrl, httpHeaders);
+
return redirectUrl;
}
+ /**
+ * Checks whether a redirect URL is safe by verifying it is either:
+ * - A relative path (no scheme), or
+ * - An absolute URL whose host matches the request's Origin header
+ *
+ * This prevents open redirect vulnerabilities where user-supplied URLs
+ * could redirect authenticated users to attacker-controlled domains.
+ *
+ * @param redirectUrl The URL to validate
+ * @param httpHeaders The HTTP headers from the current request
+ * @return true if the URL is safe to redirect to, false otherwise
+ */
+ static boolean isSafeRedirectUrl(String redirectUrl, HttpHeaders httpHeaders) {
+ if (!StringUtils.hasText(redirectUrl)) {
+ return true;
+ }
+
+ // Only single-slash-prefixed relative paths are safe (e.g., /applications)
+ if (redirectUrl.startsWith("/") && !redirectUrl.startsWith("//")) {
+ return true;
+ }
+
+ // Reject anything that isn't http(s) — covers javascript:, data:, //, bare paths, etc.
+ if (!redirectUrl.startsWith("http://") && !redirectUrl.startsWith("https://")) {
+ return false;
+ }
+
+ // For absolute URLs, the host must match the request origin
+ String origin = httpHeaders.getOrigin();
+ if (StringUtils.isEmpty(origin)) {
+ // If there is no Origin header, we cannot validate — reject absolute URLs
+ // to be safe. Relative URLs were already allowed above.
+ return false;
+ }
+
+ try {
+ URI redirectUri = new URI(redirectUrl);
+ URI originUri = new URI(origin);
+
+ // Reject URLs with userinfo (e.g., https://evil.com@app.appsmith.com)
+ // Java's URI parser treats evil.com as userinfo and app.appsmith.com as host,
+ // but browser behavior varies — block these outright to be safe.
+ if (redirectUri.getUserInfo() != null) {
+ return false;
+ }
+
+ String redirectHost = redirectUri.getHost();
+ String originHost = originUri.getHost();
+
+ if (redirectHost == null || originHost == null) {
+ return false;
+ }
+
+ // Compare host and port.
+ // When both URIs omit the port (raw port == -1), treat them as matching
+ // regardless of scheme — a scheme downgrade (https → http) on the same host
+ // is a transport-security concern, not an open redirect.
+ // When at least one port is explicit, normalize default ports per scheme
+ // (80 for http, 443 for https) before comparing.
+ int rawRedirectPort = redirectUri.getPort();
+ int rawOriginPort = originUri.getPort();
+ boolean portsMatch;
+ if (rawRedirectPort == -1 && rawOriginPort == -1) {
+ portsMatch = true;
+ } else {
+ portsMatch = normalizePort(redirectUri.getScheme(), rawRedirectPort)
+ == normalizePort(originUri.getScheme(), rawOriginPort);
+ }
+
+ return redirectHost.equalsIgnoreCase(originHost) && portsMatch;
+ } catch (URISyntaxException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Normalizes a port number, mapping -1 (unspecified) to the default port for the scheme.
+ * This ensures that https://app.com and https://app.com:443 are treated as equivalent.
+ */
+ private static int normalizePort(String scheme, int port) {
+ if (port != -1) {
+ return port;
+ }
+ if ("https".equalsIgnoreCase(scheme)) {
+ return 443;
+ }
+ if ("http".equalsIgnoreCase(scheme)) {
+ return 80;
+ }
+ return port;
+ }
+
+ /**
+ * Sanitizes a redirect URL to prevent open redirect attacks.
+ * If the URL is not safe (points to an external host), returns the default redirect URL.
+ * This method is intended for use by authentication handlers that construct redirect URLs
+ * from sources other than fulfillRedirectUrl (e.g., OAuth2 state parameter).
+ *
+ * @param redirectUrl The URL to sanitize
+ * @param httpHeaders The HTTP headers from the current request
+ * @return The original URL if safe, or the default redirect URL if not
+ */
+ public static String sanitizeRedirectUrl(String redirectUrl, HttpHeaders httpHeaders) {
+ if (isSafeRedirectUrl(redirectUrl, httpHeaders)) {
+ return redirectUrl;
+ }
+ String sanitizedLog = redirectUrl.replaceAll("[\\r\\n]", "");
+ log.warn(
+ "Blocked open redirect attempt to: {}",
+ sanitizedLog.length() > 200 ? sanitizedLog.substring(0, 200) + "..." : sanitizedLog);
+ String origin = httpHeaders.getOrigin();
+ return (!StringUtils.isEmpty(origin) ? origin : "") + DEFAULT_REDIRECT_URL;
+ }
+
/**
* This function only checks the incoming request for all possible sources of a redirection domain
* and returns with the first valid domain that it finds
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java
index 302447b976b5..2927f0cc6f36 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/BridgeUpdate.java
@@ -22,6 +22,11 @@ public BridgeUpdate push(@NonNull String key, @NonNull Object value) {
return this;
}
+ public BridgeUpdate addToSet(@NonNull String key, @NonNull Object value) {
+ update.addToSet(key, value);
+ return this;
+ }
+
public BridgeUpdate pull(@NonNull String key, @NonNull Object value) {
update.pull(key, value);
return this;
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java
index 52f76fe5c343..d670afd14d2e 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCE.java
@@ -118,7 +118,7 @@ Flux getUnpublishedActions(
Mono archiveById(String id);
- Mono> archiveActionsByApplicationId(String applicationId, AclPermission permission);
+ Mono archiveActionsByApplicationId(String applicationId, AclPermission permission);
Flux getUnpublishedActionsExceptJs(MultiValueMap params);
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java
index 277fa54a48be..7455bc4bd01a 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newactions/base/NewActionServiceCEImpl.java
@@ -1315,15 +1315,18 @@ public Mono archive(NewAction newAction) {
}
@Override
- public Mono> archiveActionsByApplicationId(String applicationId, AclPermission permission) {
+ public Mono archiveActionsByApplicationId(String applicationId, AclPermission permission) {
+ // Limit concurrency to avoid saturating the MongoDB NIO event loop thread pool
+ // during bulk application deletion. Per-entity analytics are intentionally skipped here;
+ // only the top-level application.deleted event is logged.
return repository
.findByApplicationId(applicationId, permission)
- .flatMap(repository::archive)
+ .flatMap(repository::archive, 8)
.onErrorResume(throwable -> {
log.error(throwable.getMessage());
return Mono.empty();
})
- .collectList();
+ .then();
}
private Mono updateDatasourcePolicyForPublicAction(NewAction action, Datasource datasource) {
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java
index 11024c59725b..d4030284c0c1 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCE.java
@@ -54,7 +54,7 @@ Mono findApplicationPagesByBranchedApplicationIdAndViewMode
Mono findByNameAndApplicationIdAndViewMode(
String name, String applicationId, AclPermission permission, Boolean view);
- Mono> archivePagesByApplicationId(String applicationId, AclPermission permission);
+ Mono archivePagesByApplicationId(String applicationId, AclPermission permission);
Mono updatePage(String pageId, PageDTO page);
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java
index 98a5c83d0297..12a05e962cf3 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/newpages/base/NewPageServiceCEImpl.java
@@ -455,10 +455,12 @@ public Flux findNewPagesByApplicationId(
}
@Override
- public Mono> archivePagesByApplicationId(String applicationId, AclPermission permission) {
+ public Mono archivePagesByApplicationId(String applicationId, AclPermission permission) {
+ // Limit concurrency to avoid saturating the MongoDB NIO event loop thread pool
+ // during bulk application deletion.
return findNewPagesByApplicationId(applicationId, permission)
- .flatMap(repository::archive)
- .collectList();
+ .flatMap(repository::archive, 4)
+ .then();
}
@Override
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java
index 7dea190b87f9..3cc8db080c10 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCE.java
@@ -11,4 +11,42 @@ public interface CustomUserDataRepositoryCE extends AppsmithRepository
Mono removeEntitiesFromRecentlyUsedList(String userId, String workspaceId);
Mono fetchMostRecentlyUsedWorkspaceId(String userId);
+
+ Mono removeApplicationFromFavorites(String applicationId);
+
+ /**
+ * Add an application to a single user's favorites list using an atomic update.
+ *
+ * @param userId ID of the user whose favorites list should be updated
+ * @param applicationId ID of the application to add to favorites
+ * @return Completion signal when the update operation finishes
+ */
+ Mono addFavoriteApplicationForUser(String userId, String applicationId);
+
+ /**
+ * Atomically add an application to a user's favorites list only if the list
+ * has fewer than {@code maxLimit} entries. Uses a single conditional MongoDB
+ * update ({@code $addToSet} + array-index existence check) so the limit
+ * cannot be exceeded by concurrent requests.
+ *
+ * @param userId ID of the user whose favorites list should be updated
+ * @param applicationId ID of the application to add to favorites
+ * @param maxLimit Maximum allowed size of the favorites list
+ * @return Number of matched documents: 1 if the update was applied,
+ * 0 if the array already had {@code maxLimit} or more entries
+ */
+ Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit);
+
+ /**
+ * Atomically remove an application from a user's favorites list only if it
+ * is present. The query matches the user document only when the array
+ * contains {@code applicationId}, so the returned count doubles as a
+ * "was-it-actually-removed?" signal.
+ *
+ * @param userId ID of the user whose favorites list should be updated
+ * @param applicationId ID of the application to remove from favorites
+ * @return Number of matched documents: 1 if the application was removed,
+ * 0 if it was not in the favorites list
+ */
+ Mono removeFavoriteApplicationForUser(String userId, String applicationId);
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java
index 1feb33de0a6b..a439e8dc94c2 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/CustomUserDataRepositoryCEImpl.java
@@ -6,6 +6,7 @@
import com.appsmith.server.helpers.ce.bridge.BridgeUpdate;
import com.appsmith.server.projections.UserRecentlyUsedEntitiesProjection;
import com.appsmith.server.repositories.BaseAppsmithRepositoryImpl;
+import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Mono;
@@ -45,4 +46,47 @@ public Mono fetchMostRecentlyUsedWorkspaceId(String userId) {
: recentlyUsedWorkspaceIds.get(0).getWorkspaceId();
});
}
+
+ @Override
+ public Mono removeApplicationFromFavorites(String applicationId) {
+ // MongoDB update query to pull applicationId from all users' favoriteApplicationIds arrays
+ BridgeUpdate update = new BridgeUpdate();
+ update.pull(UserData.Fields.favoriteApplicationIds, applicationId);
+ return queryBuilder().updateAll(update).then();
+ }
+
+ @Override
+ public Mono addFavoriteApplicationForUser(String userId, String applicationId) {
+ BridgeUpdate update = new BridgeUpdate();
+ update.addToSet(UserData.Fields.favoriteApplicationIds, applicationId);
+ return queryBuilder()
+ .criteria(Bridge.equal(UserData.Fields.userId, userId))
+ .updateFirst(update)
+ .then();
+ }
+
+ @Override
+ public Mono addFavoriteApplicationForUserIfUnderLimit(String userId, String applicationId, int maxLimit) {
+ BridgeUpdate update = new BridgeUpdate();
+ update.addToSet(UserData.Fields.favoriteApplicationIds, applicationId);
+ // Array-index existence trick: "field.{N-1}" not existing means the array has fewer than N elements.
+ Criteria criteria = Criteria.where(UserData.Fields.userId)
+ .is(userId)
+ .and(UserData.Fields.favoriteApplicationIds + "." + (maxLimit - 1))
+ .exists(false);
+ return queryBuilder().criteria(criteria).updateFirst(update);
+ }
+
+ @Override
+ public Mono removeFavoriteApplicationForUser(String userId, String applicationId) {
+ BridgeUpdate update = new BridgeUpdate();
+ update.pull(UserData.Fields.favoriteApplicationIds, applicationId);
+ // Only match if the array actually contains the applicationId so that
+ // matchedCount == 1 means "removed" and 0 means "was not present".
+ Criteria criteria = Criteria.where(UserData.Fields.userId)
+ .is(userId)
+ .and(UserData.Fields.favoriteApplicationIds)
+ .is(applicationId);
+ return queryBuilder().criteria(criteria).updateFirst(update);
+ }
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java
index f8d3e95e18e1..2a8032868088 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationPageServiceImpl.java
@@ -67,7 +67,8 @@ public ApplicationPageServiceImpl(
ClonePageService actionCollectionClonePageService,
ObservationRegistry observationRegistry,
CacheableRepositoryHelper cacheableRepositoryHelper,
- PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService) {
+ PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService,
+ UserDataService userDataService) {
super(
workspaceService,
applicationService,
@@ -99,6 +100,7 @@ public ApplicationPageServiceImpl(
actionCollectionClonePageService,
observationRegistry,
cacheableRepositoryHelper,
- postApplicationPublishHookCoordinatorService);
+ postApplicationPublishHookCoordinatorService,
+ userDataService);
}
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java
index 030c6cc69f29..7d20d987d920 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserDataServiceImpl.java
@@ -4,6 +4,7 @@
import com.appsmith.server.repositories.UserDataRepository;
import com.appsmith.server.repositories.UserRepository;
import com.appsmith.server.services.ce.UserDataServiceCEImpl;
+import com.appsmith.server.solutions.ApplicationPermission;
import com.appsmith.server.solutions.ReleaseNotesService;
import jakarta.validation.Validator;
import org.springframework.stereotype.Service;
@@ -21,6 +22,7 @@ public UserDataServiceImpl(
ReleaseNotesService releaseNotesService,
FeatureFlagService featureFlagService,
ApplicationRepository applicationRepository,
+ ApplicationPermission applicationPermission,
OrganizationService organizationService) {
super(
@@ -33,6 +35,7 @@ public UserDataServiceImpl(
releaseNotesService,
featureFlagService,
applicationRepository,
+ applicationPermission,
organizationService);
}
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java
index 3d9485e7cb90..269aad5dd0e6 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationPageServiceCEImpl.java
@@ -53,6 +53,7 @@
import com.appsmith.server.services.AnalyticsService;
import com.appsmith.server.services.PermissionGroupService;
import com.appsmith.server.services.SessionUserService;
+import com.appsmith.server.services.UserDataService;
import com.appsmith.server.services.WorkspaceService;
import com.appsmith.server.solutions.ActionPermission;
import com.appsmith.server.solutions.ApplicationPermission;
@@ -140,6 +141,7 @@ public class ApplicationPageServiceCEImpl implements ApplicationPageServiceCE {
private final CacheableRepositoryHelper cacheableRepositoryHelper;
private final PostPublishHookCoordinatorService postApplicationPublishHookCoordinatorService;
+ private final UserDataService userDataService;
@Override
public Mono createPage(PageDTO page) {
@@ -541,10 +543,14 @@ public Mono deleteApplication(String id) {
}
return Flux.fromIterable(List.of(application));
})
- .flatMap(application -> {
- log.debug("Archiving application with id: {}", application.getId());
- return deleteApplicationByResource(application);
- })
+ // Limit concurrency to avoid saturating the event loop during deletion of
+ // git-connected apps with many branches
+ .flatMap(
+ application -> {
+ log.debug("Archiving application with id: {}", application.getId());
+ return deleteApplicationByResource(application);
+ },
+ 2)
.then(applicationMono)
.flatMap(application -> {
GitArtifactMetadata gitData = application.getGitApplicationMetadata();
@@ -572,6 +578,7 @@ protected Mono deleteApplicationResources(Application application)
Mono actionPermissionMono =
actionPermission.getDeletePermission().cache();
Mono pagePermissionMono = pagePermission.getDeletePermission();
+ String favoriteId = application.getBaseId();
return actionPermissionMono
.flatMap(actionDeletePermission -> actionCollectionService.archiveActionCollectionByApplicationId(
application.getId(), actionDeletePermission))
@@ -580,7 +587,8 @@ protected Mono deleteApplicationResources(Application application)
.then(pagePermissionMono.flatMap(pageDeletePermission ->
newPageService.archivePagesByApplicationId(application.getId(), pageDeletePermission)))
.then(themeService.archiveApplicationThemes(application))
- .flatMap(applicationService::archive);
+ .then(userDataService.removeApplicationFromAllFavorites(favoriteId))
+ .then(applicationService.archive(application));
}
protected Mono sendAppDeleteAnalytics(Application deletedApplication) {
@@ -588,7 +596,16 @@ protected Mono sendAppDeleteAnalytics(Application deletedApplicatio
Map.of(FieldName.APP_MODE, ApplicationMode.EDIT.toString(), FieldName.APPLICATION, deletedApplication);
final Map data = Map.of(FieldName.EVENT_DATA, eventData);
- return analyticsService.sendDeleteEvent(deletedApplication, data);
+ // Run analytics/audit-log on the elastic scheduler so it does not block the
+ // MongoDB NIO event-loop threads, and swallow errors to avoid failing the delete.
+ return analyticsService
+ .sendDeleteEvent(deletedApplication, data)
+ .subscribeOn(LoadShifter.elasticScheduler)
+ .onErrorResume(throwable -> {
+ log.error(
+ "Error sending delete analytics for application {}", deletedApplication.getId(), throwable);
+ return Mono.just(deletedApplication);
+ });
}
@Override
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java
index 0acc7e55537c..a46770443b08 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCE.java
@@ -1,6 +1,7 @@
package com.appsmith.server.services.ce;
import com.appsmith.external.enums.WorkspaceResourceContext;
+import com.appsmith.server.domains.Application;
import com.appsmith.server.domains.GitProfile;
import com.appsmith.server.domains.User;
import com.appsmith.server.domains.UserData;
@@ -9,6 +10,7 @@
import reactor.core.publisher.Mono;
import java.util.Collection;
+import java.util.List;
import java.util.Map;
public interface UserDataServiceCE {
@@ -51,4 +53,10 @@ Mono updateLastUsedResourceAndWorkspaceList(
Mono removeRecentWorkspaceAndChildEntities(String userId, String workspaceId);
Mono getGitProfileForCurrentUser(String defaultApplicationId);
+
+ Mono toggleFavoriteApplication(String applicationId);
+
+ Mono> getFavoriteApplications();
+
+ Mono removeApplicationFromAllFavorites(String applicationId);
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java
index ffc30386d891..1fe5cc1b6d77 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserDataServiceCEImpl.java
@@ -1,7 +1,9 @@
package com.appsmith.server.services.ce;
import com.appsmith.external.enums.WorkspaceResourceContext;
+import com.appsmith.server.acl.AclPermission;
import com.appsmith.server.constants.FieldName;
+import com.appsmith.server.domains.Application;
import com.appsmith.server.domains.Asset;
import com.appsmith.server.domains.GitProfile;
import com.appsmith.server.domains.User;
@@ -22,10 +24,12 @@
import com.appsmith.server.services.FeatureFlagService;
import com.appsmith.server.services.OrganizationService;
import com.appsmith.server.services.SessionUserService;
+import com.appsmith.server.solutions.ApplicationPermission;
import com.appsmith.server.solutions.ReleaseNotesService;
import jakarta.validation.Validator;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.codec.multipart.Part;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
@@ -34,6 +38,7 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -54,6 +59,8 @@ public class UserDataServiceCEImpl extends BaseService getGitProfileForCurrentUser(String defaultApplicationId)
return authorProfile;
});
}
+
+ /**
+ * Toggle favorite status for an application
+ * @param applicationId Application ID to toggle
+ * @return Updated UserData with modified favorites list
+ * @throws AppsmithException if the maximum favorite limit is reached when trying to add a favorite
+ */
+ @Override
+ public Mono toggleFavoriteApplication(String applicationId) {
+ return sessionUserService.getCurrentUser().zipWhen(this::getForUser).flatMap(tuple -> {
+ User user = tuple.getT1();
+ UserData userData = tuple.getT2();
+
+ // For new users without a persisted UserData document the atomic
+ // repo operations will not match anything, so fall back to save.
+ // If a concurrent request creates the document first (DuplicateKeyException),
+ // retry through the atomic existing-user path.
+ if (userData.getId() == null) {
+ List favorites = userData.getFavoriteApplicationIds();
+ if (favorites == null) {
+ favorites = new ArrayList<>();
+ }
+
+ if (favorites.remove(applicationId)) {
+ userData.setFavoriteApplicationIds(favorites);
+ return repository.save(userData).onErrorResume(DuplicateKeyException.class, ex -> repository
+ .removeFavoriteApplicationForUser(user.getId(), applicationId)
+ .flatMap(count -> getForUser(user.getId())));
+ }
+
+ // Adding — verify access first
+ AclPermission readPermission = applicationPermission.getReadPermission();
+ List finalFavorites = favorites;
+ return applicationRepository
+ .queryBuilder()
+ .criteria(Bridge.equal(Application.Fields.id, applicationId))
+ .permission(readPermission)
+ .first()
+ .switchIfEmpty(Mono.error(new AppsmithException(
+ AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId)))
+ .flatMap(application -> {
+ if (finalFavorites.size() >= MAX_FAVORITE_APPLICATIONS_LIMIT) {
+ return Mono.error(new AppsmithException(
+ AppsmithError.INVALID_PARAMETER,
+ String.format(
+ "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.",
+ MAX_FAVORITE_APPLICATIONS_LIMIT)));
+ }
+ finalFavorites.add(applicationId);
+ userData.setFavoriteApplicationIds(finalFavorites);
+ return repository.save(userData).onErrorResume(DuplicateKeyException.class, ex -> repository
+ .addFavoriteApplicationForUserIfUnderLimit(
+ user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT)
+ .flatMap(matchedCount -> {
+ if (matchedCount == 0) {
+ return Mono.error(new AppsmithException(
+ AppsmithError.INVALID_PARAMETER,
+ String.format(
+ "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.",
+ MAX_FAVORITE_APPLICATIONS_LIMIT)));
+ }
+ return getForUser(user.getId());
+ }));
+ });
+ }
+
+ // For existing users, let the DB decide: try an atomic remove first.
+ return repository
+ .removeFavoriteApplicationForUser(user.getId(), applicationId)
+ .flatMap(removedCount -> {
+ if (removedCount > 0) {
+ // Was in favorites and has been removed.
+ return getForUser(user.getId());
+ }
+
+ // Not in favorites — add it after verifying access.
+ AclPermission readPermission = applicationPermission.getReadPermission();
+ return applicationRepository
+ .queryBuilder()
+ .criteria(Bridge.equal(Application.Fields.id, applicationId))
+ .permission(readPermission)
+ .first()
+ .switchIfEmpty(Mono.error(new AppsmithException(
+ AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, applicationId)))
+ .flatMap(application -> repository
+ .addFavoriteApplicationForUserIfUnderLimit(
+ user.getId(), applicationId, MAX_FAVORITE_APPLICATIONS_LIMIT)
+ .flatMap(matchedCount -> {
+ if (matchedCount == 0) {
+ return Mono.error(new AppsmithException(
+ AppsmithError.INVALID_PARAMETER,
+ String.format(
+ "Maximum favorite applications limit (%d) reached. Please remove some favorites before adding new ones.",
+ MAX_FAVORITE_APPLICATIONS_LIMIT)));
+ }
+ return getForUser(user.getId());
+ }));
+ });
+ });
+ }
+
+ /**
+ * Get all favorite applications for current user
+ * Filters out deleted applications and applications user no longer has access to
+ * @return List of favorite applications
+ */
+ @Override
+ public Mono> getFavoriteApplications() {
+ return getForCurrentUser().flatMap(userData -> {
+ List favoriteIds = userData.getFavoriteApplicationIds();
+ if (CollectionUtils.isNullOrEmpty(favoriteIds)) {
+ return Mono.just(Collections.emptyList());
+ }
+
+ AclPermission readPermission = applicationPermission.getReadPermission();
+ return applicationRepository
+ .queryBuilder()
+ .criteria(Bridge.in(Application.Fields.id, favoriteIds))
+ .permission(readPermission)
+ .all()
+ .collectList();
+ });
+ }
+
+ /**
+ * Remove application from all users' favorites when app is deleted
+ * @param applicationId ID of deleted application
+ */
+ @Override
+ public Mono removeApplicationFromAllFavorites(String applicationId) {
+ return repository.removeApplicationFromFavorites(applicationId);
+ }
}
diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java
index b7f6f6ff9ab1..d2b20b446f0e 100644
--- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java
+++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java
@@ -22,6 +22,7 @@
import com.appsmith.server.dtos.UserUpdateDTO;
import com.appsmith.server.exceptions.AppsmithError;
import com.appsmith.server.exceptions.AppsmithException;
+import com.appsmith.server.helpers.RedirectHelper;
import com.appsmith.server.helpers.UserServiceHelper;
import com.appsmith.server.helpers.UserUtils;
import com.appsmith.server.instanceconfigs.helpers.InstanceVariablesHelper;
@@ -967,9 +968,14 @@ public Mono verifyEmailVerificationToken(ServerWebExchange exchange) {
String baseUrl = exchange.getRequest().getHeaders().getOrigin();
if (redirectUrl == null) {
redirectUrl = baseUrl + DEFAULT_REDIRECT_URL;
+ } else {
+ // Sanitize user-supplied redirectUrl to prevent open redirect attacks
+ redirectUrl = RedirectHelper.sanitizeRedirectUrl(
+ redirectUrl, exchange.getRequest().getHeaders());
}
- String postVerificationRedirectUrl = "/signup-success?redirectUrl=" + redirectUrl
+ String postVerificationRedirectUrl = "/signup-success?redirectUrl="
+ + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8)
+ "&enableFirstTimeUserExperience=" + enableFirstTimeUserExperienceParam;
String errorRedirectUrl = "";
diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java
new file mode 100644
index 000000000000..fb3206fa1696
--- /dev/null
+++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/RedirectHelperOpenRedirectTest.java
@@ -0,0 +1,381 @@
+package com.appsmith.server.helpers;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.http.HttpHeaders;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for open redirect prevention in RedirectHelper.
+ *
+ * The isSafeRedirectUrl() method ensures that absolute redirect URLs
+ * only point to the same origin as the request, preventing attackers
+ * from crafting login links that redirect authenticated users to
+ * malicious domains.
+ */
+class RedirectHelperOpenRedirectTest {
+
+ private HttpHeaders headersWithOrigin(String origin) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setOrigin(origin);
+ return headers;
+ }
+
+ // --- isSafeRedirectUrl tests ---
+
+ @Test
+ void testRelativeUrlIsAlwaysSafe() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("/applications", headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl("/applications/123/pages/456/edit", headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl("/signup-success?redirectUrl=%2Fapplications", headers));
+ }
+
+ @Test
+ void testNullAndEmptyUrlIsSafe() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertTrue(RedirectHelper.isSafeRedirectUrl(null, headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl("", headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl(" ", headers));
+ }
+
+ @Test
+ void testSameOriginAbsoluteUrlIsSafe() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers));
+ assertTrue(
+ RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications/123/pages/456/edit", headers));
+ }
+
+ @Test
+ void testSameOriginWithPortIsSafe() {
+ HttpHeaders headers = headersWithOrigin("http://localhost:8080");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("http://localhost:8080/applications", headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl("http://localhost:8080/applications/123", headers));
+ }
+
+ @Test
+ void testDifferentHostIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com/phish", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://attacker.org/steal-cookies", headers));
+ }
+
+ @Test
+ void testDifferentPortIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("http://localhost:8080");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://localhost:9090/applications", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://localhost:3000/applications", headers));
+ }
+
+ @Test
+ void testSubdomainMismatchIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.appsmith.com/phish", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://appsmith.com/applications", headers));
+ }
+
+ @Test
+ void testAbsoluteUrlWithNoOriginHeaderIsBlocked() {
+ HttpHeaders headers = new HttpHeaders(); // no Origin header
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com/phish", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers));
+ }
+
+ @Test
+ void testRelativeUrlWithNoOriginHeaderIsSafe() {
+ HttpHeaders headers = new HttpHeaders(); // no Origin header
+ assertTrue(RedirectHelper.isSafeRedirectUrl("/applications", headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl("/applications/123/pages/456/edit", headers));
+ }
+
+ @Test
+ void testMalformedUrlIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com:not-a-port/phish", headers));
+ }
+
+ @Test
+ void testCaseInsensitiveHostComparison() {
+ HttpHeaders headers = headersWithOrigin("https://App.Appsmith.COM");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers));
+ assertTrue(RedirectHelper.isSafeRedirectUrl("https://APP.APPSMITH.COM/applications", headers));
+ }
+
+ @Test
+ void testSchemeDowngradeIsAllowed() {
+ // If origin is https but redirect is http to same host, the host check passes.
+ // The scheme mismatch is not a security concern for open redirect prevention.
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("http://app.appsmith.com/applications", headers));
+ }
+
+ @Test
+ void testExplicitDefaultPortMatchesImplicitPort() {
+ // https://app.com:443 should match https://app.com (port 443 is default for https)
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com:443/applications", headers));
+
+ // http://localhost:80 should match http://localhost (port 80 is default for http)
+ HttpHeaders headers2 = headersWithOrigin("http://localhost");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("http://localhost:80/applications", headers2));
+ }
+
+ // --- sanitizeRedirectUrl tests ---
+
+ @Test
+ void testSanitizePassesSafeUrl() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertEquals(
+ "https://app.appsmith.com/applications/123",
+ RedirectHelper.sanitizeRedirectUrl("https://app.appsmith.com/applications/123", headers));
+ }
+
+ @Test
+ void testSanitizeBlocksExternalUrl() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ String result = RedirectHelper.sanitizeRedirectUrl("https://evil.com/phish", headers);
+ assertEquals("https://app.appsmith.com/applications", result);
+ }
+
+ @Test
+ void testSanitizePassesRelativeUrl() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertEquals("/applications/123", RedirectHelper.sanitizeRedirectUrl("/applications/123", headers));
+ }
+
+ @Test
+ void testSanitizeWithNoOriginFallsBackToDefault() {
+ HttpHeaders headers = new HttpHeaders();
+ String result = RedirectHelper.sanitizeRedirectUrl("https://evil.com/phish", headers);
+ assertEquals("/applications", result);
+ }
+
+ // --- Common bypass attempts ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "https://evil.com@app.appsmith.com/",
+ "https://app.appsmith.com.evil.com/",
+ "https://evil.com/app.appsmith.com",
+ "https://evil.com#@app.appsmith.com",
+ "//evil.com/path",
+ "//evil.com",
+ })
+ void testCommonBypassAttemptsAreBlocked(String maliciousUrl) {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(
+ RedirectHelper.isSafeRedirectUrl(maliciousUrl, headers),
+ "Should block bypass attempt: " + maliciousUrl);
+ }
+
+ // --- Dangerous scheme tests ---
+ // javascript:, data:, and other non-http schemes are blocked outright.
+ // Only /path relative URLs and http(s) absolute URLs are allowed.
+
+ @Test
+ void testJavascriptSchemeIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("javascript:alert(1)", headers));
+ }
+
+ @Test
+ void testDataSchemeIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("data:text/html,", headers));
+ }
+
+ @Test
+ void testBarePathWithoutSlashIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // Bare paths like "applications" (no leading /) are rejected
+ assertFalse(RedirectHelper.isSafeRedirectUrl("applications", headers));
+ }
+
+ @Test
+ void testSanitizeBlocksProtocolRelativeUrl() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ String result = RedirectHelper.sanitizeRedirectUrl("//evil.com/path", headers);
+ assertEquals("https://app.appsmith.com/applications", result);
+ }
+
+ // --- Additional edge-case bypass attempts ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "HTTP://evil.com/phish",
+ "HTTPS://evil.com/phish",
+ "HtTp://evil.com/phish",
+ })
+ void testUppercaseSchemesAreBlocked(String url) {
+ // Our startsWith check is intentionally case-sensitive; non-lowercase schemes are rejected.
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block uppercase scheme: " + url);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "///evil.com/path",
+ "////evil.com/path",
+ })
+ void testTripleAndQuadSlashAreBlocked(String url) {
+ // Multiple leading slashes should not bypass protocol-relative URL detection.
+ // ///evil.com starts with // so it is rejected, and //// likewise.
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block multi-slash: " + url);
+ }
+
+ @Test
+ void testBackslashConfusionIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // Backslash-based bypass attempts — not starting with http:// or https://, so rejected.
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https:\\\\evil.com", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http:\\\\evil.com", headers));
+ }
+
+ @Test
+ void testSchemeDowngradeWithExplicitPortIsBlocked() {
+ // http://host:80 vs https://host — one port is explicit, so normalize per scheme.
+ // normalizePort(http, 80)=80, normalizePort(https, -1)=443 → different → blocked.
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://app.appsmith.com:80/applications", headers));
+ }
+
+ @Test
+ void testSchemeUpgradeIsAllowed() {
+ // http origin, https redirect — same host, both implicit ports → allowed.
+ HttpHeaders headers = headersWithOrigin("http://app.appsmith.com");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/applications", headers));
+ }
+
+ @Test
+ void testIPAddressHostMatchIsSafe() {
+ HttpHeaders headers = headersWithOrigin("http://127.0.0.1:8080");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("http://127.0.0.1:8080/applications", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://127.0.0.2:8080/applications", headers));
+ }
+
+ @Test
+ void testEmptyAuthorityIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // https:///path has empty authority — host will be null, so blocked.
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https:///path", headers));
+ }
+
+ // --- URL encoding bypass attempts ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "%2F%2Fevil.com",
+ "%2f%2fevil.com",
+ "%2F%2Fevil.com/path",
+ })
+ void testUrlEncodedDoubleSlashIsBlocked(String url) {
+ // URL-encoded // should not bypass the protocol-relative check.
+ // Java's URI parser does NOT decode %2F, so these become opaque paths
+ // that don't start with / — rejected by the "bare path" check.
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block encoded //: " + url);
+ }
+
+ @Test
+ void testDoubleEncodedSlashIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // %252F%252F decodes to %2F%2F on first pass — not a valid scheme or relative path
+ assertFalse(RedirectHelper.isSafeRedirectUrl("%252F%252Fevil.com", headers));
+ }
+
+ // --- Control character injection ---
+
+ @ParameterizedTest
+ @CsvSource({
+ "https://evil.com%09/path",
+ "https://evil.com%0a/path",
+ "https://evil.com%0d/path",
+ "https://evil.com%0d%0a/path",
+ "https://evil.com%00/path",
+ })
+ void testControlCharacterInjectionIsBlocked(String url) {
+ // Control characters (tab, LF, CR, CRLF, null) in URLs should not bypass validation.
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl(url, headers), "Should block control char injection: " + url);
+ }
+
+ // --- Whitespace attacks ---
+
+ @Test
+ void testWhitespacePrefixIsHandled() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // Leading whitespace before a malicious URL — StringUtils.hasText passes,
+ // but it won't start with / or http(s):// → rejected.
+ assertFalse(RedirectHelper.isSafeRedirectUrl(" https://evil.com", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("\thttps://evil.com", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("\nhttps://evil.com", headers));
+ }
+
+ // --- IPv6 address handling ---
+
+ @Test
+ void testIPv6LocalhostMatch() {
+ HttpHeaders headers = headersWithOrigin("http://[::1]:8080");
+ assertTrue(RedirectHelper.isSafeRedirectUrl("http://[::1]:8080/applications", headers));
+ // Different IPv6 address should be blocked
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://[::2]:8080/applications", headers));
+ }
+
+ @Test
+ void testIPv6VsIPv4Mismatch() {
+ // IPv6 localhost [::1] should NOT match IPv4 127.0.0.1
+ HttpHeaders headers = headersWithOrigin("http://127.0.0.1:8080");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http://[::1]:8080/applications", headers));
+ }
+
+ // --- Single-slash scheme malformation ---
+
+ @Test
+ void testSingleSlashSchemeIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // https:/evil.com — missing a slash, not a valid http(s):// URL
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https:/evil.com", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("http:/evil.com", headers));
+ }
+
+ // --- Path traversal in URL ---
+
+ @Test
+ void testPathTraversalInAbsoluteUrl() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ // Same host with path traversal — host still matches, so this is safe
+ assertTrue(RedirectHelper.isSafeRedirectUrl("https://app.appsmith.com/../etc/passwd", headers));
+ // Different host with path traversal — blocked
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com/../app.appsmith.com", headers));
+ }
+
+ // --- Fragment-based confusion ---
+
+ @Test
+ void testFragmentWithDotDomainIsBlocked() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com#.app.appsmith.com", headers));
+ assertFalse(RedirectHelper.isSafeRedirectUrl("https://evil.com#app.appsmith.com", headers));
+ }
+
+ // --- sanitizeRedirectUrl with newly vulnerable patterns ---
+
+ @Test
+ void testSanitizeBlocksUrlEncodedBypass() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ String result = RedirectHelper.sanitizeRedirectUrl("%2F%2Fevil.com", headers);
+ assertEquals("https://app.appsmith.com/applications", result);
+ }
+
+ @Test
+ void testSanitizeBlocksWhitespacePrefixedUrl() {
+ HttpHeaders headers = headersWithOrigin("https://app.appsmith.com");
+ String result = RedirectHelper.sanitizeRedirectUrl(" https://evil.com", headers);
+ assertEquals("https://app.appsmith.com/applications", result);
+ }
+}
diff --git a/deploy/docker/base.dockerfile b/deploy/docker/base.dockerfile
index 33a452eb67de..3fd7270cd998 100644
--- a/deploy/docker/base.dockerfile
+++ b/deploy/docker/base.dockerfile
@@ -46,12 +46,8 @@ ENV PATH="/usr/lib/postgresql/14/bin:${PATH}"
# Install Java
RUN set -o xtrace \
&& mkdir -p /opt/java \
- # Assets from https://github.com/adoptium/temurin17-binaries/releases
- # TODO: The release jdk-17.0.9+9.1 doesn't include Linux binaries, so this fails.
- # Temporarily using hardcoded version in URL until we figure out a more elaborate/smarter solution.
- #&& version="$(curl --write-out '%{redirect_url}' 'https://github.com/adoptium/temurin17-binaries/releases/latest' | sed 's,.*jdk-,,')" \
- && version="17.0.9+9" \
- && curl --location "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-$version/OpenJDK17U-jdk_$(uname -m | sed s/x86_64/x64/)_linux_hotspot_$(echo $version | tr + _).tar.gz" \
+ && arch="$(uname -m | sed 's/x86_64/x64/; s/aarch64/aarch64/')" \
+ && curl --location "https://api.adoptium.net/v3/binary/latest/17/ga/linux/${arch}/jdk/hotspot/normal/eclipse" \
| tar -xz -C /opt/java --strip-components 1
# Install NodeJS
diff --git a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs
index 534cdc7ef621..4f5c94134adc 100644
--- a/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs
+++ b/deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs
@@ -84,6 +84,9 @@ parts.push(`
}
log_skip @source-map-files
+ # On-the-fly compression, honoring the client's Accept-Encoding header.
+ encode zstd gzip
+
# The internal request ID header should never be accepted from an incoming request.
request_header -X-Appsmith-Request-Id
diff --git a/deploy/docker/route-tests/common/encoding.hurl b/deploy/docker/route-tests/common/encoding.hurl
new file mode 100644
index 000000000000..65f69043b4f5
--- /dev/null
+++ b/deploy/docker/route-tests/common/encoding.hurl
@@ -0,0 +1,21 @@
+# Verify on-the-fly compression when the client requests it.
+
+# gzip encoding
+GET http://localhost/static/test-encoding.txt
+Accept-Encoding: gzip
+HTTP 200
+[Asserts]
+header "Content-Encoding" == "gzip"
+
+# zstd encoding
+GET http://localhost/static/test-encoding.txt
+Accept-Encoding: zstd
+HTTP 200
+[Asserts]
+header "Content-Encoding" == "zstd"
+
+# No encoding requested, no encoding applied
+GET http://localhost/static/test-encoding.txt
+HTTP 200
+[Asserts]
+header "Content-Encoding" not exists
diff --git a/deploy/docker/route-tests/entrypoint.sh b/deploy/docker/route-tests/entrypoint.sh
index 8e0d98c264fc..2e2a5b45165a 100644
--- a/deploy/docker/route-tests/entrypoint.sh
+++ b/deploy/docker/route-tests/entrypoint.sh
@@ -49,6 +49,9 @@ mkdir -p "$WWW_PATH" /opt/appsmith/editor
echo -n 'index.html body, this will be replaced' > "$WWW_PATH/index.html"
echo '{}' > /opt/appsmith/info.json
echo -n 'actual index.html body' > /opt/appsmith/editor/index.html
+# A file large enough (>256 bytes) for Caddy's encode directive to compress.
+mkdir -p /opt/appsmith/editor/static
+printf 'a%.0s' {1..512} > /opt/appsmith/editor/static/test-encoding.txt
mkcert -install
# Start echo server
diff --git a/deploy/helm/README.md b/deploy/helm/README.md
index d1b6109784ab..2db26c330c69 100644
--- a/deploy/helm/README.md
+++ b/deploy/helm/README.md
@@ -86,6 +86,7 @@ The command uninstalls the release and removes all Kubernetes resources associat
| --------------------------- | --------------------------------------------------- | --------------- |
| `strategyType` | Appsmith deployment strategy type | `RollingUpdate` |
| `schedulerName` | Alternate scheduler | `""` |
+| `annotations` | Annotations to add to the Deployment/StatefulSet resource | `{}` |
| `podAnnotations` | Annotations for Appsmith pods | `{}` |
| `podLabels` | Labels for Appsmith pods | `{}` |
| `podSecurityContext` | Appsmith pods security context | `{}` |
@@ -95,6 +96,8 @@ The command uninstalls the release and removes all Kubernetes resources associat
| `nodeSelector` | Node labels for pod assignment | `{}` |
| `tolerations` | Tolerations for pod assignment | `[]` |
| `affinity` | Affinity fod pod assignment | `{}` |
+| `extraVolumes` | Additional volumes to add to the pod | `[]` |
+| `extraVolumeMounts` | Additional volume mounts to add to the appsmith container | `[]` |
#### Workload kind
diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml
index 48db3ba19e69..4d0306903bc1 100644
--- a/deploy/helm/values.yaml
+++ b/deploy/helm/values.yaml
@@ -167,10 +167,18 @@ securityContext: {}
# runAsUser: 1000
## @param extraVolumes Additional volumes to add to the pod
+## e.g:
+## extraVolumes:
+## - name: tmp-dir
+## emptyDir: {}
##
extraVolumes: []
## @param extraVolumeMounts Additional volume mounts to add to the appsmith container
+## e.g:
+## extraVolumeMounts:
+## - name: tmp-dir
+## mountPath: /tmp
##
extraVolumeMounts: []