diff --git a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java
index 96b40c78..4a82193a 100644
--- a/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java
+++ b/commafeed-server/src/main/java/com/commafeed/frontend/resource/UserREST.java
@@ -35,6 +35,7 @@ import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
+import com.google.common.html.HtmlEscapers;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
@@ -412,7 +413,7 @@ public class UserREST {
Urls.removeTrailingSlash(config.passwordRecoveryPublicBaseUrl().orElseThrow());
return String.format(
"You asked for password recovery for account '%s', follow this link to change your password. Ignore this if you didn't request a password recovery.",
- user.getName(), callbackUrl(user, publicUrl));
+ HtmlEscapers.htmlEscaper().escape(user.getName()), callbackUrl(user, publicUrl));
}
private String callbackUrl(User user, String publicUrl) throws URISyntaxException {
diff --git a/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java b/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java
index 219c84c5..8b4eb280 100644
--- a/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java
+++ b/commafeed-server/src/test/java/com/commafeed/integration/rest/UserIT.java
@@ -4,6 +4,7 @@ import com.commafeed.TestConstants;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.request.PasswordResetConfirmationRequest;
import com.commafeed.frontend.model.request.PasswordResetRequest;
+import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.integration.BaseIT;
import io.quarkus.mailer.MockMailbox;
@@ -17,6 +18,7 @@ import jakarta.ws.rs.core.HttpHeaders;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -119,6 +121,45 @@ class UserIT extends BaseIT {
RestAssured.given().get("rest/user/settings").then().statusCode(200);
}
+ @Test
+ void resetPasswordEscapesUsernameInEmail() {
+ String name = "CLICK";
+ String email = "victim@commafeed.com";
+
+ RegistrationRequest registrationReq = new RegistrationRequest();
+ registrationReq.setName(name);
+ registrationReq.setPassword("MyPassword123!");
+ registrationReq.setEmail(email);
+ RestAssured.given()
+ .body(registrationReq)
+ .contentType(ContentType.JSON)
+ .post("rest/user/register")
+ .then()
+ .statusCode(200);
+
+ PasswordResetRequest req = new PasswordResetRequest();
+ req.setEmail(email);
+ RestAssured.given()
+ .body(req)
+ .contentType(ContentType.JSON)
+ .post("rest/user/passwordReset")
+ .then()
+ .statusCode(200);
+
+ List mails = mailbox.getMailMessagesSentTo(email);
+ Assertions.assertEquals(1, mails.size());
+
+ String html = mails.getFirst().getHtml();
+ Assertions.assertTrue(
+ html.startsWith(
+ "You asked for password recovery for account '<a href=//evil.example>CLICK</a>'"));
+
+ Elements links = Jsoup.parse(html).select("a");
+ Assertions.assertEquals(1, links.size());
+ Assertions.assertTrue(
+ links.getFirst().attr("href").startsWith("https://commafeed.example.com"));
+ }
+
@Test
void saveSettings() {
Settings settings =