diff --git a/app/client/cypress/locators/AdminsSettings.js b/app/client/cypress/locators/AdminsSettings.js index e6978b32c620..d0e3ca3755df 100644 --- a/app/client/cypress/locators/AdminsSettings.js +++ b/app/client/cypress/locators/AdminsSettings.js @@ -80,6 +80,7 @@ export default { smtpAppsmithMailUserNameInput: "[name='APPSMITH_MAIL_USERNAME']", smtpAppsmithMailPasswordInput: "[name='APPSMITH_MAIL_PASSWORD']", smtpAppsmithMailTestButton: "[data-testid='admin-settings-button']", + appsmithBaseUrlInput: "[name='APPSMITH_BASE_URL']", addEmailGhostInput: "[data-testid='admin-settings-tag-input'] .bp3-input-ghost", // Static URL related locators diff --git a/app/client/src/ce/pages/AdminSettings/config/configuration.tsx b/app/client/src/ce/pages/AdminSettings/config/configuration.tsx index 2116b369148d..fdc02f96a449 100644 --- a/app/client/src/ce/pages/AdminSettings/config/configuration.tsx +++ b/app/client/src/ce/pages/AdminSettings/config/configuration.tsx @@ -38,6 +38,26 @@ export const APPSMITH_REDIS_URL: Setting = { "* Appsmith internally uses Redis for session storage. Change this to an external redis for clustering", }; +export const APPSMITH_BASE_URL: Setting = { + id: "APPSMITH_BASE_URL", + category: SettingCategories.CONFIGURATION, + controlType: SettingTypes.TEXTINPUT, + controlSubType: SettingSubtype.TEXT, + label: "Appsmith Base URL", + subText: + "* The base URL where Appsmith is accessible. This is required for password reset and email verification links to work correctly.", + placeholder: "https://appsmith.example.com", + validate: (value: string) => { + if (!value || value.trim() === "") { + return "This field cannot be empty"; + } + + if (!value.startsWith("http://") && !value.startsWith("https://")) { + return "Base URL must start with http:// or https://"; + } + }, +}; + export const APPSMITH_POOL_SIZE_CONFIG: Setting = { id: "connectionMaxPoolSize", category: SettingCategories.CONFIGURATION, @@ -136,6 +156,7 @@ export const config: AdminConfigType = { settings: [ APPSMITH_DB_URL, APPSMITH_REDIS_URL, + APPSMITH_BASE_URL, APPSMITH_POOL_SIZE_CONFIG, APPSMITH_ALLOWED_FRAME_ANCESTORS_SETTING, ], 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..faed60b418e3 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 @@ -95,7 +95,6 @@ public Mono> forgotPasswordRequest( // username scraping, where the response of this API can prove whether an email has an account or not. return service.forgotPasswordTokenGenerate(userPasswordDTO) .defaultIfEmpty(true) - .onErrorReturn(true) .thenReturn(new ResponseDTO<>(HttpStatus.OK, true)); } 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 4c6866267332..e21052bdbae6 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 @@ -44,6 +44,7 @@ import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.net.WWWFormCodec; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.mongodb.core.query.UpdateDefinition; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -108,6 +109,34 @@ public class UserServiceCEImpl extends BaseService private final UserServiceHelper userPoliciesComputeHelper; private final InstanceVariablesHelper instanceVariablesHelper; + @Value("${APPSMITH_BASE_URL:}") + private String appsmithBaseUrl; + + /** + * Validates that the provided base URL matches APPSMITH_BASE_URL if it is configured. + * This prevents account takeover attacks by ensuring password reset and email verification + * links are only sent to the configured base URL when APPSMITH_BASE_URL is set. + * If APPSMITH_BASE_URL is not configured, validation is skipped to maintain backwards compatibility. + * + * @param providedBaseUrl The base URL from the request (typically from Origin header) + * @return Mono that completes successfully if validation passes or is skipped, or errors if validation fails + */ + private Mono validateBaseUrl(String providedBaseUrl) { + // If APPSMITH_BASE_URL is not configured, skip validation for backwards compatibility + if (!StringUtils.hasText(appsmithBaseUrl)) { + return Mono.empty(); + } + + // If APPSMITH_BASE_URL is configured, validate that Origin header matches it + if (!appsmithBaseUrl.equals(providedBaseUrl)) { + return Mono.error(new AppsmithException( + AppsmithError.GENERIC_BAD_REQUEST, + "Origin header does not match APPSMITH_BASE_URL configuration.")); + } + + return Mono.empty(); + } + protected static final WebFilterChain EMPTY_WEB_FILTER_CHAIN = serverWebExchange -> Mono.empty(); private static final String FORGOT_PASSWORD_CLIENT_URL_FORMAT = "%s/user/resetPassword?token=%s"; private static final Pattern ALLOWED_ACCENTED_CHARACTERS_PATTERN = Pattern.compile("^[\\p{L} 0-9 .\'\\-]+$"); @@ -188,7 +217,15 @@ public Mono forgotPasswordTokenGenerate(ResetUserPasswordDTO resetUserP return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.ORIGIN)); } - String email = resetUserPasswordDTO.getEmail(); + // Validate Origin header against APPSMITH_BASE_URL + return validateBaseUrl(resetUserPasswordDTO.getBaseUrl()).then(Mono.defer(() -> { + String email = resetUserPasswordDTO.getEmail(); + return processForgotPasswordTokenGeneration(email, resetUserPasswordDTO); + })); + } + + private Mono processForgotPasswordTokenGeneration( + String email, ResetUserPasswordDTO resetUserPasswordDTO) { // Create a random token to be sent out. final String token = UUID.randomUUID().toString(); @@ -811,7 +848,15 @@ public Mono resendEmailVerification( return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, FieldName.ORIGIN)); } - String email = resendEmailVerificationDTO.getEmail(); + // Validate Origin header against APPSMITH_BASE_URL + return validateBaseUrl(resendEmailVerificationDTO.getBaseUrl()).then(Mono.defer(() -> { + String email = resendEmailVerificationDTO.getEmail(); + return processResendEmailVerification(email, resendEmailVerificationDTO, redirectUrl); + })); + } + + private Mono processResendEmailVerification( + String email, ResendEmailVerificationDTO resendEmailVerificationDTO, String redirectUrl) { // Create a random token to be sent out. final String token = UUID.randomUUID().toString();