diff --git a/WebContent/js/cah.constants.js b/WebContent/js/cah.constants.js index f8732f2..6dfaa90 100644 --- a/WebContent/js/cah.constants.js +++ b/WebContent/js/cah.constants.js @@ -136,6 +136,7 @@ cah.$.ErrorCode.TOO_MANY_GAMES = "tmg"; cah.$.ErrorCode.INVALID_ID_CODE = "iid"; cah.$.ErrorCode.CANNOT_JOIN_ANOTHER_GAME = "cjag"; cah.$.ErrorCode.NO_MSG_SPECIFIED = "nms"; +cah.$.ErrorCode.CAPSLOCK = "CL"; cah.$.ErrorCode.ALREADY_STARTED = "as"; cah.$.ErrorCode.NOT_ADMIN = "na"; cah.$.ErrorCode.INVALID_GAME = "ig"; @@ -199,6 +200,7 @@ cah.$.ErrorCode_msg['B&'] = "Banned."; cah.$.ErrorCode_msg['mtl'] = "Messages cannot be longer than 200 characters."; cah.$.ErrorCode_msg['in'] = "Nickname must contain only upper and lower case letters, numbers, or underscores, must be 3 to 30 characters long, and must not start with a number."; cah.$.ErrorCode_msg['serr'] = "An error occured on the server."; +cah.$.ErrorCode_msg['CL'] = "Try turning caps lock off."; cah.$.ErrorCode_msg['dnhc'] = "You don't have that card."; cah.$.ErrorCode_msg['as'] = "The game has already started."; cah.$.ErrorCode_msg['nns'] = "No nickname specified."; diff --git a/build.properties.example b/build.properties.example index bc10576..31ef294 100644 --- a/build.properties.example +++ b/build.properties.example @@ -11,6 +11,9 @@ pyx.id_code_salt= # comma-separated listed of IP addresses (v4 or v6) from which users are considered admins. pyx.admin_addrs=127.0.0.1,::1 +# comma-separated list of strings that will cause a chat message to get silently dropped without +# notifying the user. does not currently permanently shadowban the user, but maybe it should... +pyx.shadowban_strings= # Settings for global chat protection. Some of these do not apply to game chats. # Ratio of 'basic' characters to length of message. Basic characters are defined by # Character.isJavaIdentifierPart, which stipulates: @@ -25,6 +28,9 @@ pyx.admin_addrs=127.0.0.1,::1 pyx.global.basic_ratio=.5 # A message must have at least this many characters for that ratio to apply. pyx.global.basic_min_len=10 +# Message longer than min_len characters cannot have more than ratio of CAPS CHARACTERS +pyx.global.capslock_min_len=50 +pyx.global.capslock_ratio=.5 # messages longer than min_len characters require at least min_count spaces between words pyx.global.spaces_min_len=50 pyx.global.spaces_min_count=4 diff --git a/src/main/filtered-resources/WEB-INF/pyx.properties b/src/main/filtered-resources/WEB-INF/pyx.properties index 8a40209..7192eb9 100644 --- a/src/main/filtered-resources/WEB-INF/pyx.properties +++ b/src/main/filtered-resources/WEB-INF/pyx.properties @@ -6,12 +6,15 @@ pyx.server.broadcast_connects_and_disconnects=${pyx.broadcast_connects_and_disco pyx.server.global_chat_enabled=${pyx.global_chat_enabled} pyx.server.id_code_salt=${pyx.id_code_salt} pyx.server.admin_addrs=${pyx.admin_addrs} +pyx.chat.shadowban_strings=${pyx.shadowban_strings} pyx.chat.global.flood_count=${pyx.global.flood_count} pyx.chat.global.flood_time=${pyx.global.flood_time} pyx.chat.global.basic_ratio=${pyx.global.basic_ratio} pyx.chat.global.basic_min_len=${pyx.global.basic_min_len} pyx.chat.global.spaces_min_len=${pyx.global.spaces_min_len} pyx.chat.global.spaces_min_count=${pyx.global.spaces_min_count} +pyx.chat.global.capslock_min_len=${pyx.global.capslock_min_len} +pyx.chat.global.capslock_ratio=${pyx.global.capslock_ratio} pyx.chat.game.flood_count=${pyx.game.flood_count} pyx.chat.game.flood_time=${pyx.game.flood_time} pyx.build=${buildNumber} diff --git a/src/main/java/net/socialgamer/cah/Constants.java b/src/main/java/net/socialgamer/cah/Constants.java index 38ff74c..3cd41f7 100644 --- a/src/main/java/net/socialgamer/cah/Constants.java +++ b/src/main/java/net/socialgamer/cah/Constants.java @@ -351,6 +351,7 @@ public class Constants { @DuplicationAllowed BANNED(DisconnectReason.BANNED, "Banned."), CANNOT_JOIN_ANOTHER_GAME("cjag", "You cannot join another game."), + CAPSLOCK("CL", "Try turning caps lock off."), CARDCAST_CANNOT_FIND("ccf", "Cannot find Cardcast deck with given ID. If you just added this" + " deck to Cardcast, wait a few minutes and try again."), CARDCAST_INVALID_ID("cii", "Invalid Cardcast ID. Must be exactly 5 characters."), diff --git a/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java b/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java index 7d4f164..f0ad7b1 100644 --- a/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java @@ -93,13 +93,18 @@ public class ChatHandler extends Handler { final ChatFilter.Result filterResult = chatFilter.filterGlobal(user, message); switch (filterResult) { - case OK: - // nothing to do - break; + case CAPSLOCK: + return error(ErrorCode.CAPSLOCK); + case DROP_MESSAGE: + // Don't tell the user we dropped it, and don't send it to everyone else... + return data; case NO_MESSAGE: return error(ErrorCode.NO_MSG_SPECIFIED); case NOT_ENOUGH_SPACES: return error(ErrorCode.NOT_ENOUGH_SPACES); + case OK: + // nothing to do + break; case REPEAT: return error(ErrorCode.REPEAT_MESSAGE); case TOO_FAST: diff --git a/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java b/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java index 3872618..b07f81a 100644 --- a/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java @@ -78,13 +78,18 @@ public class GameChatHandler extends GameWithPlayerHandler { final ChatFilter.Result filterResult = chatFilter.filterGame(user, message); switch (filterResult) { - case OK: - // nothing to do - break; + case CAPSLOCK: + return error(ErrorCode.CAPSLOCK); + case DROP_MESSAGE: + // Don't tell the user we dropped it, and don't send it to everyone else... + return data; case NO_MESSAGE: return error(ErrorCode.NO_MSG_SPECIFIED); case NOT_ENOUGH_SPACES: return error(ErrorCode.NOT_ENOUGH_SPACES); + case OK: + // nothing to do + break; case REPEAT: return error(ErrorCode.REPEAT_MESSAGE); case TOO_FAST: diff --git a/src/main/java/net/socialgamer/cah/util/ChatFilter.java b/src/main/java/net/socialgamer/cah/util/ChatFilter.java index 5b7edd7..7a23189 100644 --- a/src/main/java/net/socialgamer/cah/util/ChatFilter.java +++ b/src/main/java/net/socialgamer/cah/util/ChatFilter.java @@ -28,6 +28,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.TreeMap; import java.util.WeakHashMap; import java.util.concurrent.TimeUnit; @@ -35,6 +36,7 @@ import java.util.regex.Pattern; import org.apache.log4j.Logger; +import com.google.common.collect.ImmutableSet; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.Singleton; @@ -56,6 +58,9 @@ public class ChatFilter { private static final double DEFAULT_BASIC_CHARACTER_RATIO = .5; private static final int DEFAULT_SPACES_MIN_MSG_LENGTH = 75; private static final int DEFAULT_SPACES_REQUIRED = 3; + private static final int DEFAULT_CAPSLOCK_MIN_MSG_LENGTH = 50; + private static final double DEFAULT_CAPSLOCK_RATIO = .5; + private static final String DEFAULT_SHADOWBAN_CHARACTERS = ""; public static final Pattern SIMPLE_MESSAGE_PATTERN = Pattern .compile("^[a-zA-Z0-9 _\\-=+*()\\[\\]\\\\/|,.!:'\"`~#]+$"); @@ -64,7 +69,7 @@ public class ChatFilter { private final Map filterData = Collections.synchronizedMap(new WeakHashMap<>()); public enum Result { - OK, NO_MESSAGE, NOT_ENOUGH_SPACES, REPEAT, TOO_FAST, TOO_LONG, TOO_MANY_SPECIALS + CAPSLOCK, DROP_MESSAGE, NO_MESSAGE, NOT_ENOUGH_SPACES, OK, REPEAT, TOO_FAST, TOO_LONG, TOO_MANY_SPECIALS } private enum Scope { @@ -89,7 +94,8 @@ public class ChatFilter { // do some more in-depth analysis. we don't want too many emoji or non-latin characters final long basic = message.codePoints().filter(c -> Character.isJavaIdentifierPart(c)) .count(); - if (((double) basic) / total < getBasicCharacterRatio(Scope.global)) { + if (((double) basic) / total < getDoubleParameter(Scope.global, "basic_ratio", + DEFAULT_BASIC_CHARACTER_RATIO)) { return Result.TOO_MANY_SPECIALS; } } @@ -100,6 +106,13 @@ public class ChatFilter { return Result.NOT_ENOUGH_SPACES; } + final long caps = message.codePoints().filter(c -> Character.isUpperCase(c)).count(); + if (total >= getIntParameter(Scope.global, "capslock_min_len", DEFAULT_CAPSLOCK_MIN_MSG_LENGTH) + && ((double) caps) / total > getDoubleParameter(Scope.global, "capslock_ratio", + DEFAULT_CAPSLOCK_RATIO)) { + return Result.CAPSLOCK; + } + getMessageTimes(user, Scope.global).add(System.currentTimeMillis()); return Result.OK; } @@ -110,8 +123,6 @@ public class ChatFilter { return result; } - // TODO? - getMessageTimes(user, Scope.game).add(System.currentTimeMillis()); return Result.OK; } @@ -141,12 +152,22 @@ public class ChatFilter { } } + // TODO keep track of how much someone does this and perma-shadowban them... + for (final String banned : getShadowbanCharacters()) { + if (message.contains(banned)) { + LOG.info(String.format( + "Dropping message '%s' from user %s (%s); contains banned string %s.", message, + user.getNickname(), user.getHostname(), banned)); + return Result.DROP_MESSAGE; + } + } + return Result.OK; } private int getIntParameter(final Scope scope, final String name, final int defaultValue) { try { - return Integer.parseInt(propsProvider.get().getProperty( + return Integer.parseInt(getPropValue( String.format("pyx.chat.%s.%s", scope, name), String.valueOf(defaultValue))); } catch (final NumberFormatException e) { LOG.warn(String.format("Unable to parse pyx.chat.%s.%s as a number," @@ -155,6 +176,27 @@ public class ChatFilter { } } + private double getDoubleParameter(final Scope scope, final String name, + final double defaultValue) { + try { + return Double.parseDouble( + getPropValue(String.format("pyx.chat.%s.%s", scope, name), String.valueOf(defaultValue))); + } catch (final NumberFormatException e) { + LOG.warn(String.format("Unable to parse pyx.chat.%s.%s as a number," + + " using default of %d", scope, name, defaultValue), e); + return defaultValue; + } + } + + private Set getShadowbanCharacters() { + return ImmutableSet.copyOf(getPropValue("pyx.chat.shadowban_strings", + DEFAULT_SHADOWBAN_CHARACTERS).split(",")); + } + + private String getPropValue(final String name, final String defaultValue) { + return propsProvider.get().getProperty(name, defaultValue); + } + private int getFloodCount(final Scope scope) { return getIntParameter(scope, "flood_count", DEFAULT_CHAT_FLOOD_MESSAGE_COUNT); } @@ -164,18 +206,6 @@ public class ChatFilter { .toMillis(getIntParameter(scope, "flood_time", DEFAULT_CHAT_FLOOD_TIME_SECONDS)); } - private double getBasicCharacterRatio(final Scope scope) { - try { - return Double.parseDouble(propsProvider.get().getProperty( - String.format("pyx.chat.%s.basic_ratio", scope), - String.valueOf(DEFAULT_BASIC_CHARACTER_RATIO))); - } catch (final NumberFormatException e) { - LOG.warn(String.format("Unable to parse pyx.chat.%s.basic_ratio as a number," - + " using default of %d", scope, DEFAULT_BASIC_CHARACTER_RATIO), e); - return DEFAULT_BASIC_CHARACTER_RATIO; - } - } - private FilterData getFilterData(final User user) { FilterData data; synchronized (filterData) { diff --git a/src/test/java/net/socialgamer/cah/util/ChatFilterTest.java b/src/test/java/net/socialgamer/cah/util/ChatFilterTest.java new file mode 100644 index 0000000..a8fb49f --- /dev/null +++ b/src/test/java/net/socialgamer/cah/util/ChatFilterTest.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2018, Andy Janata + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted + * provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this list of conditions + * and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of + * conditions and the following disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package net.socialgamer.cah.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ChatFilterTest { + + @Test + public void testSimpleMessagePattern() { + assertTrue(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher("Hello world.").matches()); + assertTrue(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher(":) :( =\\ ._.").matches()); + assertTrue(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher("*neat* (cool) [awesome]").matches()); + assertFalse(ChatFilter.SIMPLE_MESSAGE_PATTERN.matcher(":$").matches()); + } +}