From 2f3e10e15a3f952199543f8da9c33d2262ba1f86 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 7 Aug 2021 13:56:32 +0100 Subject: [PATCH] Added chat rate limiter This can be controlled by two settings: "chatRateLimitSeconds" - the monitored time range, in seconds. Default: 5 minutes. "chatRateLimitCount" - the maximum number of messages per address within the above time range. Default: 25. Exact defaults still TBC. Also, we may decide this is a bad idea altogether, so I've put it in its own branch. --- .../java/org/qortal/chat/ChatRateLimiter.java | 152 ++++++++++++++++++ .../java/org/qortal/settings/Settings.java | 14 ++ .../qortal/transaction/ChatTransaction.java | 7 + .../org/qortal/transaction/Transaction.java | 1 + 4 files changed, 174 insertions(+) create mode 100644 src/main/java/org/qortal/chat/ChatRateLimiter.java diff --git a/src/main/java/org/qortal/chat/ChatRateLimiter.java b/src/main/java/org/qortal/chat/ChatRateLimiter.java new file mode 100644 index 00000000..1cece87d --- /dev/null +++ b/src/main/java/org/qortal/chat/ChatRateLimiter.java @@ -0,0 +1,152 @@ +package org.qortal.chat; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class ChatRateLimiter extends Thread { + + private static ChatRateLimiter instance; + private volatile boolean isStopping = false; + + // Maintain a list of recent chat timestamps for each address, to save having to query the database every time + private Map> recentMessages = new ConcurrentHashMap>(); + + public ChatRateLimiter() { + + } + + public static synchronized ChatRateLimiter getInstance() { + if (instance == null) { + instance = new ChatRateLimiter(); + instance.start(); + } + + return instance; + } + + @Override + public void run() { + Thread.currentThread().setName("Chat Rate Limiter"); + + try { + while (!isStopping) { + Thread.sleep(60000); + + this.cleanup(); + } + } catch (InterruptedException e) { + // Fall-through to exit thread... + } + } + + public void shutdown() { + isStopping = true; + this.interrupt(); + } + + + public void addMessage(String address, long timestamp) { + // Add timestamp to array for address + List timestamps = new ArrayList(); + if (this.recentMessages.containsKey(address)) { + timestamps = this.recentMessages.get(address); + } + if (!timestamps.contains(timestamp)) { + timestamps.add(timestamp); + } + this.recentMessages.put(address, timestamps); + } + + public boolean isAddressAboveRateLimit(String address) { + int chatRateLimitCount = Settings.getInstance().getChatRateLimitCount(); + long chatRateLimitMilliseconds = Settings.getInstance().getChatRateLimitSeconds() * 1000L; + long now = NTP.getTime(); + + if (this.recentMessages.containsKey(address)) { + int messageCount = 0; + boolean timestampsUpdated = false; + + List timestamps = this.recentMessages.get(address); + Iterator iterator = timestamps.iterator(); + while (iterator.hasNext()) { + Long timestamp = (Long) iterator.next(); + if (timestamp >= now - chatRateLimitMilliseconds) { + // Message within tracked range + messageCount++; + } + else { + // Older than tracked range - delete to reduce memory consumption + iterator.remove(); + timestampsUpdated = true; + } + } + // Update timestamps for address + if (timestampsUpdated) { + if (timestamps.size() > 0) { + this.recentMessages.put(address, timestamps); + } + else { + this.recentMessages.remove(address); + } + } + + if (messageCount >= chatRateLimitCount) { + // Rate limit has been hit + return true; + } + } + + return false; + } + + + private void cleanup() { + + // Cleanup map of addresses and timestamps + this.deleteOldTimestampsForAllAddresses(); + } + + private void deleteOldTimestampsForAddress(String address) { + if (address == null) { + return; + } + + long chatRateLimitMilliseconds = Settings.getInstance().getChatRateLimitSeconds() * 1000L; + long now = NTP.getTime(); + + if (this.recentMessages.containsKey(address)) { + boolean timestampsUpdated = false; + + List timestamps = recentMessages.get(address); + Iterator iterator = timestamps.iterator(); + while (iterator.hasNext()) { + Long timestamp = (Long) iterator.next(); + if (timestamp < now - chatRateLimitMilliseconds) { + // Older than tracked interval + iterator.remove(); + timestampsUpdated = true; + } + } + // Update timestamps for address + if (timestampsUpdated) { + if (timestamps.size() > 0) { + this.recentMessages.put(address, timestamps); + } else { + this.recentMessages.remove(address); + } + } + } + } + + private void deleteOldTimestampsForAllAddresses() { + for (Map.Entry> entry : this.recentMessages.entrySet()) { + this.deleteOldTimestampsForAddress(entry.getKey()); + } + } + +} diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index b8884c6c..ae0bb176 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -164,6 +164,12 @@ public class Settings { // Lists private String listsPath = "lists"; + // Chat rate limit + /** Limit to 20 messages per address... */ + private int chatRateLimitCount = 25; + /** ...per 5 minutes of time that passes */ + private int chatRateLimitSeconds = 5 * 60; + /** Array of NTP server hostnames. */ private String[] ntpServers = new String[] { "pool.ntp.org", @@ -481,6 +487,14 @@ public class Settings { return this.listsPath; } + public int getChatRateLimitCount() { + return this.chatRateLimitCount; + } + + public int getChatRateLimitSeconds() { + return this.chatRateLimitSeconds; + } + public String[] getNtpServers() { return this.ntpServers; } diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index a670ea4b..b30be577 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -6,6 +6,7 @@ import java.util.List; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; +import org.qortal.chat.ChatRateLimiter; import org.qortal.crypto.Crypto; import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.ChatTransactionData; @@ -159,6 +160,12 @@ public class ChatTransaction extends Transaction { if (chatTransactionData.getData().length < 1 || chatTransactionData.getData().length > MAX_DATA_SIZE) return ValidationResult.INVALID_DATA_LENGTH; + // Check rate limit + ChatRateLimiter rateLimiter = ChatRateLimiter.getInstance(); + rateLimiter.addMessage(chatTransactionData.getSender(), chatTransactionData.getTimestamp()); + if (rateLimiter.isAddressAboveRateLimit(chatTransactionData.getSender())) + return ValidationResult.ADDRESS_ABOVE_RATE_LIMIT; + return ValidationResult.OK; } diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 3c761d28..9217e6f3 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -248,6 +248,7 @@ public abstract class Transaction { INCORRECT_NONCE(94), INVALID_TIMESTAMP_SIGNATURE(95), ADDRESS_IN_BLACKLIST(96), + ADDRESS_ABOVE_RATE_LIMIT(97), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000);