Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Fix/captcha repeater vulnerability #427

Merged
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a503bb4
Added captcha generation using nano captcha library. Added initial va…
ivanmrsulja Nov 20, 2023
461213d
Added variable challenge length.
ivanmrsulja Nov 20, 2023
d06b400
Added Google reCaptcha. Added simple configuration feature toggle.
ivanmrsulja Nov 21, 2023
58bdd92
Added java docs.
ivanmrsulja Nov 22, 2023
ca18759
Fixed log4J version bug. Added unit tests for captcha functionality.
ivanmrsulja Nov 22, 2023
fe87586
Added guava cache for mitigation of DoS by generating challenges.
ivanmrsulja Nov 22, 2023
262b99a
Aligned sl4j-api dependency version.
ivanmrsulja Nov 23, 2023
83c553b
Added refresh button for default challenge.
ivanmrsulja Nov 28, 2023
9156eba
Improved error messages and added localization.
ivanmrsulja Nov 29, 2023
0655072
Update home/src/main/resources/rdf/i18n/de_DE/interface-i18n/firsttim…
ivanmrsulja Dec 5, 2023
c5d6573
Update home/src/main/resources/rdf/i18n/pt_BR/interface-i18n/firsttim…
ivanmrsulja Dec 5, 2023
d616c95
Update home/src/main/resources/rdf/i18n/pt_BR/interface-i18n/firsttim…
ivanmrsulja Dec 5, 2023
3f86241
Added missing licence header. Refactored code to avoid passing vreq w…
ivanmrsulja Dec 11, 2023
b9d7620
Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttim…
ivanmrsulja Dec 11, 2023
3060975
Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttim…
ivanmrsulja Dec 11, 2023
bdc9820
Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttim…
ivanmrsulja Dec 11, 2023
187dc85
Update home/src/main/resources/rdf/i18n/ru_RU/interface-i18n/firsttim…
ivanmrsulja Dec 11, 2023
c195f60
Switched to using getInstance() instead of deprecated getBean() method.
ivanmrsulja Dec 11, 2023
4233deb
Fixed frontend display error.
ivanmrsulja Dec 12, 2023
34842c7
Added captcha feature toggle with difficulty setting.
ivanmrsulja Dec 15, 2023
358e8ae
Updated captcha configuration.
ivanmrsulja Dec 15, 2023
2e7b041
Made nanocaptcha challenge little bigger.
ivanmrsulja Dec 15, 2023
dee609f
Improved spanish localization.
ivanmrsulja Dec 15, 2023
71dbdfd
Made configuration options as enumertions.
ivanmrsulja Dec 18, 2023
0aa5ff7
Made configuration case insensitive, updated configuration docs.
ivanmrsulja Dec 19, 2023
af1ddae
Improved french localization.
ivanmrsulja Dec 19, 2023
aef5f86
Refactored code to use provider pattern.
ivanmrsulja Dec 22, 2023
74c20c1
Updated javadocs.
ivanmrsulja Dec 22, 2023
fc92fc3
Update home/src/main/resources/config/example.runtime.properties
ivanmrsulja Jan 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,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,159 @@
/* $This file is distributed under the terms of the license in LICENSE$ */

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

import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

import javax.imageio.ImageIO;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import net.logicsquad.nanocaptcha.image.ImageCaptcha;
import net.logicsquad.nanocaptcha.image.backgrounds.GradiatedBackgroundProducer;
import net.logicsquad.nanocaptcha.image.filter.FishEyeImageFilter;
import net.logicsquad.nanocaptcha.image.filter.StretchImageFilter;
import net.logicsquad.nanocaptcha.image.noise.CurvedLineNoiseProducer;
import net.logicsquad.nanocaptcha.image.noise.StraightLineNoiseProducer;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;


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

private static final SecureRandom random = new SecureRandom();

private static final Log log = LogFactory.getLog(CaptchaServiceBean.class.getName());

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


