diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java index 56065e1d6a8b..82daa773e7ef 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java @@ -2,6 +2,7 @@ import com.appsmith.server.constants.Url; import com.appsmith.server.controllers.ce.UserControllerCE; +import com.appsmith.server.helpers.HostUrlHelper; import com.appsmith.server.services.SessionUserService; import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.UserService; @@ -20,6 +21,7 @@ public class UserController extends UserControllerCE { public UserController( UserService service, SessionUserService sessionUserService, + HostUrlHelper hostUrlHelper, UserWorkspaceService userWorkspaceService, UserSignup userSignup, UserDataService userDataService, @@ -28,6 +30,7 @@ public UserController( super( service, sessionUserService, + hostUrlHelper, userWorkspaceService, userSignup, userDataService, 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 dc4ef7cf5913..204d18b37a8c 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 @@ -10,6 +10,7 @@ import com.appsmith.server.dtos.ResponseDTO; import com.appsmith.server.dtos.UserProfileDTO; import com.appsmith.server.dtos.UserUpdateDTO; +import com.appsmith.server.helpers.HostUrlHelper; import com.appsmith.server.services.SessionUserService; import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.UserService; @@ -47,6 +48,7 @@ public class UserControllerCE { private final UserService service; private final SessionUserService sessionUserService; + private final HostUrlHelper hostUrlHelper; private final UserWorkspaceService userWorkspaceService; private final UserSignup userSignup; private final UserDataService userDataService; @@ -82,15 +84,16 @@ public Mono> leaveWorkspace(@PathVariable String workspaceId) } /** - * This function initiates the process to reset a user's password. We require the Origin header from the request - * in order to construct client facing URLs that will be sent to the user over email. - * @param originHeader The Origin header in the request. This is a mandatory parameter. + * This function initiates the process to reset a user's password. The client-facing base URL is derived from the + * current request context to construct URLs that will be sent to the user over email. */ @JsonView(Views.Public.class) @PostMapping("/forgotPassword") public Mono> forgotPasswordRequest( - @RequestBody ResetUserPasswordDTO userPasswordDTO, @RequestHeader("Origin") String originHeader) { - userPasswordDTO.setBaseUrl(originHeader); + @RequestBody ResetUserPasswordDTO userPasswordDTO, + @RequestHeader("Origin") String originHeader, + ServerWebExchange exchange) { + userPasswordDTO.setBaseUrl(hostUrlHelper.getClientFacingBaseUrl(originHeader, exchange)); // We shouldn't leak information on whether this operation was successful or not to the client. This can enable // username scraping, where the response of this API can prove whether an email has an account or not. return service.forgotPasswordTokenGenerate(userPasswordDTO) @@ -189,8 +192,9 @@ public Mono>> getFeatureFlags() { @PostMapping("/resendEmailVerification") public Mono> resendEmailVerification( @RequestBody ResendEmailVerificationDTO resendEmailVerificationDTO, - @RequestHeader("Origin") String originHeader) { - resendEmailVerificationDTO.setBaseUrl(originHeader); + @RequestHeader("Origin") String originHeader, + ServerWebExchange exchange) { + resendEmailVerificationDTO.setBaseUrl(hostUrlHelper.getClientFacingBaseUrl(originHeader, exchange)); return service.resendEmailVerification(resendEmailVerificationDTO, null) .thenReturn(new ResponseDTO<>(HttpStatus.OK, true)); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/HostUrlHelper.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/HostUrlHelper.java new file mode 100644 index 000000000000..b659572c9b30 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/HostUrlHelper.java @@ -0,0 +1,9 @@ +package com.appsmith.server.helpers; + +import com.appsmith.server.helpers.ce.HostUrlHelperCE; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class HostUrlHelper extends HostUrlHelperCE {} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/HostUrlHelperCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/HostUrlHelperCE.java new file mode 100644 index 000000000000..31fdcdeba512 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/HostUrlHelperCE.java @@ -0,0 +1,187 @@ +package com.appsmith.server.helpers.ce; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +import static com.google.common.net.HttpHeaders.FORWARDED; +import static com.google.common.net.HttpHeaders.X_FORWARDED_HOST; +import static com.google.common.net.HttpHeaders.X_FORWARDED_PROTO; + +@Slf4j +public class HostUrlHelperCE { + + @Value("${appsmith.domain:}") + private String configuredRedirectDomain; + + @Value("${APPSMITH_BASE_URL:}") + private String configuredBaseUrl; + + public static final String APPSMITH_FORWARDED_HOST = "appsmith-forwarded-host"; + public static final String APPSMITH_FORWARDED_PROTO = "appsmith-forwarded-proto"; + + /** + * Resolve the client facing base URL for the current request. + *

+ * The method first prefers the {@code APPSMITH_BASE_URL} environment variable to ensure that an operator supplied + * canonical URL is always respected. When that variable is not configured, it derives the host from the incoming + * request metadata (forwarded headers, host header etc). The {@code Origin} header is deliberately ignored for + * deriving the base URL because it is supplied by the client and can be trivially spoofed. Instead, we only log the + * {@code Origin} value when it disagrees with the derived host to aid troubleshooting. + * + * @param originInHeader origin header value supplied by the client, used solely for logging discrepancies + * @param exchange current request exchange used to derive request based host details + * @return canonical base URL that should be used when communicating with the client. Returns an empty string when + * it cannot be determined. + */ + public String getClientFacingBaseUrl(String originInHeader, ServerWebExchange exchange) { + String derivedHost; + + if (StringUtils.hasText(configuredBaseUrl)) { + derivedHost = sanitizeConfiguredDomain(configuredBaseUrl, exchange); + } else { + derivedHost = getHostUrl(exchange.getRequest()); + } + + if (!StringUtils.hasText(derivedHost)) { + log.warn("Unable to determine client facing host from configured base URL or request headers"); + derivedHost = ""; + } + + // Log mismatch only when both values are present and not equal + if (StringUtils.hasText(originInHeader) + && StringUtils.hasText(derivedHost) + && !originInHeader.equalsIgnoreCase(derivedHost)) { + log.warn( + "Origin header and derived host mismatch; origin in header: {}, derived host: {}", + originInHeader, + derivedHost); + } + + return derivedHost; + } + + public static String getHostUrl(ServerHttpRequest request) { + HttpHeaders headers = request.getHeaders(); + String hostUrl = null; + + String query = request.getURI().getQuery(); + // Check for appsmith-forwarded parameters in query string + // This is done so that we only check for the query parameter when the request is for the OAuth2 login + if (request.getURI().toString().contains("/login/oauth2/code/google") && query != null && !query.isEmpty()) { + String hostFromQuery = getHostUrlFromQuery(query); + if (hostFromQuery != null) { + log.info("Using host URL from query parameters: {}", hostFromQuery); + return hostFromQuery; + } + } + + // Prefer Forwarded header (RFC 7239) + String forwarded = headers.getFirst(FORWARDED); + if (forwarded != null) { + try { + if (forwarded.contains("host=")) { + hostUrl = forwarded.split("host=")[1].split("[;,]")[0].trim(); + log.trace("Using Forwarded header host: {}", hostUrl); + return ensureScheme(hostUrl, request); + } + } catch (Exception e) { + log.debug("Failed to parse Forwarded header: {}", forwarded, e); + } + } + + // Use X-Forwarded-Host if available + String xForwardedHost = headers.getFirst(X_FORWARDED_HOST); + if (xForwardedHost != null) { + String scheme = headers.getFirst(X_FORWARDED_PROTO); + if (scheme == null) { + scheme = request.getURI().getScheme(); + } + hostUrl = scheme + "://" + xForwardedHost; + log.trace("Using X-Forwarded-Host: {}", hostUrl); + return hostUrl; + } + + // Fall back to standard Host header + String host = headers.getFirst(HttpHeaders.HOST); + if (host != null) { + String scheme = request.getURI().getScheme(); + hostUrl = scheme + "://" + host; + log.trace("Using standard Host header: {}", hostUrl); + return hostUrl; + } + + log.debug("No host information found in request headers"); + return null; + } + + private static String ensureScheme(String hostUrl, ServerHttpRequest request) { + if (!hostUrl.contains("://")) { + String scheme = request.getHeaders().getFirst(X_FORWARDED_PROTO); + if (scheme == null) { + scheme = request.getURI().getScheme(); + } + return scheme + "://" + hostUrl; + } + return hostUrl; + } + + public static boolean isSecureScheme(ServerWebExchange exchange) { + String scheme = exchange.getRequest().getHeaders().getFirst(X_FORWARDED_PROTO); + if (scheme == null) { + scheme = exchange.getRequest().getURI().getScheme(); + } + return "https".equals(scheme); + } + + /** + * Extract host URL from query parameters if they contain appsmith-forwarded-host and appsmith-forwarded-proto + * @param query The query string to parse + * @return The host URL constructed from query parameters, or null if parameters not found + */ + private static String getHostUrlFromQuery(String query) { + String host = null; + String proto = null; + + // Parse the query string + String[] params = query.split("&"); + for (String param : params) { + if (param.startsWith(APPSMITH_FORWARDED_HOST + "=")) { + host = param.substring(APPSMITH_FORWARDED_HOST.length() + 1); + } else if (param.startsWith(APPSMITH_FORWARDED_PROTO + "=")) { + proto = param.substring(APPSMITH_FORWARDED_PROTO.length() + 1); + } + + // If we have both parameters, we can return the host URL + if (host != null && proto != null) { + return proto + "://" + host; + } + } + + return null; + } + + private static String sanitizeConfiguredDomain(String domain, ServerWebExchange exchange) { + String sanitizedDomain = domain; + while (sanitizedDomain.endsWith("/")) { + sanitizedDomain = sanitizedDomain.substring(0, sanitizedDomain.length() - 1); + } + + if (sanitizedDomain.startsWith("http://") || sanitizedDomain.startsWith("https://")) { + return sanitizedDomain; + } + + String scheme = exchange.getRequest().getHeaders().getFirst(X_FORWARDED_PROTO); + if (!StringUtils.hasText(scheme)) { + scheme = exchange.getRequest().getURI().getScheme(); + } + if (!StringUtils.hasText(scheme)) { + scheme = "https"; + } + + return scheme + "://" + sanitizedDomain; + } +}