Skip to content

Commit

Permalink
Merge pull request #34 from privacybydesign/security/ratelimiting
Browse files Browse the repository at this point in the history
Ratelimiting
  • Loading branch information
bobhageman authored Sep 6, 2023
2 parents 1a0ca47 + 9b30f65 commit 958a946
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package foundation.privacybydesign.email;

import foundation.privacybydesign.email.ratelimit.MemoryRateLimit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
* Clean up in-memory data structures once in a while.
*/
@WebListener
public class CleanupBackgroundJob implements ServletContextListener {
private static Logger logger = LoggerFactory.getLogger(CleanupBackgroundJob.class);
private ScheduledExecutorService scheduler;

@Override
public void contextInitialized(ServletContextEvent event) {
logger.info("Setting up background cleanup task");
scheduler = Executors.newSingleThreadScheduledExecutor();

scheduler.scheduleAtFixedRate(new Runnable() {
@Override public void run() {
try {
MemoryRateLimit.getInstance().periodicCleanup();
} catch (Exception e) {
logger.error("Failed to run periodic cleanup:");
e.printStackTrace();
}
}
}, 5, 5, TimeUnit.MINUTES);
}

@Override
public void contextDestroyed(ServletContextEvent event) {
scheduler.shutdownNow();
}
}
28 changes: 27 additions & 1 deletion src/main/java/foundation/privacybydesign/email/EmailRestApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import foundation.privacybydesign.email.ratelimit.MemoryRateLimit;
import foundation.privacybydesign.email.ratelimit.RateLimit;
import jakarta.mail.internet.AddressException;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
Expand All @@ -26,11 +28,13 @@
@Path("")
public class EmailRestApi {
private static Logger logger = LoggerFactory.getLogger(EmailRestApi.class);
private static RateLimit rateLimiter = MemoryRateLimit.getInstance();

private static final String ERR_ADDRESS_MALFORMED = "error:email-address-malformed";
private static final String ERR_INVALID_TOKEN = "error:invalid-token";
private static final String ERR_INVALID_LANG = "error:invalid-language";
private static final String OK_RESPONSE = "OK"; // value doesn't really matter
private static final String ERR_RATE_LIMITED = "error:ratelimit";

private EmailTokens signer;

Expand Down Expand Up @@ -58,8 +62,20 @@ public Response sendEmail(@FormParam("email") String email,
return Response.status(Response.Status.BAD_REQUEST).entity(ERR_ADDRESS_MALFORMED).build();
}

String token = signer.createToken(email);
try {

long retryAfter = rateLimiter.rateLimited(email);
if (retryAfter > 0) {
// 429 Too Many Requests
// https://tools.ietf.org/html/rfc6585#section-4
return Response.status(429)
.entity(ERR_RATE_LIMITED)
.header("Retry-After", (int) Math.ceil(retryAfter / 1000.0))
.build();
}

String token = signer.createToken(email);

String url = conf.getServerURL(lang) + "#verify-email/" + token
+ "/" + URLEncoder.encode(client.getReturnURL(), StandardCharsets.UTF_8.toString());
EmailSender.send(
Expand Down Expand Up @@ -103,6 +119,16 @@ public Response sendEmailToken(@FormParam("email") String emailAddress,
return Response.status(Response.Status.BAD_REQUEST).entity(ERR_ADDRESS_MALFORMED).build();
}

long retryAfter = rateLimiter.rateLimited(emailAddress);
if (retryAfter > 0) {
// 429 Too Many Requests
// https://tools.ietf.org/html/rfc6585#section-4
return Response.status(429)
.entity(ERR_RATE_LIMITED)
.header("Retry-After", (int) Math.ceil(retryAfter / 1000.0))
.build();
}

// Test email with signature
String token = signer.createToken(emailAddress);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package foundation.privacybydesign.email.ratelimit;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Store rate limits in memory. Useful for debugging and rate limits that
* aren't very long.
*
* How it works:
* How much budget a user has, is expressed in a timestamp. The timestamp is
* initially some period in the past, but with every usage (countEmail)
* this timestamp is incremented. For e-mail addresses this
* amount is exponential.
*
* An algorithm with a similar goal is the Token Bucket algorithm. This
* algorithm probably works well, but seemed harder to implement.
* https://en.wikipedia.org/wiki/Token_bucket
*/
public class MemoryRateLimit extends RateLimit {
private static final long SECOND = 1000; // 1000ms = 1s
private static final long MINUTE = SECOND * 60;
private static final long HOUR = MINUTE * 60;
private static final long DAY = HOUR * 24;

private static MemoryRateLimit instance;

private final Map<String, Limit> emailLimits;

public MemoryRateLimit() {
emailLimits = new ConcurrentHashMap<>();
}

public static MemoryRateLimit getInstance() {
if (instance == null) {
instance = new MemoryRateLimit();
}
return instance;
}

// Is the user over the rate limit per e-mail address?
@Override
protected synchronized long nextTryEmail(String email, long now) {
// Rate limiter durations (sort-of logarithmic):
// 1 10 seconds
// 2 1 minute
// 3 5 minutes
// 4 1 hour
// 5 12 hours
// 6+ 2 per day
// Keep log 2 days for proper limiting.
Limit limit = emailLimits.get(email);
if (limit == null) {
limit = new Limit(now);
emailLimits.put(email, limit);
}
long nextTry; // timestamp when the next request is allowed
switch (limit.tries) {
case 0: // try 1: always succeeds
nextTry = limit.timestamp;
break;
case 1: // try 2: allowed after 10 seconds
nextTry = limit.timestamp + 10 * SECOND;
break;
case 2: // try 3: allowed after 1 minute
nextTry = limit.timestamp + MINUTE;
break;
case 3: // try 4: allowed after 5 minutes
nextTry = limit.timestamp + 5 * MINUTE;
break;
case 4: // try 5: allowed after 1 hour
nextTry = limit.timestamp + HOUR;
break;
case 5: // try 6: allowed after 12 hours
nextTry = limit.timestamp + 12 * HOUR;
break;
default:
throw new IllegalStateException("invalid tries count");
}
return nextTry;
}

// Count the usage of this rate limit - adding to the budget for this
// e-mail address.
@Override
protected synchronized void countEmail(String email, long now) {
long nextTry = nextTryEmail(email, now);
Limit limit = emailLimits.get(email);
if (nextTry > now) {
throw new IllegalStateException("counting rate limit while over the limit");
}
limit.tries = Math.min(limit.tries+1, 6); // add 1, max at 6
// If the last usage was e.g. ≥2 days ago, we should allow them 2
// extra tries this day.
long lastTryDaysAgo = (now-limit.timestamp)/DAY;
long bonusTries = limit.tries - lastTryDaysAgo;
if (bonusTries >= 1) {
limit.tries = (int)bonusTries;
}
limit.timestamp = now;
}

public void periodicCleanup() {
long now = System.currentTimeMillis();
// Use enhanced for loop, because an iterator makes sure concurrency issues cannot occur.
for (Map.Entry<String, Limit> entry : emailLimits.entrySet()) {
if (entry.getValue().timestamp < now - 2*DAY) {
emailLimits.remove(entry.getKey());
}
}
}
}

class Limit {
long timestamp;
int tries;

Limit(long now) {
tries = 0;
timestamp = now;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package foundation.privacybydesign.email.ratelimit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Base class for rate limiting. Subclasses provide storage methods (memory
* for easier debugging and database for production).
*/
public abstract class RateLimit {
private static Logger logger = LoggerFactory.getLogger(RateLimit.class);

/** Take an e-mail address and rate limit it.
* @param email e-mail address
* @return the number of milliseconds that the client should wait - 0 if
* it shouldn't wait.
*/
public long rateLimited(String email) {
long now = System.currentTimeMillis();
long retryAfter = nextTryEmail(email, now);

if (retryAfter > now) {
logger.warn("Denying request: email rate limit email exceeded");
// Don't count this request if it has been denied.
return retryAfter - now;
}

countEmail(email, now);
return 0;
}

protected abstract long nextTryEmail(String email, long now);
protected abstract void countEmail(String email, long now);
}
25 changes: 25 additions & 0 deletions src/test/java/foundation/privacybydesign/email/RateLimitTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package foundation.privacybydesign.email;

import org.junit.Test;

import foundation.privacybydesign.email.ratelimit.MemoryRateLimit;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;

/**
* Test whether the rate limiter works as expected.
*/
public class RateLimitTest {

@Test
public void testRateLimit() {

var rateLimit = MemoryRateLimit.getInstance();
var email = "[email protected]";

assertEquals("not rate limited", 0, rateLimit.rateLimited(email));

assertNotEquals("rate limited", 0, rateLimit.rateLimited(email));
}
}
40 changes: 36 additions & 4 deletions webapp/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,46 @@ function addEmail(e) {
$('#email-form input').prop('disabled', false).val('');
})
.fail(function(e) {
// Address format problem?
setStatus('danger', MESSAGES[e.responseText] || MESSAGES['unknown-problem']);
var errormsg = e.responseText;
console.error('failed to submit email address:', errormsg);

if (!errormsg || !MESSAGES[errormsg]) {
errormsg = 'error:internal';
}

if (errormsg == 'error:ratelimit') {
var retryAfter = e.getResponseHeader('Retry-After');

// In JavaScript, we can mostly ignore the fact we're dealing
// with a string here and treat it as an integer...
if (retryAfter < 60) {
var timemsg = MESSAGES['seconds'];
if (retryAfter == 1) {
var timemsg = MESSAGES['second'];
}
} else if (retryAfter < 60*60) {
retryAfter = Math.round(retryAfter / 60);
var timemsg = MESSAGES['minutes'];
if (retryAfter == 1) {
var timemsg = MESSAGES['minute'];
}
} else {
retryAfter = Math.round(retryAfter / 60 / 60);
var timemsg = MESSAGES['hours'];
if (retryAfter == 1) {
var timemsg = MESSAGES['hour'];
}
}
setStatus('danger', MESSAGES[errormsg].replace('%time%',
timemsg.replace('%n%', retryAfter)));
} else {
setStatus('danger', MESSAGES[errormsg]);
}

setWindow('email-add');

// Make editable again
$('#email-form input').prop('disabled', false);

console.error('fail', e.responseText);
});
}

Expand Down
9 changes: 8 additions & 1 deletion webapp/en/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ var MESSAGES = {
'sending-verification-email': 'An e-mail is being sent to:<br><b class="email">%address%</b>',
'sent-verification-email': 'An e-mail has been sent to:<br><b class="email">%address%</b>',
'verifying-email-token': 'E-mail address is being verified ...',
'unknown-problem': 'Unknown problem',
'error:invalid-token': 'Link in the e-mail is out-of-date or invalid',
'email-failed-to-verify': 'Unknown problem during verification of the e-mail address',
'email-add-verified': 'E-mail address verified',
Expand All @@ -17,4 +16,12 @@ var MESSAGES = {
'submit-email-add': 'Add address',
'submit-email-confirm': 'Confirm',
'issuers-overview-page': 'https://privacybydesign.foundation/issuance/',
'error:internal': 'Internal error. Please contact Yivi if this happens more often.',
'error:ratelimit': 'Please try again in %time%.',
'second': '%n% seconds',
'seconds': '%n% seconds',
'minute': '%n% minute',
'minutes': '%n% minutes',
'hour': '%n% hour',
'hours': '%n% hour',
};
9 changes: 8 additions & 1 deletion webapp/nl/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ var MESSAGES = {
'sending-verification-email': 'Een e-mail wordt gestuurd naar:<br><b class="email">%address%</b>',
'sent-verification-email': 'Er is een e-mail gestuurd naar:<br><b class="email">%address%</b>',
'verifying-email-token': 'Het e-mailadres wordt geverifieerd...',
'unknown-problem': 'Onbekend probleem',
'error:invalid-token': 'De link in de e-mail is verouderd of ongeldig',
'email-failed-to-verify': 'Onbekend probleem tijdens het verifiëren van het e-mail adres',
'email-add-verified': 'Het e-mailadres geverifieerd',
Expand All @@ -17,4 +16,12 @@ var MESSAGES = {
'submit-email-add': 'Voeg adres toe',
'submit-email-confirm': 'Bevestig',
'issuers-overview-page': 'https://privacybydesign.foundation/uitgifte/',
'error:internal': 'Interne fout. Neem contact op met Yivi als dit vaker voorkomt.',
'error:ratelimit': 'Probeer het opnieuw over %time%.',
'second': '%n% seconde',
'seconds': '%n% seconden',
'minute': '%n% minuut',
'minutes': '%n% minuten',
'hour': '%n% uur',
'hours': '%n% uur',
};

0 comments on commit 958a946

Please sign in to comment.