/**
* Validates a reCAPTCHA response using Google's reCAPTCHA API.
*
* @param recaptchaResponse The reCAPTCHA response to validate.
* @param secretKey The secret key used for Google ReCaptcha validation.
* @return True if the reCAPTCHA response is valid, false otherwise.
*/
public static boolean validateReCaptcha(String recaptchaResponse, String secretKey) {
String verificationUrl =
"https://www.google.com/recaptcha/api/siteverify?secret=" + secretKey + "&response=" + recaptchaResponse;

try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpGet verificationRequest = new HttpGet(verificationUrl);
HttpResponse verificationResponse = httpClient.execute(verificationRequest);

String responseBody = EntityUtils.toString(verificationResponse.getEntity());
ObjectMapper objectMapper = new ObjectMapper();
ReCaptchaResponse response = objectMapper.readValue(responseBody, ReCaptchaResponse.class);

return response.isSuccess();
} catch (IOException e) {
log.warn("ReCaptcha validation failed.");
}

return false;
}

/**
* Generates a new CAPTCHA challenge using the nanocaptcha library.
*
* @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 generateChallenge() throws IOException {
ImageCaptcha imageCaptcha =
new ImageCaptcha.Builder(200, 75)
.addContent(random.nextInt(2) + 5)
.addBackground(new GradiatedBackgroundProducer())
.addNoise(new CurvedLineNoiseProducer(getRandomColor(), 2f))
.addNoise(new StraightLineNoiseProducer(getRandomColor(), 2))
.addFilter(new StretchImageFilter())
.addFilter(new FishEyeImageFilter())
.addBorder()
.build();
return new CaptchaBundle(convertToBase64(imageCaptcha.getImage()), imageCaptcha.getContent(),
UUID.randomUUID().toString());
}

/**
* 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;
}

/**
* Converts a BufferedImage object to Base64 format.
*
* @param image The BufferedImage to convert.
* @return The Base64-encoded string representation of the image.
* @throws IOException If an error occurs during image conversion.
*/
private static String convertToBase64(BufferedImage image) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
byte[] imageBytes = baos.toByteArray();

return Base64.getEncoder().encodeToString(imageBytes);
}

/**
* Generates a random Color object.
*
* @return A randomly generated Color object.
*/
private static Color getRandomColor() {
int r = random.nextInt(256);
int g = random.nextInt(256);
int b = random.nextInt(256);
return new Color(r, g, b);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* $This file is distributed under the terms of the license in LICENSE$ */

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

import java.util.Date;

/**
* Represents the response from Google's reCAPTCHA API.
* It includes information about the success of the reCAPTCHA verification,
* the timestamp of the challenge, and the hostname associated with the verification.
*
* @author Ivan Mrsulja
* @version 1.0
*/
public class ReCaptchaResponse {

private boolean success;

private Date challenge_ts;

private String hostname;

public ReCaptchaResponse() {
}

public ReCaptchaResponse(boolean success, Date challenge_ts, String hostname) {
this.success = success;
this.challenge_ts = challenge_ts;
this.hostname = hostname;
}

public boolean isSuccess() {
return success;
}

public void setSuccess(boolean success) {
this.success = success;
}

public Date getChallenge_ts() {
return challenge_ts;
}

public void setChallenge_ts(Date challenge_ts) {
this.challenge_ts = challenge_ts;
}

public String getHostname() {
return hostname;
}

public void setHostname(String hostname) {
this.hostname = hostname;
}

@Override
public String toString() {
return "ReCaptchaResponse{" +
"success=" + success +
", challenge_ts=" + challenge_ts +
", hostname='" + hostname + '\'' +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* $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.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.twelvemonkeys.servlet.HttpServlet;

@WebServlet(name = "refreshCaptcha", urlPatterns = {"/refreshCaptcha"}, loadOnStartup = 5)
public class RefreshCaptchaController extends HttpServlet {

protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String oldChallengeId = request.getParameter("oldChallengeId");

response.setContentType("application/json");
PrintWriter out = response.getWriter();

CaptchaBundle newChallenge = CaptchaServiceBean.generateChallenge();
CaptchaServiceBean.getCaptchaChallenges().invalidate(oldChallengeId);
CaptchaServiceBean.getCaptchaChallenges().put(newChallenge.getCaptchaId(), newChallenge);

out.println("{\"challenge\": \"" + newChallenge.getB64Image() + "\", \"challengeId\": \"" +
newChallenge.getCaptchaId() + "\"}");
}

}
Loading