Skip to content

Commit

Permalink
Fix/captcha repeater vulnerability (vivo-project#427)
Browse files Browse the repository at this point in the history
* Added captcha generation using nano captcha library. Added initial validation logic.

* Added variable challenge length.

* Added Google reCaptcha. Added simple configuration feature toggle.

* Added java docs.

* Fixed log4J version bug. Added unit tests for captcha functionality.

* Added guava cache for mitigation of DoS by generating challenges.

* Aligned sl4j-api dependency version.

* Added refresh button for default challenge.

* Improved error messages and added localization.

* Update home/src/main/resources/rdf/i18n/de_DE/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: Benjamin Kampe <[email protected]>

* Update home/src/main/resources/rdf/i18n/pt_BR/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: salmjunior <[email protected]>

* Update home/src/main/resources/rdf/i18n/pt_BR/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: salmjunior <[email protected]>

* Added missing licence header. Refactored code to avoid passing vreq where unnecessary.

* Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: Georgy Litvinov <[email protected]>

* Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: Georgy Litvinov <[email protected]>

* Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: Georgy Litvinov <[email protected]>

* Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttime/vitro_UiLabel.ttl

Co-authored-by: Georgy Litvinov <[email protected]>

* Switched to using getInstance() instead of deprecated getBean() method.

* Fixed frontend display error.

* Added captcha feature toggle with difficulty setting.

* Updated captcha configuration.

* Made nanocaptcha challenge little bigger.

* Improved spanish localization.

* Made configuration options as enumertions.

* Made configuration case insensitive, updated configuration docs.

* Improved french localization.

* Refactored code to use provider pattern.

* Updated javadocs.

* Update home/src/main/resources/config/example.runtime.properties

Co-authored-by: Georgy Litvinov <[email protected]>

---------

Co-authored-by: Benjamin Kampe <[email protected]>
Co-authored-by: salmjunior <[email protected]>
Co-authored-by: Georgy Litvinov <[email protected]>
  • Loading branch information
4 people authored Jan 10, 2024
1 parent 440d4e8 commit 2df1164
Show file tree
Hide file tree
Showing 27 changed files with 1,619 additions and 254 deletions.
15 changes: 15 additions & 0 deletions api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@
<artifactId>argon2-jvm</artifactId>
<version>2.11</version>
</dependency>
<dependency>
<groupId>net.logicsquad</groupId>
<artifactId>nanocaptcha</artifactId>
<version>1.5</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.36</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>fluent-hc</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package edu.cornell.mannlib.vitro.webapp.beans;

import java.io.IOException;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
* The AbstractCaptchaProvider is an abstract class providing a base structure for captcha providers.
* It includes methods for generating refresh challenges, adding captcha-related fields to page context,
* and validating user inputs against captcha challenges.
*
* @see CaptchaBundle
*/
public abstract class AbstractCaptchaProvider {

protected static final Log log = LogFactory.getLog(AbstractCaptchaProvider.class.getName());

/**
* Generates a refresh challenge, typically used for updating the captcha displayed on the page.
* Returns empty CaptchaBundle in case of 3rd party implementations
*
* @return CaptchaBundle containing the refreshed captcha challenge.
* @throws IOException If there is an issue generating the refresh challenge.
*/
abstract CaptchaBundle generateRefreshChallenge() throws IOException;

/**
* Adds captcha-related fields to the provided page context, allowing integration with web pages.
*
* @param context The context map representing the page's variables.
* @throws IOException If there is an issue adding captcha-related fields to the page context.
*/
abstract void addCaptchaRelatedFieldsToPageContext(Map<String, Object> context) throws IOException;

/**
* Validates the user input against a captcha challenge identified by the provided challengeId.
*
* @param captchaInput The user's input to be validated.
* @param challengeId The identifier of the captcha challenge (ignored in case of 3rd party implementations).
* @return True if the input is valid, false otherwise.
*/
boolean validateCaptcha(String captchaInput, String challengeId) {
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* $This file is distributed under the terms of the license in LICENSE$ */

package edu.cornell.mannlib.vitro.webapp.beans;

import java.util.Objects;

/**
* Represents a bundle containing a CAPTCHA image in Base64 format, the associated code,
* and a unique challenge identifier.
*
* @author Ivan Mrsulja
* @version 1.0
*/
public class CaptchaBundle {

private final String b64Image;

private final String code;

private final String challengeId;


public CaptchaBundle(String b64Image, String code, String challengeId) {
this.b64Image = b64Image;
this.code = code;
this.challengeId = challengeId;
}

public String getB64Image() {
return b64Image;
}

public String getCode() {
return code;
}

public String getCaptchaId() {
return challengeId;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CaptchaBundle that = (CaptchaBundle) o;
return Objects.equals(code, that.code) && Objects.equals(challengeId, that.challengeId);
}

@Override
public int hashCode() {
return Objects.hash(code, challengeId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package edu.cornell.mannlib.vitro.webapp.beans;

public enum CaptchaDifficulty {
EASY,
HARD
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package edu.cornell.mannlib.vitro.webapp.beans;

public enum CaptchaImplementation {
RECAPTCHAV2,
NANOCAPTCHA,
NONE;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/* $This file is distributed under the terms of the license in LICENSE$ */

package edu.cornell.mannlib.vitro.webapp.beans;

import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;


/**
* This class provides services related to CAPTCHA challenges and validation.
* It includes method delegates for generating challenges, validating challenge responses,
* and managing CAPTCHA challenges for specific hosts.
*
* @author Ivan Mrsulja
* @version 1.0
*/
public class CaptchaServiceBean {

private static final Cache<String, CaptchaBundle> captchaChallenges =
CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();

private static AbstractCaptchaProvider captchaProvider;

static {
CaptchaImplementation captchaImplementation = getCaptchaImpl();
switch (captchaImplementation) {
case RECAPTCHAV2:
captchaProvider = new Recaptchav2Provider();
break;
case NANOCAPTCHA:
captchaProvider = new NanocaptchaProvider();
break;
case NONE:
captchaProvider = new DummyCaptchaProvider();
break;
}
}

/**
* Generates a new CAPTCHA challenge (returns empty CaptchaBundle for 3rd party providers).
*
* @return A CaptchaBundle containing the CAPTCHA image in Base64 format, the content,
* and a unique identifier.
* @throws IOException If an error occurs during image conversion.
*/
public static CaptchaBundle generateRefreshedChallenge() throws IOException {
return captchaProvider.generateRefreshChallenge();
}

/**
* Retrieves a CAPTCHA challenge for a specific host based on the provided CAPTCHA ID
* Removes the challenge from the storage after retrieval.
*
* @param captchaId The CAPTCHA ID to match.
* @return An Optional containing the CaptchaBundle if a matching challenge is found,
* or an empty Optional otherwise.
*/
public static Optional<CaptchaBundle> getChallenge(String captchaId) {
CaptchaBundle challengeForHost = captchaChallenges.getIfPresent(captchaId);
if (challengeForHost == null) {
return Optional.empty();
}

captchaChallenges.invalidate(captchaId);

return Optional.of(challengeForHost);
}

/**
* Gets the map containing CAPTCHA challenges for different hosts.
*
* @return A ConcurrentHashMap with host addresses as keys and CaptchaBundle objects as values.
*/
public static Cache<String, CaptchaBundle> getCaptchaChallenges() {
return captchaChallenges;
}

/**
* Retrieves the configured captcha implementation based on the application's configuration properties.
* If captcha functionality is disabled, returns NONE. If the captcha implementation is not specified,
* defaults to NANOCAPTCHA.
*
* @return The selected captcha implementation (NANOCAPTCHA, RECAPTCHAv2, or NONE).
*/
public static CaptchaImplementation getCaptchaImpl() {
String captchaEnabledSetting = ConfigurationProperties.getInstance().getProperty("captcha.enabled");

if (Objects.nonNull(captchaEnabledSetting) && !Boolean.parseBoolean(captchaEnabledSetting)) {
return CaptchaImplementation.NONE;
}

String captchaImplSetting =
ConfigurationProperties.getInstance().getProperty("captcha.implementation");

if (Strings.isNullOrEmpty(captchaImplSetting) ||
(!captchaImplSetting.equalsIgnoreCase(CaptchaImplementation.RECAPTCHAV2.name()) &&
!captchaImplSetting.equalsIgnoreCase(CaptchaImplementation.NANOCAPTCHA.name()))) {
captchaImplSetting = CaptchaImplementation.NANOCAPTCHA.name();
}

return CaptchaImplementation.valueOf(captchaImplSetting.toUpperCase());
}

/**
* Adds captcha-related fields to the given page context map. The specific fields added depend on the
* configured captcha implementation.
*
* @param context The page context map to which captcha-related fields are added.
* @throws IOException If there is an IO error during captcha challenge generation.
*/
public static void addCaptchaRelatedFieldsToPageContext(Map<String, Object> context) throws IOException {
CaptchaImplementation captchaImpl = getCaptchaImpl();
context.put("captchaToUse", captchaImpl.name());
captchaProvider.addCaptchaRelatedFieldsToPageContext(context);
}

/**
* Validates a user's captcha input.
*
* @param captchaInput The user's input for the captcha challenge.
* @param challengeId The unique identifier for the challenge (if captcha is 3rd party, this param is ignored).
* @return {@code true} if the captcha input is valid, {@code false} otherwise.
*/
public static boolean validateCaptcha(String captchaInput, String challengeId) {
return captchaProvider.validateCaptcha(captchaInput, challengeId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package edu.cornell.mannlib.vitro.webapp.beans;

import java.util.Map;

/**
* DummyCaptchaProvider is a concrete implementation of AbstractCaptchaProvider,
* serving as a fallback when CAPTCHA is disabled. Validation will always pass,
* in order to fulfill validation logic.
*
* @see AbstractCaptchaProvider
* @see CaptchaBundle
*/
public class DummyCaptchaProvider extends AbstractCaptchaProvider {

@Override
CaptchaBundle generateRefreshChallenge() {
return new CaptchaBundle("", "", ""); // No refresh challenges if there is no implementation
}

@Override
void addCaptchaRelatedFieldsToPageContext(Map<String, Object> context) {
// No added fields necessary if there is no implementation
}

@Override
boolean validateCaptcha(String captchaInput, String challengeId) {
return true; // validation always passes
}
}
Loading

0 comments on commit 2df1164

Please sign in to comment.