diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java index 2a784bdad..48b6f5404 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java @@ -15,6 +15,8 @@ import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.simpleapi.messages.MessageAPI; +import me.clip.placeholderapi.PlaceholderAPI; + public class JavascriptEngine { private HashMap engineAPI; @@ -102,6 +104,33 @@ public JavascriptEngine addToEngine(String text, Object ob) { return this; } + public String preparePlaceholders(OfflinePlayer player, String expression) { + return preparePlaceholders(player, expression, null); + } + + public String preparePlaceholders(OfflinePlayer player, String expression, HashMap placeholders) { + if (expression == null || expression.isEmpty()) { + return expression; + } + + return JavascriptPlaceholderParser.replace(expression, placeholder -> { + String key = placeholder.substring(1, placeholder.length() - 1); + if (placeholders != null) { + for (Entry entry : placeholders.entrySet()) { + if (entry.getKey().equalsIgnoreCase(key)) { + return entry.getValue(); + } + } + } + + if (placeholder.startsWith("%") && player != null + && AdvancedCorePlugin.getInstance().isPlaceHolderAPIEnabled()) { + return PlaceholderAPI.setPlaceholders(player, placeholder); + } + return placeholder; + }, this::addToEngine); + } + public void execute(String expression) { getResult(expression); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParser.java new file mode 100644 index 000000000..b1528b30a --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParser.java @@ -0,0 +1,555 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class JavascriptPlaceholderParser { + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("%([^%]+)%|(? REGEX_PREFIX_KEYWORDS = Set.of("return", "throw", "case", "delete", "void", + "typeof", "instanceof", "in", "of", "new", "yield", "await", "else", "do"); + private static final Set CONTROL_HEAD_KEYWORDS = Set.of("if", "while", "for", "with", "switch", "catch"); + private static final Set BLOCK_PREFIX_KEYWORDS = Set.of("else", "do", "try", "finally", "static"); + + private JavascriptPlaceholderParser() { + } + + static String replace(String script, Function resolver, BiConsumer bindings) { + Matcher matcher = PLACEHOLDER_PATTERN.matcher(script); + StringBuffer result = new StringBuffer(); + int index = 0; + while (matcher.find()) { + String placeholder = matcher.group(); + String value = JavascriptSafeValue.decodePlaceholder(placeholder); + if (value == null) { + value = resolver.apply(placeholder); + } + if (value == null || value.equals(placeholder)) { + matcher.appendReplacement(result, Matcher.quoteReplacement(placeholder)); + continue; + } + + Context context = contextAt(script, matcher.start()); + String replacement; + if (context == Context.REGEX || context == Context.REGEX_CHARACTER_CLASS) { + replacement = escapeRegexLiteral(value, context == Context.REGEX_CHARACTER_CLASS); + } else if (context == Context.CODE || context == Context.TEMPLATE_EXPRESSION || context == Context.COMMENT) { + String variable = VARIABLE_PREFIX + index++; + bindings.accept(variable, coercePrimitive(value)); + replacement = variable; + } else { + replacement = escape(value, context == Context.SINGLE_QUOTE ? '\'' + : context == Context.DOUBLE_QUOTE ? '"' : '`'); + } + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(result); + return result.toString(); + } + + private static Object coercePrimitive(String value) { + if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { + return Boolean.valueOf(value); + } + if (INTEGER_PATTERN.matcher(value).matches()) { + try { + return Long.valueOf(value); + } catch (NumberFormatException ignored) { + } + } + if (DECIMAL_PATTERN.matcher(value).matches()) { + try { + return Double.valueOf(value); + } catch (NumberFormatException ignored) { + } + } + return value; + } + + private static Context contextAt(String script, int end) { + Deque templates = new ArrayDeque<>(); + Deque controlParens = new ArrayDeque<>(); + Deque functionExpressionParens = new ArrayDeque<>(); + Deque statementBraces = new ArrayDeque<>(); + Context context = Context.CODE; + boolean escaped = false; + boolean regexCharacterClass = false; + int lastControlHeadClose = -1; + int lastFunctionExpressionParenClose = -1; + int lastStatementBlockClose = -1; + + for (int i = 0; i < end; i++) { + char current = script.charAt(i); + char next = i + 1 < end ? script.charAt(i + 1) : '\0'; + + if (context == Context.LINE_COMMENT) { + if (current == '\n' || current == '\r') { + context = codeContext(templates); + } + continue; + } + if (context == Context.BLOCK_COMMENT) { + if (current == '*' && next == '/') { + context = codeContext(templates); + i++; + } + continue; + } + if (context == Context.REGEX) { + if (escaped) { + escaped = false; + continue; + } + if (current == '\\') { + escaped = true; + continue; + } + if (current == '[') { + regexCharacterClass = true; + continue; + } + if (current == ']' && regexCharacterClass) { + regexCharacterClass = false; + continue; + } + if (current == '/' && !regexCharacterClass) { + context = codeContext(templates); + } + continue; + } + if (context == Context.SINGLE_QUOTE || context == Context.DOUBLE_QUOTE) { + char quote = context == Context.SINGLE_QUOTE ? '\'' : '"'; + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + context = codeContext(templates); + } + continue; + } + if (context == Context.TEMPLATE_TEXT) { + if (escaped) { + escaped = false; + continue; + } + if (current == '\\') { + escaped = true; + continue; + } + if (current == '`') { + templates.pop(); + context = codeContext(templates); + continue; + } + if (current == '$' && next == '{') { + templates.peek().expressionDepth = 1; + context = Context.TEMPLATE_EXPRESSION; + i++; + } + continue; + } + + if (current == '/' && next == '/') { + context = Context.LINE_COMMENT; + i++; + continue; + } + if (current == '/' && next == '*') { + context = Context.BLOCK_COMMENT; + i++; + continue; + } + if (current == '/' && startsRegexLiteral(script, i, lastControlHeadClose, lastStatementBlockClose)) { + context = Context.REGEX; + escaped = false; + regexCharacterClass = false; + continue; + } + if (current == '\'') { + context = Context.SINGLE_QUOTE; + escaped = false; + continue; + } + if (current == '"') { + context = Context.DOUBLE_QUOTE; + escaped = false; + continue; + } + if (current == '`') { + templates.push(new TemplateFrame()); + context = Context.TEMPLATE_TEXT; + continue; + } + if (current == '(') { + int keywordEnd = previousNonWhitespace(script, i - 1); + controlParens.push(isStandaloneControlHead(script, keywordEnd, statementBraces)); + functionExpressionParens.push(opensFunctionExpressionParameters(script, i, statementBraces)); + } else if (current == ')' && !controlParens.isEmpty()) { + if (controlParens.pop()) { + lastControlHeadClose = i; + } + if (!functionExpressionParens.isEmpty() && functionExpressionParens.pop()) { + lastFunctionExpressionParenClose = i; + } + } + + if (current == '{') { + statementBraces.push(opensStatementBlock(script, i, statementBraces, lastFunctionExpressionParenClose)); + if (!templates.isEmpty() && templates.peek().expressionDepth > 0) { + templates.peek().expressionDepth++; + } + } else if (current == '}') { + boolean closesTemplateExpression = !templates.isEmpty() && templates.peek().expressionDepth == 1; + if (!closesTemplateExpression && !statementBraces.isEmpty() && statementBraces.pop()) { + lastStatementBlockClose = i; + } + if (!templates.isEmpty() && templates.peek().expressionDepth > 0) { + templates.peek().expressionDepth--; + if (templates.peek().expressionDepth == 0) { + context = Context.TEMPLATE_TEXT; + } + } + } + } + if (context == Context.LINE_COMMENT || context == Context.BLOCK_COMMENT) { + return Context.COMMENT; + } + if (context == Context.REGEX && regexCharacterClass) { + return Context.REGEX_CHARACTER_CLASS; + } + return context; + } + + private static boolean startsRegexLiteral(String script, int slashIndex, int lastControlHeadClose, + int lastStatementBlockClose) { + int previousIndex = previousNonWhitespace(script, slashIndex - 1); + if (previousIndex < 0) { + return true; + } + char previous = script.charAt(previousIndex); + if ((previous == '+' || previous == '-') && isPostfixIncrementOrDecrement(script, previousIndex)) { + return false; + } + if ("([{:;,=!?&|+-*%^~<>".indexOf(previous) >= 0) { + return true; + } + if (previous == ')' && previousIndex == lastControlHeadClose) { + return true; + } + if (previous == '}' && previousIndex == lastStatementBlockClose) { + return true; + } + return isStandaloneRegexPrefixKeyword(script, previousIndex); + } + + private static boolean isStandaloneRegexPrefixKeyword(String script, int keywordEnd) { + String keyword = previousIdentifier(script, keywordEnd); + if (!REGEX_PREFIX_KEYWORDS.contains(keyword)) { + return false; + } + int keywordStart = identifierStart(script, keywordEnd); + int beforeKeyword = previousNonWhitespace(script, keywordStart - 1); + return beforeKeyword < 0 || script.charAt(beforeKeyword) != '.'; + } + + private static boolean isStandaloneControlHead(String script, int keywordEnd, Deque statementBraces) { + String keyword = previousIdentifier(script, keywordEnd); + if (!CONTROL_HEAD_KEYWORDS.contains(keyword)) { + return false; + } + int keywordStart = identifierStart(script, keywordEnd); + int beforeKeyword = previousNonWhitespace(script, keywordStart - 1); + if (beforeKeyword >= 0 && script.charAt(beforeKeyword) == '.') { + return false; + } + return statementBraces.isEmpty() || statementBraces.peek(); + } + + private static boolean isPostfixIncrementOrDecrement(String script, int operatorEndIndex) { + char operator = script.charAt(operatorEndIndex); + if (operatorEndIndex == 0 || script.charAt(operatorEndIndex - 1) != operator) { + return false; + } + int operandEnd = previousNonWhitespace(script, operatorEndIndex - 2); + if (operandEnd < 0) { + return false; + } + char operand = script.charAt(operandEnd); + return Character.isJavaIdentifierPart(operand) || Character.isDigit(operand) || operand == ')' || operand == ']' + || operand == '}'; + } + + private static boolean opensStatementBlock(String script, int openBraceIndex, Deque statementBraces, + int lastFunctionExpressionParenClose) { + int prefixIndex = previousNonWhitespace(script, openBraceIndex - 1); + if (prefixIndex < 0) { + return true; + } + char prefix = script.charAt(prefixIndex); + if (prefix == ')') { + return prefixIndex != lastFunctionExpressionParenClose; + } + if (prefix == '}' || prefix == ';') { + return true; + } + if (prefix == ':') { + return followsStatementLabel(script, prefixIndex, statementBraces); + } + if (prefix == '>' && prefixIndex > 0 && script.charAt(prefixIndex - 1) == '=') { + // Arrow-function bodies are part of an expression. Their closing brace is an + // operand and must not make a following slash look like a regex statement. + return false; + } + if (opensClassDeclarationBody(script, prefixIndex, statementBraces)) { + return true; + } + return BLOCK_PREFIX_KEYWORDS.contains(previousIdentifier(script, prefixIndex)); + } + + private static boolean opensClassDeclarationBody(String script, int prefixIndex, Deque statementBraces) { + int cursor = prefixIndex; + int parenDepth = 0; + int bracketDepth = 0; + while (cursor >= 0) { + char current = script.charAt(cursor); + if (Character.isWhitespace(current)) { + cursor--; + continue; + } + if (current == ')') { + parenDepth++; + cursor--; + continue; + } + if (current == '(' && parenDepth > 0) { + parenDepth--; + cursor--; + continue; + } + if (current == ']') { + bracketDepth++; + cursor--; + continue; + } + if (current == '[' && bracketDepth > 0) { + bracketDepth--; + cursor--; + continue; + } + if (parenDepth > 0 || bracketDepth > 0) { + cursor--; + continue; + } + if (Character.isJavaIdentifierPart(current)) { + int start = identifierStart(script, cursor); + String word = script.substring(start, cursor + 1); + if (word.equals("class")) { + int beforeClass = previousNonWhitespace(script, start - 1); + return isClassDeclarationBoundary(script, beforeClass, statementBraces); + } + cursor = previousNonWhitespace(script, start - 1); + continue; + } + if ("=?:,;".indexOf(current) >= 0) { + return false; + } + cursor--; + } + return false; + } + + private static boolean isClassDeclarationBoundary(String script, int beforeClass, Deque statementBraces) { + if (isStatementBoundary(script, beforeClass, statementBraces)) { + return true; + } + if (beforeClass < 0 || !Character.isJavaIdentifierPart(script.charAt(beforeClass))) { + return false; + } + String previousWord = previousIdentifier(script, beforeClass); + if (previousWord.equals("export")) { + int exportStart = identifierStart(script, beforeClass); + return isStatementBoundary(script, previousNonWhitespace(script, exportStart - 1), statementBraces); + } + if (previousWord.equals("default")) { + int defaultStart = identifierStart(script, beforeClass); + int beforeDefault = previousNonWhitespace(script, defaultStart - 1); + if (beforeDefault >= 0 && previousIdentifier(script, beforeDefault).equals("export")) { + int exportStart = identifierStart(script, beforeDefault); + return isStatementBoundary(script, previousNonWhitespace(script, exportStart - 1), statementBraces); + } + } + return false; + } + + private static boolean opensFunctionExpressionParameters(String script, int openParenIndex, + Deque statementBraces) { + int tokenEnd = previousNonWhitespace(script, openParenIndex - 1); + if (tokenEnd >= 0 && script.charAt(tokenEnd) == '*') { + tokenEnd = previousNonWhitespace(script, tokenEnd - 1); + } + if (tokenEnd < 0 || !Character.isJavaIdentifierPart(script.charAt(tokenEnd))) { + return false; + } + + String token = previousIdentifier(script, tokenEnd); + int functionEnd; + boolean anonymous; + if (token.equals("function")) { + functionEnd = tokenEnd; + anonymous = true; + } else { + int tokenStart = identifierStart(script, tokenEnd); + int beforeName = previousNonWhitespace(script, tokenStart - 1); + if (beforeName >= 0 && script.charAt(beforeName) == '*') { + beforeName = previousNonWhitespace(script, beforeName - 1); + } + if (beforeName < 0 || !previousIdentifier(script, beforeName).equals("function")) { + return false; + } + functionEnd = beforeName; + anonymous = false; + } + + int functionStart = identifierStart(script, functionEnd); + int beforeFunction = previousNonWhitespace(script, functionStart - 1); + if (beforeFunction >= 0 && script.charAt(beforeFunction) == '.') { + return false; + } + if (anonymous) { + return true; + } + + // Include a preceding async keyword when deciding whether a named function is + // a declaration or an expression. + if (beforeFunction >= 0 && Character.isJavaIdentifierPart(script.charAt(beforeFunction)) + && previousIdentifier(script, beforeFunction).equals("async")) { + int asyncStart = identifierStart(script, beforeFunction); + beforeFunction = previousNonWhitespace(script, asyncStart - 1); + } + + return !isStatementBoundary(script, beforeFunction, statementBraces); + } + + private static boolean isStatementBoundary(String script, int index, Deque statementBraces) { + if (index < 0) { + return true; + } + char previous = script.charAt(index); + if (previous == ';' || previous == '}') { + return true; + } + if (previous == '{') { + return statementBraces.isEmpty() || statementBraces.peek(); + } + return false; + } + + private static boolean followsStatementLabel(String script, int colonIndex, Deque statementBraces) { + int labelEnd = previousNonWhitespace(script, colonIndex - 1); + if (labelEnd < 0 || !Character.isJavaIdentifierPart(script.charAt(labelEnd))) { + return false; + } + int labelStart = identifierStart(script, labelEnd); + int beforeLabel = previousNonWhitespace(script, labelStart - 1); + if (beforeLabel < 0) { + return true; + } + char previous = script.charAt(beforeLabel); + if (previous == ';' || previous == '}') { + return true; + } + if (previous == '{') { + return statementBraces.isEmpty() || statementBraces.peek(); + } + String previousWord = previousIdentifier(script, beforeLabel); + return previousWord.equals("case"); + } + + private static int previousNonWhitespace(String script, int index) { + for (int i = index; i >= 0; i--) { + if (!Character.isWhitespace(script.charAt(i))) { + return i; + } + } + return -1; + } + + private static int identifierStart(String script, int endIndex) { + if (endIndex < 0 || !Character.isJavaIdentifierPart(script.charAt(endIndex))) { + return endIndex + 1; + } + int start = endIndex; + while (start > 0 && Character.isJavaIdentifierPart(script.charAt(start - 1))) { + start--; + } + return start; + } + + private static String previousIdentifier(String script, int endIndex) { + if (endIndex < 0 || !Character.isJavaIdentifierPart(script.charAt(endIndex))) { + return ""; + } + int start = identifierStart(script, endIndex); + return script.substring(start, endIndex + 1); + } + + private static Context codeContext(Deque templates) { + return !templates.isEmpty() && templates.peek().expressionDepth > 0 ? Context.TEMPLATE_EXPRESSION : Context.CODE; + } + + private static String escapeRegexLiteral(String value, boolean characterClass) { + StringBuilder escaped = new StringBuilder(value.length()); + String special = characterClass ? "\\/]^-" : "\\/.*+?^${}()|[]"; + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + switch (current) { + case '\r': + escaped.append("\\r"); + break; + case '\n': + escaped.append("\\n"); + break; + case '\u2028': + escaped.append("\\u2028"); + break; + case '\u2029': + escaped.append("\\u2029"); + break; + default: + if (special.indexOf(current) >= 0) { + escaped.append('\\'); + } + escaped.append(current); + break; + } + } + return escaped.toString(); + } + + private static String escape(String value, char quote) { + String escaped = value.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n") + .replace("\u2028", "\\u2028").replace("\u2029", "\\u2029"); + escaped = escaped.replace(String.valueOf(quote), "\\" + quote); + if (quote == '`') { + escaped = escaped.replace("${", "\\${"); + } + return escaped; + } + + private enum Context { + CODE, SINGLE_QUOTE, DOUBLE_QUOTE, TEMPLATE_TEXT, TEMPLATE_EXPRESSION, REGEX, REGEX_CHARACTER_CLASS, + LINE_COMMENT, BLOCK_COMMENT, COMMENT + } + + private static final class TemplateFrame { + private int expressionDepth; + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptSafeValue.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptSafeValue.java new file mode 100644 index 000000000..6e631c2aa --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptSafeValue.java @@ -0,0 +1,37 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Encodes values that must survive an earlier placeholder pass but still be + * treated as data by the JavaScript placeholder parser. + */ +public final class JavascriptSafeValue { + private static final String PREFIX = "__advancedcore_js_value:"; + + private JavascriptSafeValue() { + } + + public static String encodePlaceholder(String value) { + String encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + return "{" + PREFIX + encoded + "}"; + } + + static String decodePlaceholder(String placeholder) { + if (placeholder == null || placeholder.length() < 2 || placeholder.charAt(0) != '{' + || placeholder.charAt(placeholder.length() - 1) != '}') { + return null; + } + String value = placeholder.substring(1, placeholder.length() - 1); + if (!value.startsWith(PREFIX)) { + return null; + } + try { + return new String(Base64.getUrlDecoder().decode(value.substring(PREFIX.length())), StandardCharsets.UTF_8); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java index 8e74bb82d..5cbfbbfe6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java @@ -10,6 +10,7 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; +import com.bencodez.advancedcore.api.javascript.JavascriptSafeValue; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.simpleapi.messages.MessageAPI; @@ -19,6 +20,10 @@ import net.md_5.bungee.api.chat.TextComponent; public class PlaceholderUtils { + private static final String JAVASCRIPT_MARKER = "[Javascript="; + private static final String SAFE_JAVASCRIPT_MARKER = "[\u200BJavascript="; + private static final String PROTECTED_JAVASCRIPT_MARKER = "[\u2063Javascript="; + @SuppressWarnings("deprecation") public static TextComponent parseJson(String msg) { TextComponent comp = new TextComponent(""); @@ -39,7 +44,6 @@ public static TextComponent parseJson(String msg) { TextComponent t = new TextComponent(text); String typeMsg = msg; - // types boolean parsing = true; while (parsing) { int nextTypeIndex = typeMsg.indexOf("\","); @@ -67,21 +71,12 @@ public static TextComponent parseJson(String msg) { } else if (type.equalsIgnoreCase("suggest_command")) { t.setClickEvent(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, typeData)); } - } - /* - * int secondMiddle = msg.indexOf("=\"", middle); String type = - * msg.substring(middle + "\",".length(), secondMiddle); String typeData = - * msg.substring(secondMiddle + "=\"".length(), endIndex); - */ comp.addExtra(parseJson(preMessage)); - comp.addExtra(t); - comp.addExtra(parseJson(postMessage)); } else { - comp.addExtra(new TextComponent(TextComponent.fromLegacyText(msg))); } return comp; @@ -92,13 +87,14 @@ public static String parseText(Player player, String str) { } public static String parseText(Player player, String str, HashMap placeholders) { + if (AdvancedCorePlugin.getInstance().getOptions().isJavascriptEngineEnabled()) { + JavascriptEngine engine = new JavascriptEngine().addPlayer(player); + str = replaceJavascript(str, engine, player, placeholders); + } if (placeholders != null) { str = replacePlaceHolder(str, placeholders); } - str = replacePlaceHolders(player, str); - - str = replaceJavascript(player, str); return MessageAPI.colorize(str); } @@ -107,11 +103,12 @@ public static String parseText(String str) { } public static String parseText(String str, HashMap placeholders) { + if (AdvancedCorePlugin.getInstance().getOptions().isJavascriptEngineEnabled()) { + str = replaceJavascript(str, new JavascriptEngine(), null, placeholders); + } if (placeholders != null) { str = replacePlaceHolder(str, placeholders); } - - str = replaceJavascript(str); return MessageAPI.colorize(str); } @@ -126,7 +123,7 @@ public static ArrayList replaceJavascript(AdvancedCoreUser user, ArrayLi public static String replaceJavascript(AdvancedCoreUser user, String text) { if (user.getPlugin().getOptions().isJavascriptEngineEnabled()) { JavascriptEngine engine = new JavascriptEngine().addPlayer(user); - return replaceJavascript(text, engine); + return replaceJavascript(text, engine, user.getOfflinePlayer(), null); } return text; } @@ -176,9 +173,10 @@ public static String replaceJavascript(OfflinePlayer player, String text) { return replaceJavascript(player.getPlayer(), text); } JavascriptEngine engine = new JavascriptEngine().addPlayer(player); - return replaceJavascript(text, engine); + String parsed = replaceJavascript(text, engine, player, null); + return replacePlaceHolders(player, parsed); } - return text; + return replacePlaceHolders(player, text); } public static ArrayList replaceJavascriptOnly(OfflinePlayer player, ArrayList list) { @@ -195,7 +193,7 @@ public static String replaceJavascriptOnly(OfflinePlayer player, String text) { return replaceJavascriptOnly(player.getPlayer(), text); } JavascriptEngine engine = new JavascriptEngine().addPlayer(player); - return replaceJavascript(text, engine); + return replaceJavascript(text, engine, player, null); } return text; } @@ -209,12 +207,12 @@ public static ArrayList replaceJavascript(Player player, ArrayList replaceJavascriptOnly(Player player, ArrayList list) { @@ -228,7 +226,7 @@ public static ArrayList replaceJavascriptOnly(Player player, ArrayList placeholders) { String msg = ""; - if (MessageAPI.containsIgnorecase(text, "[Javascript=")) { + if (MessageAPI.containsIgnorecase(text, JAVASCRIPT_MARKER)) { if (engine == null) { engine = new JavascriptEngine(); } @@ -247,7 +250,7 @@ public static String replaceJavascript(String text, JavascriptEngine engine) { int startIndex = 0; int num = 0; while (startIndex != -1) { - startIndex = text.indexOf("[Javascript=", lastIndex); + startIndex = text.indexOf(JAVASCRIPT_MARKER, lastIndex); int endIndex = -1; if (startIndex != -1) { @@ -258,28 +261,28 @@ public static String replaceJavascript(String text, JavascriptEngine engine) { } num++; endIndex = text.indexOf("]", startIndex); - String str = text.substring(startIndex + "[Javascript=".length(), endIndex); - // plugin.debug(startIndex + ":" + endIndex + " from " + - // text + " to " + str + " currently " + msg); - String script = engine.getStringValue(str); + if (endIndex == -1) { + return text; + } + String scriptText = text.substring(startIndex + JAVASCRIPT_MARKER.length(), endIndex); + String prepared = engine.preparePlaceholders(player, scriptText, placeholders); + if (prepared == null) { + prepared = scriptText; + } + String script = engine.getStringValue(prepared); if (script == null) { - script = "" + engine.getBooleanValue(str); - + script = "" + engine.getBooleanValue(prepared); } - if (script != null) { msg += script; } lastIndex = endIndex; } - } msg += text.substring(lastIndex + 1); - } else { msg = text; } - // plugin.debug(msg); return msg; } @@ -292,44 +295,130 @@ public static ArrayList replacePlaceHolder(ArrayList list, HashM } public static String replacePlaceHolder(String str, HashMap placeholders) { - if (placeholders != null) { - for (Entry entry : placeholders.entrySet()) { - str = replacePlaceHolder(str, entry.getKey(), entry.getValue()); - } + if (str == null || placeholders == null || placeholders.isEmpty()) { + return str; + } + str = encodeJavascriptMarkerMapValues(str, placeholders, true); + String protectedMarker = createProtectedJavascriptMarkerToken(str, placeholders); + str = str.replace(JAVASCRIPT_MARKER, protectedMarker); + for (Entry entry : placeholders.entrySet()) { + str = replacePlaceHolder(str, entry.getKey(), entry.getValue()); } - return str; + str = neutralizeJavascriptMarker(str); + return str.replace(protectedMarker, JAVASCRIPT_MARKER); } public static String replacePlaceHolder(String str, HashMap placeholders, boolean ignoreCase) { - if (placeholders != null) { - for (Entry entry : placeholders.entrySet()) { - str = replacePlaceHolder(str, entry.getKey(), entry.getValue(), ignoreCase); - } + if (str == null || placeholders == null || placeholders.isEmpty()) { + return str; + } + str = encodeJavascriptMarkerMapValues(str, placeholders, ignoreCase); + String protectedMarker = createProtectedJavascriptMarkerToken(str, placeholders); + str = str.replace(JAVASCRIPT_MARKER, protectedMarker); + for (Entry entry : placeholders.entrySet()) { + str = replacePlaceHolder(str, entry.getKey(), entry.getValue(), ignoreCase); } - return str; + str = neutralizeJavascriptMarker(str); + return str.replace(protectedMarker, JAVASCRIPT_MARKER); } - /** - * Replace place holder. - * - * @param str the str - * @param toReplace the to replace - * @param replaceWith the replace with - * @return the string - */ public static String replacePlaceHolder(String str, String toReplace, String replaceWith) { return replacePlaceHolder(str, toReplace, replaceWith, true); } public static String replacePlaceHolder(String str, String toReplace, String replaceWith, boolean ignoreCase) { + String protectedSource = protectJavascriptMarkers(str); + String safeReplacement = neutralizeJavascriptReplacement(replaceWith); + String replaced; if (ignoreCase) { - return MessageAPI.replaceIgnoreCase(MessageAPI.replaceIgnoreCase(str, "%" + toReplace + "%", replaceWith), - "\\{" + toReplace + "\\}", replaceWith); + replaced = MessageAPI.replaceIgnoreCase( + MessageAPI.replaceIgnoreCase(protectedSource, "%" + toReplace + "%", safeReplacement), + "\\{" + toReplace + "\\}", safeReplacement); + } else { + replaced = protectedSource.replaceAll("\\{", "%"); + replaced = replaced.replaceAll("\\}", "%"); + replaced = replaced.replace("%" + toReplace + "%", safeReplacement); + } + return restoreJavascriptMarkers(neutralizeJavascriptMarker(replaced)); + } + + static String neutralizeJavascriptMarker(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return value.replace(JAVASCRIPT_MARKER, SAFE_JAVASCRIPT_MARKER); + } + + private static String encodeJavascriptMarkerMapValues(String text, HashMap placeholders, + boolean ignoreCase) { + StringBuilder result = new StringBuilder(text.length()); + int index = 0; + while (index < text.length()) { + int start = text.indexOf(JAVASCRIPT_MARKER, index); + if (start < 0) { + result.append(text, index, text.length()); + break; + } + result.append(text, index, start); + int end = text.indexOf(']', start + JAVASCRIPT_MARKER.length()); + if (end < 0) { + result.append(text, start, text.length()); + break; + } + String script = text.substring(start + JAVASCRIPT_MARKER.length(), end); + for (Entry entry : placeholders.entrySet()) { + String safeValue = JavascriptSafeValue.encodePlaceholder(entry.getValue()); + if (ignoreCase) { + script = MessageAPI.replaceIgnoreCase(script, "%" + entry.getKey() + "%", safeValue); + script = MessageAPI.replaceIgnoreCase(script, "\\{" + entry.getKey() + "\\}", safeValue); + } else { + script = script.replace("%" + entry.getKey() + "%", safeValue); + script = script.replace("{" + entry.getKey() + "}", safeValue); + } + } + result.append(JAVASCRIPT_MARKER).append(script).append(']'); + index = end + 1; + } + return result.toString(); + } + + private static String createProtectedJavascriptMarkerToken(String text, HashMap placeholders) { + String token; + boolean collision; + do { + token = ""; + collision = text != null && text.contains(token); + if (!collision && placeholders != null) { + for (String value : placeholders.values()) { + if (value != null && value.contains(token)) { + collision = true; + break; + } + } + } + } while (collision); + return token; + } + + private static String neutralizeJavascriptReplacement(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return neutralizeJavascriptMarker(value).replace(PROTECTED_JAVASCRIPT_MARKER, SAFE_JAVASCRIPT_MARKER); + } + + private static String protectJavascriptMarkers(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return value.replace(JAVASCRIPT_MARKER, PROTECTED_JAVASCRIPT_MARKER); + } + + private static String restoreJavascriptMarkers(String value) { + if (value == null || value.isEmpty()) { + return value; } - str = str.replaceAll("\\{", "%"); - str = str.replaceAll("\\}", "%"); - str = str.replace("%" + toReplace + "%", replaceWith); - return str; + return value.replace(PROTECTED_JAVASCRIPT_MARKER, JAVASCRIPT_MARKER); } public static ArrayList replacePlaceHolders(ArrayList list, Player p) { @@ -358,13 +447,6 @@ public static String replacePlaceHolders(OfflinePlayer player, String text) { return text; } - /** - * Replace place holders. - * - * @param player the player - * @param text the text - * @return the string - */ public static String replacePlaceHolders(Player player, String text) { if (player == null) { return text; diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java index 3af8e73a5..a2dff62b0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java @@ -14,7 +14,6 @@ import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueList; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; -import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.DefinedReward; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardBuilder; @@ -39,8 +38,8 @@ public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList placeholders) { if (section.getBoolean("Enabled")) { String expression = section.getString("Expression"); - expression = PlaceholderUtils.replacePlaceHolders(user.getOfflinePlayer(), expression); - if (new JavascriptEngine().addPlayer(user.getOfflinePlayer()) - .getBooleanValue(PlaceholderUtils.replacePlaceHolder(expression, placeholders))) { + JavascriptEngine engine = new JavascriptEngine().addPlayer(user.getOfflinePlayer()); + String prepared = engine.preparePlaceholders(user.getOfflinePlayer(), expression, placeholders); + if (engine.getBooleanValue(prepared == null ? expression : prepared)) { new RewardBuilder(section, "TrueRewards").withPrefix(reward.getName() + ".Javascript").send(user); } else { new RewardBuilder(section, "FalseRewards").withPrefix(reward.getName() + ".Javascript").send(user); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java index 22be9c421..cc656feb6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java @@ -8,7 +8,6 @@ import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueString; import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.javascript.JavascriptEngine; -import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardEditData; import com.bencodez.advancedcore.api.rewards.RewardHandler; @@ -28,9 +27,16 @@ public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { @Override public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, String expression, RewardOptions rewardOptions) { - return expression.equals("") || new JavascriptEngine().addPlayer(user.getOfflinePlayer()) - .getBooleanValue(PlaceholderUtils.replacePlaceHolders(user.getOfflinePlayer(), - PlaceholderUtils.replacePlaceHolder(expression, rewardOptions.getPlaceholders()))); + if (expression.equals("")) { + return true; + } + JavascriptEngine engine = new JavascriptEngine().addPlayer(user.getOfflinePlayer()); + String prepared = engine.preparePlaceholders(user.getOfflinePlayer(), expression, + rewardOptions.getPlaceholders()); + if (prepared == null) { + prepared = expression; + } + return engine.getBooleanValue(prepared); } }.priority(90).addEditButton(new EditGUIButton(new ItemBuilder("DETECTOR_RAIL"), new EditGUIValueString("JavascriptExpression", null) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptFunctionExpressionParserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptFunctionExpressionParserTest.java new file mode 100644 index 000000000..36df0d627 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptFunctionExpressionParserTest.java @@ -0,0 +1,37 @@ +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +class JavascriptFunctionExpressionParserTest { + + @Test + void asyncNamedFunctionExpressionFollowedByDivisionStaysDivision() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,x=async function named() {} / 2; '%untrusted%'; allowed", + ignored -> injection, bindings::put); + + assertEquals("var allowed=false,x=async function named() {} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void generatorFunctionExpressionFollowedByDivisionStaysDivision() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,x=function* named() {} / 2; '%untrusted%'; allowed", + ignored -> injection, bindings::put); + + assertEquals("var allowed=false,x=function* named() {} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParserKeywordClassTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParserKeywordClassTest.java new file mode 100644 index 000000000..d2c77f091 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParserKeywordClassTest.java @@ -0,0 +1,74 @@ +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +class JavascriptPlaceholderParserKeywordClassTest { + + @Test + void qualifiedReturnMemberBeforeDivisionDoesNotOpenRegexContext() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,obj={return:4}; obj.return / 2; '%untrusted%'; allowed", + placeholder -> placeholder.equals("%untrusted%") ? injection : placeholder, bindings::put); + + assertEquals("var allowed=false,obj={return:4}; obj.return / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void anonymousGeneratorExpressionBeforeDivisionDoesNotOpenRegexContext() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,x=function*() {} / 2; '%untrusted%'; allowed", + ignored -> injection, bindings::put); + + assertEquals("var allowed=false,x=function*() {} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void classDeclarationBodyAllowsFollowingRegexStatement() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace( + "class X {} /[']/.test('x'); %untrusted%", ignored -> injection, bindings::put); + + assertEquals("class X {} /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void classDeclarationWithExtendsAllowsFollowingRegexStatement() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace( + "class X extends Base {} /[']/.test('x'); %untrusted%", ignored -> injection, bindings::put); + + assertEquals("class X extends Base {} /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void classExpressionBodyBeforeDivisionDoesNotOpenRegexContext() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,C=class X {} / 2; '%untrusted%'; allowed", + ignored -> injection, bindings::put); + + assertEquals("var allowed=false,C=class X {} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParserTest.java new file mode 100644 index 000000000..1fe27427b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderParserTest.java @@ -0,0 +1,365 @@ +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +class JavascriptPlaceholderParserTest { + @Test + void bindsUnquotedPlaceholderValuesInsteadOfAddingThemToSource() { + HashMap bindings = new HashMap<>(); + String injection = "\"); Bukkit.dispatchCommand(Console, \"op attacker\"); //"; + + String script = JavascriptPlaceholderParser.replace("%player_name% == 'allowed'", ignored -> injection, + bindings::put); + + assertEquals("__advancedCorePlaceholder0 == 'allowed'", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void escapesPlaceholderValuesInsideQuotedStrings() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("check('%player_name%', \"%player_name%\")", + ignored -> "'\"\\\n${attack}", bindings::put); + + assertEquals("check('\\'\"\\\\\\n${attack}', \"'\\\"\\\\\\n${attack}\")", script); + assertEquals(0, bindings.size()); + } + + @Test + void escapesTemplateLiteralText() { + String script = JavascriptPlaceholderParser.replace("`Hello %player_name%`", ignored -> "${attack}`", + (name, value) -> { + }); + + assertEquals("`Hello \\${attack}\\``", script); + } + + @Test + void bindsPlaceholdersInsideTemplateExpressions() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("`${%untrusted%}`", ignored -> injection, bindings::put); + + assertEquals("`${__advancedCorePlaceholder0}`", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void ignoresCommentBracesInsideTemplateExpressions() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("`${/* } */ %untrusted%}`", ignored -> injection, + bindings::put); + + assertEquals("`${/* } */ __advancedCorePlaceholder0}`", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void preservesNestedTemplateLiteralTextInsideExpressions() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("`${`Hello %name%`}`", ignored -> "Ben", bindings::put); + + assertEquals("`${`Hello Ben`}`", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void regexLiteralQuotesDoNotChangePlaceholderContext() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("/[']/.test('x'); %untrusted%", ignored -> injection, + bindings::put); + + assertEquals("/[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void recognizesRegexLiteralAfterControlHead() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("if (ok) /[']/.test('x'); %untrusted%", ignored -> injection, + bindings::put); + + assertEquals("if (ok) /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void recognizesRegexLiteralAfterExpressionKeyword() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("return /[']/.test('x'); %untrusted%", ignored -> injection, + bindings::put); + + assertEquals("return /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void recognizesRegexLiteralAfterStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("if (ok) {} /[']/.test('x'); %untrusted%", ignored -> injection, + bindings::put); + + assertEquals("if (ok) {} /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void recognizesRegexLiteralAfterLabeledStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("label: {} /[']/.test('x'); %untrusted%", ignored -> injection, + bindings::put); + + assertEquals("label: {} /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void nestedObjectPropertyBlockDoesNotBecomeStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace("var x={a: {}} / 2; '%untrusted%'", + ignored -> injection, bindings::put); + + assertEquals("var x={a: {}} / 2; '\\'; allowed=true; \\''", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void postfixIncrementBeforeDivisionDoesNotOpenRegexContext() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace("var allowed=false,i=1; i++ / 2; '%untrusted%'; allowed", + ignored -> injection, bindings::put); + + assertEquals("var allowed=false,i=1; i++ / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void controlHeadIgnoresParenthesesInsideStrings() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("if (fn(\")\")) /[']/.test('x'); %untrusted%", + ignored -> injection, bindings::put); + + assertEquals("if (fn(\")\")) /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void controlHeadIgnoresParenthesesInsideRegexLiterals() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace("if (/\\)/.test(value)) /[']/.test('x'); %untrusted%", + ignored -> injection, bindings::put); + + assertEquals("if (/\\)/.test(value)) /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void memberNamedIfDoesNotCreateControlHeadRegexContext() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace("obj.if(true) / 2; '%untrusted%'", ignored -> injection, + bindings::put); + + assertEquals("obj.if(true) / 2; '\\'; allowed=true; \\''", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void functionExpressionBodyDoesNotBecomeStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,x=function() {} / 2; '%untrusted%'; allowed", ignored -> injection, bindings::put); + + assertEquals("var allowed=false,x=function() {} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void namedFunctionExpressionBodyDoesNotBecomeStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,x=function named() {} / 2; '%untrusted%'; allowed", ignored -> injection, bindings::put); + + assertEquals("var allowed=false,x=function named() {} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void arrowFunctionBodyDoesNotBecomeStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace( + "var allowed=false,x=()=>{} / 2; '%untrusted%'; allowed", ignored -> injection, bindings::put); + + assertEquals("var allowed=false,x=()=>{} / 2; '\\'; allowed=true; \\''; allowed", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void functionDeclarationBodyStillAllowsRegexStatement() { + HashMap bindings = new HashMap<>(); + String injection = "Bukkit.dispatchCommand(Console, \"op attacker\")"; + + String script = JavascriptPlaceholderParser.replace( + "function named() {} /[']/.test('x'); %untrusted%", ignored -> injection, bindings::put); + + assertEquals("function named() {} /[']/.test('x'); __advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void objectLiteralStringBraceDoesNotBecomeStatementBlock() { + HashMap bindings = new HashMap<>(); + String injection = "'; allowed=true; '"; + + String script = JavascriptPlaceholderParser.replace("var x={a:\"){\"} / 2; '%untrusted%'", + placeholder -> placeholder.equals("%untrusted%") ? injection : placeholder, bindings::put); + + assertEquals("var x={a:\"){\"} / 2; '\\'; allowed=true; \\''", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void objectLiteralDoesNotConsumeNestedPercentPlaceholder() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("({allowed: %permission_result%}).allowed", + placeholder -> placeholder.equals("%permission_result%") ? "true" : placeholder, bindings::put); + + assertEquals("({allowed: __advancedCorePlaceholder0}).allowed", script); + assertEquals(Boolean.TRUE, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void objectLiteralFollowedByDivisionIsNotTreatedAsRegex() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("value = {} / 2; %name%", ignored -> "Ben", bindings::put); + + assertEquals("value = {} / 2; __advancedCorePlaceholder0", script); + assertEquals("Ben", bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void regexCharacterClassesAndEscapesRemainRegexText() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("/[/\\']+/.test(value); %name%", ignored -> "Ben", + bindings::put); + + assertEquals("/[/\\']+/.test(value); __advancedCorePlaceholder0", script); + assertEquals("Ben", bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void preservesPlaceholderValuesInsideRegexLiterals() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("/^%player_name%$/.test(name)", + ignored -> "Ben.* /admin", bindings::put); + + assertEquals("/^Ben\\.\\* \\/admin$/.test(name)", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void escapesPlaceholderValuesInsideRegexCharacterClasses() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("/[%chars%]/.test(name)", ignored -> "]^-", bindings::put); + + assertEquals("/[\\]\\^\\-]/.test(name)", script); + assertTrue(bindings.isEmpty()); + } + + @Test + void encodedMapValueIsDecodedAndBoundAsData() { + HashMap bindings = new HashMap<>(); + String injection = "'; Bukkit.dispatchCommand(Console, \"op attacker\"); '"; + String encoded = JavascriptSafeValue.encodePlaceholder(injection); + + String script = JavascriptPlaceholderParser.replace(encoded, value -> value, bindings::put); + + assertEquals("__advancedCorePlaceholder0", script); + assertEquals(injection, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void preservesBooleanAndNumericPlaceholderTypes() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("%bool% == true && %count% > 2 && %ratio% < 2.0", + placeholder -> { + switch (placeholder) { + case "%bool%": + return "true"; + case "%count%": + return "5"; + default: + return "1.5"; + } + }, bindings::put); + + assertEquals("__advancedCorePlaceholder0 == true && __advancedCorePlaceholder1 > 2 && __advancedCorePlaceholder2 < 2.0", + script); + assertEquals(Boolean.TRUE, bindings.get("__advancedCorePlaceholder0")); + assertEquals(Long.valueOf(5), bindings.get("__advancedCorePlaceholder1")); + assertEquals(Double.valueOf(1.5), bindings.get("__advancedCorePlaceholder2")); + } + + @Test + void supportsBraceFormCustomPlaceholders() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("{permission_result} == true", + placeholder -> placeholder.equals("{permission_result}") ? "true" : placeholder, bindings::put); + + assertEquals("__advancedCorePlaceholder0 == true", script); + assertEquals(Boolean.TRUE, bindings.get("__advancedCorePlaceholder0")); + } + + @Test + void leavesUnresolvedPlaceholdersUntouched() { + HashMap bindings = new HashMap<>(); + + String script = JavascriptPlaceholderParser.replace("%unknown% == true && {unknown_brace} == false", value -> value, + bindings::put); + + assertEquals("%unknown% == true && {unknown_brace} == false", script); + assertTrue(bindings.isEmpty()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/PlaceholderUtilsSecurityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/PlaceholderUtilsSecurityTest.java new file mode 100644 index 000000000..780da6a4e --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/PlaceholderUtilsSecurityTest.java @@ -0,0 +1,89 @@ +package com.bencodez.advancedcore.api.messages; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +class PlaceholderUtilsSecurityTest { + + @Test + void placeholderValueCannotCreateJavascriptMarker() { + HashMap placeholders = new HashMap<>(); + placeholders.put("displayname", "[Javascript=Bukkit.dispatchCommand(Console,'op attacker')]"); + + String result = PlaceholderUtils.replacePlaceHolder("Thanks %displayname%", placeholders); + + assertFalse(result.contains("[Javascript=")); + assertTrue(result.contains("Javascript=")); + } + + @Test + void substitutionsCannotAssembleJavascriptMarkerAcrossTemplateText() { + HashMap placeholders = new HashMap<>(); + placeholders.put("part", "script"); + + String result = PlaceholderUtils.replacePlaceHolder( + "[Java%part%=Bukkit.dispatchCommand(Console,'op attacker')]", placeholders); + + assertFalse(result.contains("[Javascript=")); + assertTrue(result.contains("Javascript=")); + } + + @Test + void multipleSubstitutionsCannotAssembleJavascriptMarker() { + HashMap placeholders = new HashMap<>(); + placeholders.put("prefix", "Java"); + placeholders.put("suffix", "script"); + + String result = PlaceholderUtils.replacePlaceHolder( + "[%prefix%%suffix%=Bukkit.dispatchCommand(Console,'op attacker')]", placeholders); + + assertFalse(result.contains("[Javascript=")); + assertTrue(result.contains("Javascript=")); + } + + @Test + void operatorAuthoredJavascriptMarkerRemainsAvailable() { + HashMap placeholders = new HashMap<>(); + placeholders.put("name", "Ben"); + + String result = PlaceholderUtils.replacePlaceHolder("[Javascript=1+1] %name%", placeholders); + + assertTrue(result.contains("[Javascript=1+1]")); + assertTrue(result.contains("Ben")); + } + + @Test + void mapValueInsideAuthoredJavascriptMarkerIsNotCopiedIntoSource() { + HashMap placeholders = new HashMap<>(); + String injection = "'; Bukkit.dispatchCommand(Console, \"op attacker\"); '"; + placeholders.put("displayname", injection); + + String result = PlaceholderUtils.replacePlaceHolder("[Javascript='%displayname%']", placeholders); + + assertTrue(result.startsWith("[Javascript='")); + assertFalse(result.contains("Bukkit.dispatchCommand")); + assertFalse(result.contains(injection)); + assertTrue(result.contains("__advancedcore_js_value:")); + } + + @Test + void emptyPlaceholderMapLeavesOperatorAuthoredMarkerUntouched() { + String result = PlaceholderUtils.replacePlaceHolder("[Javascript=1+1]", new HashMap<>()); + + assertTrue(result.contains("[Javascript=1+1]")); + } + + @Test + void normalPlaceholderFormattingIsPreserved() { + HashMap placeholders = new HashMap<>(); + placeholders.put("displayname", "&aDisplay Name"); + + String result = PlaceholderUtils.replacePlaceHolder("Thanks %displayname%", placeholders); + + assertTrue(result.contains("&aDisplay Name")); + } +}