prevent Host header injection attacks

This commit is contained in:
Athou
2026-08-17 22:18:59 +02:00
parent f3c390e0e0
commit 7f77cf0268
5 changed files with 17 additions and 1 deletions

View File

@@ -41,6 +41,11 @@ public class CommaFeedApplication {
ImageProxyUrl.generateKey(); ImageProxyUrl.generateKey();
} }
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
throw new IllegalStateException(
"password recovery is enabled but no public base url is set");
}
feedRefreshEngine.start(); feedRefreshEngine.start();
taskScheduler.start(); taskScheduler.start();
} }

View File

@@ -49,6 +49,13 @@ public interface CommaFeedConfiguration {
@WithDefault("false") @WithDefault("false")
boolean passwordRecoveryEnabled(); boolean passwordRecoveryEnabled();
/**
* The base URL of the application to use in the password recovery link in the email. We can't
* use the URL sent by the browser of the user because of malicious Host header injection
* attacks.
*/
Optional<String> passwordRecoveryPublicBaseUrl();
/** Message displayed in a notification at the bottom of the page. */ /** Message displayed in a notification at the bottom of the page. */
Optional<String> announcement(); Optional<String> announcement();

View File

@@ -413,7 +413,8 @@ public class UserREST {
} }
private String buildEmailContent(User user) throws URISyntaxException { private String buildEmailContent(User user) throws URISyntaxException {
String publicUrl = Urls.removeTrailingSlash(uri.getBaseUri().toString()); String publicUrl =
Urls.removeTrailingSlash(config.passwordRecoveryPublicBaseUrl().orElseThrow());
return String.format( return String.format(
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.", "You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
user.getName(), callbackUrl(user, publicUrl)); user.getName(), callbackUrl(user, publicUrl));

View File

@@ -52,6 +52,7 @@ quarkus.native.additional-build-args=-H:PageSize=65536
%test.commafeed.users.create-demo-account=true %test.commafeed.users.create-demo-account=true
%test.commafeed.users.allow-registrations=true %test.commafeed.users.allow-registrations=true
%test.commafeed.password-recovery-enabled=true %test.commafeed.password-recovery-enabled=true
%test.commafeed.password-recovery-public-base-url=https://commafeed.example.com
%test.commafeed.http-client.cache.enabled=false %test.commafeed.http-client.cache.enabled=false
%test.commafeed.http-client.block-local-addresses=false %test.commafeed.http-client.block-local-addresses=false
%test.commafeed.database.cleanup.entries-max-age=0 %test.commafeed.database.cleanup.entries-max-age=0

View File

@@ -52,6 +52,7 @@ class UserIT extends BaseIT {
RestAssured.given() RestAssured.given()
.body(req) .body(req)
.contentType(ContentType.JSON) .contentType(ContentType.JSON)
.header("Host", "malicious.url.com")
.post("rest/user/passwordReset") .post("rest/user/passwordReset")
.then() .then()
.statusCode(200); .statusCode(200);
@@ -64,6 +65,7 @@ class UserIT extends BaseIT {
Assertions.assertTrue( Assertions.assertTrue(
message.getHtml() message.getHtml()
.startsWith("You asked for password recovery for account 'admin'")); .startsWith("You asked for password recovery for account 'admin'"));
Assertions.assertTrue(message.getHtml().contains("https://commafeed.example.com"));
Assertions.assertEquals("admin@commafeed.com", message.getTo().getFirst()); Assertions.assertEquals("admin@commafeed.com", message.getTo().getFirst());
Element a = Jsoup.parse(message.getHtml()).select("a").getFirst(); Element a = Jsoup.parse(message.getHtml()).select("a").getFirst();