Compare commits

...

3 Commits

Author SHA1 Message Date
CalDescent
88711ae018 Delete messages from cache after 1 hour to reduce memory usage.
This would allow a duplicate message to be send again after an hour, but this is okay as it is only really designed to prevent frequent spamming of the same content.
2021-08-07 15:56:08 +01:00
CalDescent
b0f963cca7 Added duplicate message filter for chat transactions.
This tracks the last 3 unique (unencrypted) messages for each address and fails validation if a duplicate is sent.
2021-08-07 14:59:32 +01:00
CalDescent
2f3e10e15a 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.
2021-08-07 13:56:32 +01:00
5 changed files with 372 additions and 0 deletions

View File

@ -0,0 +1,187 @@
package org.qortal.chat;
import org.qortal.settings.Settings;
import org.qortal.utils.NTP;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class ChatDuplicateMessageFilter extends Thread {
public static class SimpleChatMessage {
private long timestamp;
private String message;
public SimpleChatMessage(long timestamp, String message) {
this.timestamp = timestamp;
this.message = message;
}
public long getTimestamp() {
return this.timestamp;
}
public String getMessage() {
return this.message;
}
@Override
public boolean equals(Object other) {
if (other == this)
return true;
if (!(other instanceof SimpleChatMessage))
return false;
SimpleChatMessage otherMessage = (SimpleChatMessage) other;
return Objects.equals(this.getMessage(), otherMessage.getMessage());
}
}
private static ChatDuplicateMessageFilter instance;
private volatile boolean isStopping = false;
private static final int numberOfUniqueMessagesToMonitor = 3; // Only hold the last 3 messages in memory
private static final long maxMessageAge = 60 * 60 * 1000L; // Forget messages after 1 hour
// Maintain a short list of recent chat messages for each address, to save having to query the database every time
private Map<String, List<SimpleChatMessage>> recentMessages = new ConcurrentHashMap<>();
public ChatDuplicateMessageFilter() {
}
public static synchronized ChatDuplicateMessageFilter getInstance() {
if (instance == null) {
instance = new ChatDuplicateMessageFilter();
instance.start();
}
return instance;
}
@Override
public void run() {
Thread.currentThread().setName("Duplicate Chat Message Filter");
try {
while (!isStopping) {
Thread.sleep(60000);
this.cleanup();
}
} catch (InterruptedException e) {
// Fall-through to exit thread...
}
}
public void shutdown() {
isStopping = true;
this.interrupt();
}
public boolean isDuplicateMessage(String address, long timestamp, String message) {
boolean isDuplicateMessage;
boolean messagesUpdated = false;
SimpleChatMessage thisMessage = new SimpleChatMessage(timestamp, message);
// Add message to array for address
List<SimpleChatMessage> messages = new ArrayList<>();
if (this.recentMessages.containsKey(address)) {
messages = this.recentMessages.get(address);
}
// Check for duplicate, and add if unique
if (!messages.contains(thisMessage)) {
messages.add(thisMessage);
this.recentMessages.put(address, messages);
messagesUpdated = true;
isDuplicateMessage = false;
}
else {
// Can't add message because it already exists
isDuplicateMessage = true;
}
// Ensure we're not tracking more messages than intended
while (messages.size() > numberOfUniqueMessagesToMonitor) {
messages.remove(0);
messagesUpdated = true;
}
// Ensure we're not holding on to messages for longer than a defined time period
Iterator iterator = messages.iterator();
long now = NTP.getTime();
while (iterator.hasNext()) {
SimpleChatMessage simpleChatMessage = (SimpleChatMessage) iterator.next();
if (simpleChatMessage.getTimestamp() < now - maxMessageAge) {
// Older than tracked interval
iterator.remove();
messagesUpdated = true;
}
}
if (messagesUpdated) {
if (messages.size() > 0) {
this.recentMessages.put(address, messages);
}
else {
this.recentMessages.remove(address);
}
}
return isDuplicateMessage;
}
private void cleanup() {
// Cleanup map of addresses and messages
this.deleteOldMessagesForAllAddresses();
}
private void deleteOldMessagesForAddress(String address, long now) {
if (address == null) {
return;
}
if (this.recentMessages.containsKey(address)) {
boolean messagesUpdated = false;
List<SimpleChatMessage> messages = recentMessages.get(address);
// Ensure we're not holding on to messages for longer than a defined time period
Iterator iterator = messages.iterator();
while (iterator.hasNext()) {
SimpleChatMessage simpleChatMessage = (SimpleChatMessage) iterator.next();
if (simpleChatMessage.getTimestamp() < now - maxMessageAge) {
// Older than tracked interval
iterator.remove();
messagesUpdated = true;
}
}
// Update messages for address
if (messagesUpdated) {
if (messages.size() > 0) {
this.recentMessages.put(address, messages);
}
else {
this.recentMessages.remove(address);
}
}
}
}
private void deleteOldMessagesForAllAddresses() {
long now = NTP.getTime();
for (Map.Entry<String, List<SimpleChatMessage>> entry : this.recentMessages.entrySet()) {
this.deleteOldMessagesForAddress(entry.getKey(), now);
}
}
}

View File

@ -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<String, List<Long>> recentMessages = new ConcurrentHashMap<String, List<Long>>();
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<Long> timestamps = new ArrayList<Long>();
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<Long> 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<Long> 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<String, List<Long>> entry : this.recentMessages.entrySet()) {
this.deleteOldTimestampsForAddress(entry.getKey());
}
}
}

View File

@ -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;
}

View File

@ -6,6 +6,8 @@ import java.util.List;
import org.qortal.account.Account;
import org.qortal.account.PublicKeyAccount;
import org.qortal.asset.Asset;
import org.qortal.chat.ChatDuplicateMessageFilter;
import org.qortal.chat.ChatRateLimiter;
import org.qortal.crypto.Crypto;
import org.qortal.crypto.MemoryPoW;
import org.qortal.data.transaction.ChatTransactionData;
@ -18,6 +20,7 @@ import org.qortal.repository.Repository;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.ChatTransactionTransformer;
import org.qortal.transform.transaction.TransactionTransformer;
import org.qortal.utils.Base58;
public class ChatTransaction extends Transaction {
@ -159,6 +162,20 @@ 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;
// Check for duplicate messages (unencrypted text messages only)
if (!chatTransactionData.getIsEncrypted() && chatTransactionData.getIsText()) {
ChatDuplicateMessageFilter duplicateFilter = ChatDuplicateMessageFilter.getInstance();
String message58 = Base58.encode(chatTransactionData.getData());
if (duplicateFilter.isDuplicateMessage(chatTransactionData.getSender(), chatTransactionData.getTimestamp(), message58))
return ValidationResult.DUPLICATE_MESSAGE;
}
return ValidationResult.OK;
}

View File

@ -248,6 +248,8 @@ public abstract class Transaction {
INCORRECT_NONCE(94),
INVALID_TIMESTAMP_SIGNATURE(95),
ADDRESS_IN_BLACKLIST(96),
ADDRESS_ABOVE_RATE_LIMIT(97),
DUPLICATE_MESSAGE(98),
INVALID_BUT_OK(999),
NOT_YET_RELEASED(1000);