diff --git a/.github/scripts/finalize_authored_javascript_boundaries.py b/.github/scripts/finalize_authored_javascript_boundaries.py new file mode 100644 index 000000000..a3eea7847 --- /dev/null +++ b/.github/scripts/finalize_authored_javascript_boundaries.py @@ -0,0 +1,710 @@ +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] + + +def write(relative, content): + path = ROOT / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + + +def replace_once(text, old, new, label): + count = text.count(old) + if count != 1: + raise RuntimeError(f"Expected exactly one {label} match, found {count}") + return text.replace(old, new, 1) + + +write("AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptEngine.java", r''' +package com.bencodez.advancedcore.api.javascript; + +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import javax.script.Bindings; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; +import javax.script.ScriptException; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderBinder.PreparedJavascript; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.messages.MessageAPI; + +public class JavascriptEngine { + private final HashMap engineAPI; + private final HashMap placeholders; + private OfflinePlayer placeholderPlayer; + + public JavascriptEngine() { + engineAPI = new HashMap<>(); + placeholders = new HashMap<>(); + } + + public JavascriptEngine addPlayer(AdvancedCoreUser user) { + placeholderPlayer = user.getOfflinePlayer(); + addToEngine("PlayerName", user.getPlayerName()); + addToEngine("PlayerUUID", user.getUUID()); + addToEngine("AdvancedCoreUser", user); + for (JavascriptPlaceholderRequest request : AdvancedCorePlugin.getInstance().getJavascriptEngineRequests()) { + addToEngine(request.getStr(), request.getObject(user.getOfflinePlayer())); + } + if (user.isOnline()) { + return addPlayer(user.getPlayer()); + } + return this; + } + + public JavascriptEngine addPlayer(CommandSender player) { + addToEngine("CommandSender", player); + if (player instanceof Player) { + Player onlinePlayer = (Player) player; + placeholderPlayer = onlinePlayer; + addToEngine("Player", onlinePlayer); + addToEngine("PlayerName", onlinePlayer.getName()); + addToEngine("PlayerUUID", onlinePlayer.getUniqueId().toString()); + addToEngine("AdvancedCoreUser", AdvancedCorePlugin.getInstance().getUserManager().getUser(onlinePlayer)); + for (JavascriptPlaceholderRequest request : AdvancedCorePlugin.getInstance() + .getJavascriptEngineRequests()) { + addToEngine(request.getStr(), request.getObject(onlinePlayer)); + } + } else { + addToEngine("Player", player); + } + return this; + } + + public JavascriptEngine addPlayer(OfflinePlayer player) { + placeholderPlayer = player; + addToEngine("Player", player); + addToEngine("PlayerName", player.getName()); + addToEngine("PlayerUUID", player.getUniqueId().toString()); + addToEngine("AdvancedCoreUser", AdvancedCorePlugin.getInstance().getUserManager().getUser(player)); + addToEngine("CommandSender", player); + for (JavascriptPlaceholderRequest request : AdvancedCorePlugin.getInstance().getJavascriptEngineRequests()) { + addToEngine(request.getStr(), request.getObject(player)); + } + if (player.isOnline()) { + return addPlayer(player.getPlayer()); + } + return this; + } + + public JavascriptEngine addPlayer(Player player) { + if (player != null) { + placeholderPlayer = player; + addToEngine("Player", player); + addToEngine("PlayerName", player.getName()); + addToEngine("PlayerUUID", player.getUniqueId().toString()); + addToEngine("AdvancedCoreUser", AdvancedCorePlugin.getInstance().getUserManager().getUser(player)); + addToEngine("CommandSender", player); + for (JavascriptPlaceholderRequest request : AdvancedCorePlugin.getInstance() + .getJavascriptEngineRequests()) { + addToEngine(request.getStr(), request.getObject(player)); + } + } + return this; + } + + public JavascriptEngine addPlaceholders(Map values) { + if (values != null && !values.isEmpty()) { + placeholders.putAll(values); + } + return this; + } + + public JavascriptEngine addToEngine(HashMap values) { + if (values != null && !values.isEmpty()) { + engineAPI.putAll(values); + } + return this; + } + + public JavascriptEngine addToEngine(String text, Object object) { + engineAPI.put(text, object); + return this; + } + + public void execute(String expression) { + getResult(expression); + } + + public boolean getBooleanValue(String expression) { + Object result = getResult(expression); + if (result instanceof Boolean) { + return ((Boolean) result).booleanValue(); + } + return result != null && Boolean.parseBoolean(result.toString()); + } + + public Object getResult(String expression) { + if (expression == null || expression.isEmpty()) { + return null; + } + if (!AdvancedCorePlugin.getInstance().getOptions().isJavascriptEngineEnabled()) { + return null; + } + + PreparedJavascript prepared; + try { + prepared = JavascriptPlaceholderBinder.prepare(expression, placeholderPlayer, placeholders); + } catch (IllegalArgumentException exception) { + AdvancedCorePlugin.getInstance().getLogger() + .warning("Refusing to evaluate unsafe or invalid javascript: " + exception.getMessage()); + AdvancedCorePlugin.getInstance().debug(exception); + return null; + } + + ScriptEngine engine = JavascriptEngineHandler.getInstance().getJSScriptEngine(); + if (engine == null) { + AdvancedCorePlugin.getInstance().debug("Failed to process javascript, engine == null"); + return null; + } + + engine.put("Bukkit", Bukkit.getServer()); + engine.put("AdvancedCore", AdvancedCorePlugin.getInstance()); + engine.put("Console", Bukkit.getConsoleSender()); + engine.put("UserManager", AdvancedCorePlugin.getInstance().getUserManager()); + engine.put("RewardHandler", AdvancedCorePlugin.getInstance().getRewardHandler()); + engine.put("MessageAPI", MessageAPI.class); + engineAPI.putAll(AdvancedCorePlugin.getInstance().getJavascriptEngine()); + for (Entry entry : engineAPI.entrySet()) { + engine.put(entry.getKey(), entry.getValue()); + } + + Bindings engineBindings = engine.getBindings(ScriptContext.ENGINE_SCOPE); + prepared.getBindings().forEach(engineBindings::put); + try { + return engine.eval(prepared.getSource()); + } catch (ScriptException exception) { + AdvancedCorePlugin.getInstance().getLogger().warning( + "Error occurred while evaluating javascript, turn debug on to see stacktrace: " + exception); + AdvancedCorePlugin.getInstance().debug(exception); + } finally { + prepared.getBindings().keySet().forEach(engineBindings::remove); + } + return null; + } + + public String getStringValue(String expression) { + try { + Object result = getResult(expression); + if (result != null) { + return result.toString(); + } + } catch (Exception exception) { + AdvancedCorePlugin.getInstance().debug(exception); + } + return ""; + } +} +''') + +placeholder_path = ROOT / "AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/PlaceholderUtils.java" +placeholder = placeholder_path.read_text(encoding="utf-8") +placeholder = replace_once(placeholder, + "import com.bencodez.advancedcore.api.javascript.JavascriptEngine;\n", + "import com.bencodez.advancedcore.api.javascript.JavascriptEngine;\n" + "import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue;\n", + "PlaceholderUtils JavaScript import") + +start = placeholder.index("\tpublic static String replaceJavascript(String text, JavascriptEngine engine) {") +end = placeholder.index("\n\tpublic static ArrayList replacePlaceHolder(", start) +placeholder = placeholder[:start] + r''' public static String replaceJavascript(String text, JavascriptEngine engine) { + if (text == null || text.isEmpty()) { + return text; + } + if (engine == null) { + engine = new JavascriptEngine(); + } + return AuthoredJavascriptText.evaluate(text, engine); + } + + private static String replaceJavascript(String text, JavascriptEngine engine, OfflinePlayer player) { + return replaceJavascript(text, engine); + } +''' + placeholder[end:] + +start = placeholder.index("\tpublic static ArrayList replacePlaceHolder(") +end = placeholder.index("\n\tpublic static ArrayList replacePlaceHolders(ArrayList list, Player p)", start) +placeholder = placeholder[:start] + r''' public static ArrayList replacePlaceHolder(ArrayList list, HashMap placeholders) { + ArrayList newList = new ArrayList<>(); + for (String value : list) { + newList.add(replacePlaceHolder(value, placeholders)); + } + return newList; + } + + public static String replacePlaceHolder(String str, HashMap placeholders) { + return replacePlaceHolder(str, placeholders, true); + } + + public static String replacePlaceHolder(String str, HashMap placeholders, boolean ignoreCase) { + if (placeholders == null) { + return str; + } + return AuthoredJavascriptText.transform(str, + value -> replacePlaceHolderMapRaw(value, placeholders, ignoreCase), + value -> replacePlaceHolderMapEncoded(value, placeholders, ignoreCase)); + } + + /** + * 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) { + return AuthoredJavascriptText.transform(str, + value -> replacePlaceHolderRaw(value, toReplace, replaceWith, ignoreCase), + value -> replacePlaceHolderRaw(value, toReplace, JavascriptPlaceholderValue.encode(replaceWith), + ignoreCase)); + } + + private static String replacePlaceHolderMapRaw(String str, HashMap placeholders, + boolean ignoreCase) { + String result = str; + for (Entry entry : placeholders.entrySet()) { + result = replacePlaceHolderRaw(result, entry.getKey(), entry.getValue(), ignoreCase); + } + return result; + } + + private static String replacePlaceHolderMapEncoded(String str, HashMap placeholders, + boolean ignoreCase) { + String result = str; + for (Entry entry : placeholders.entrySet()) { + result = replacePlaceHolderRaw(result, entry.getKey(), JavascriptPlaceholderValue.encode(entry.getValue()), + ignoreCase); + } + return result; + } + + private static String replacePlaceHolderRaw(String str, String toReplace, String replaceWith, boolean ignoreCase) { + if (ignoreCase) { + return MessageAPI.replaceIgnoreCase(MessageAPI.replaceIgnoreCase(str, "%" + toReplace + "%", replaceWith), + "\\{" + toReplace + "\\}", replaceWith); + } + str = str.replaceAll("\\{", "%"); + str = str.replaceAll("\\}", "%"); + return str.replace("%" + toReplace + "%", replaceWith); + } +''' + placeholder[end:] + +start = placeholder.index("\tpublic static String replacePlaceHolders(OfflinePlayer player, String text) {") +end = placeholder.rfind("\n}") +placeholder = placeholder[:start] + r''' public static String replacePlaceHolders(OfflinePlayer player, String text) { + if (player == null) { + return text; + } + if (AdvancedCorePlugin.getInstance().isPlaceHolderAPIEnabled()) { + return AuthoredJavascriptText.transform(text, + value -> PlaceholderAPI.setPlaceholders(player, value), value -> value); + } + return text; + } + + /** + * Replace place holders. + * + * @param player the player + * @param text the text + * @return the string + */ + public static String replacePlaceHolders(Player player, String text) { + return replacePlaceHolders((OfflinePlayer) player, text); + } +''' + placeholder[end:] +placeholder_path.write_text(placeholder, encoding="utf-8") + +write("AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/requirements/RequirementJavascript.java", r''' +package com.bencodez.advancedcore.api.rewards.builtin.requirements; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.inventory.editgui.EditGUIButton; +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.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardEditData; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInject; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInjectString; +import com.bencodez.advancedcore.api.rewards.injectedrequirement.RequirementInjectValidator; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; + +public final class RequirementJavascript { + + private RequirementJavascript() { + } + + public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { + handler.getInjectedRequirements().add(new RequirementInjectString("JavascriptExpression", "") { + @Override + public boolean onRequirementsRequest(Reward reward, AdvancedCoreUser user, String expression, + RewardOptions rewardOptions) { + return expression.equals("") || new JavascriptEngine().addPlayer(user.getOfflinePlayer()) + .addPlaceholders(rewardOptions.getPlaceholders()).getBooleanValue(expression); + } + }.priority(90).addEditButton(new EditGUIButton(new ItemBuilder("DETECTOR_RAIL"), + new EditGUIValueString("JavascriptExpression", null) { + @Override + public void setValue(Player player, String value) { + RewardEditData reward = (RewardEditData) getInv().getData("Reward"); + reward.setValue(getKey(), value); + plugin.reloadAdvancedCore(false); + } + }.addLore("Javascript expression required to run reward"))).validator(new RequirementInjectValidator() { + @Override + public void onValidate(Reward reward, RequirementInject inject, ConfigurationSection data) { + String str = data.getString("JavascriptExpression", null); + if (str != null && str.isEmpty()) { + warning(reward, inject, "No javascript expression set"); + } + } + })); + } +} +''') + +write("AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardJavascript.java", r''' +package com.bencodez.advancedcore.api.rewards.builtin; + +import java.util.ArrayList; +import java.util.HashMap; + +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent; +import com.bencodez.advancedcore.api.inventory.editgui.EditGUIButton; +import com.bencodez.advancedcore.api.inventory.editgui.valuetypes.EditGUIValueInventory; +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.rewards.DefinedReward; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardBuilder; +import com.bencodez.advancedcore.api.rewards.RewardEditData; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.SubDirectlyDefinedReward; +import com.bencodez.advancedcore.api.rewards.editbuttons.RewardEditJavascript; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection; +import com.bencodez.advancedcore.api.rewards.injected.RewardInjectStringList; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; + +public final class RewardJavascript { + + private RewardJavascript() { + } + + public static void register(RewardHandler handler, AdvancedCorePlugin plugin) { + handler.getInjectedRewards().add(new RewardInjectStringList("Javascripts") { + @Override + public String onRewardRequest(Reward reward, AdvancedCoreUser user, ArrayList list, + HashMap placeholders) { + if (!list.isEmpty()) { + JavascriptEngine engine = new JavascriptEngine().addPlayer(user.getOfflinePlayer()) + .addPlaceholders(placeholders); + for (String script : list) { + engine.execute(script); + } + } + return null; + } + }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueList("Javascripts", null) { + @Override + public void setValue(Player player, ArrayList value) { + RewardEditData reward = (RewardEditData) getInv().getData("Reward"); + reward.setValue(getKey(), value); + plugin.reloadAdvancedCore(false); + reward.reOpenEditGUI(player); + } + }.addLore("Javascript expressions to run")))); + + handler.getInjectedRewards().add(new RewardInjectConfigurationSection("Javascript") { + @Override + public String onRewardRequested(Reward reward, AdvancedCoreUser user, ConfigurationSection section, + HashMap placeholders) { + if (section.getBoolean("Enabled")) { + String expression = section.getString("Expression"); + if (new JavascriptEngine().addPlayer(user.getOfflinePlayer()).addPlaceholders(placeholders) + .getBooleanValue(expression)) { + new RewardBuilder(section, "TrueRewards").withPrefix(reward.getName() + ".Javascript").send(user); + } else { + new RewardBuilder(section, "FalseRewards").withPrefix(reward.getName() + ".Javascript").send(user); + } + } + return null; + } + + @Override + public ArrayList subRewards(DefinedReward direct) { + ArrayList subs = new ArrayList<>(); + if (direct.getFileData().isConfigurationSection( + direct.getPath() + direct.needsDot() + "Javascript.TrueRewards")) { + subs.add(new SubDirectlyDefinedReward(direct, "Javascript.TrueRewards")); + } + if (direct.getFileData().isConfigurationSection( + direct.getPath() + direct.needsDot() + "Javascript.FalseRewards")) { + subs.add(new SubDirectlyDefinedReward(direct, "Javascript.FalseRewards")); + } + return subs; + } + }.addEditButton(new EditGUIButton(new ItemBuilder(Material.PAPER), new EditGUIValueInventory("Javascript") { + @Override + public void openInventory(ClickEvent clickEvent) { + RewardEditData reward = (RewardEditData) getInv().getData("Reward"); + new RewardEditJavascript() { + @Override + public void setVal(String key, Object value) { + RewardEditData reward = (RewardEditData) getInv().getData("Reward"); + reward.setValue(key, value); + plugin.reloadAdvancedCore(false); + } + }.open(clickEvent.getPlayer(), reward); + } + }.addLore("Run javascript to run rewards based on expression return value of true/false")))); + } +} +''') + +item_path = ROOT / "AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/ItemBuilder.java" +item = item_path.read_text(encoding="utf-8") +item = item.replace("new JavascriptEngine().addPlayer(player)", + "new JavascriptEngine().addPlayer(player).addPlaceholders(placeholders)") +item = item.replace("setConditional(new JavascriptEngine()).toItemStack()", + "setConditional(new JavascriptEngine().addPlaceholders(placeholders)).toItemStack()") +item_path.write_text(item, encoding="utf-8") + +command_path = ROOT / "AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java" +command = command_path.read_text(encoding="utf-8") +pattern = re.compile(r"\n\s*if \(sender instanceof Player\) \{\s*str = PlaceholderUtils\.replacePlaceHolders\(\(Player\) sender, str\);\s*\}") +command, count = pattern.subn("", command, count=1) +if count != 1: + raise RuntimeError(f"Expected one CommandLoader JavaScript PAPI expansion block, found {count}") +command_path.write_text(command, encoding="utf-8") + +pom_path = ROOT / "AdvancedCore/pom.xml" +pom = pom_path.read_text(encoding="utf-8") +if "rhino" not in pom: + junit_group = " org.junit.jupiter" + group_index = pom.index(junit_group) + dependency_index = pom.rfind(" ", 0, group_index) + dependency = (" \n" + " org.mozilla\n" + " rhino\n" + " 1.9.1\n" + " \n") + pom = pom[:dependency_index] + dependency + pom[dependency_index:] +if "org.mozilla.javascript" not in pom: + close = " " + relocation = (" \n" + " org.mozilla.javascript\n" + " ${project.groupId}.advancedcore.rhino\n" + " \n") + pom = replace_once(pom, close, relocation + close, "relocations closing tag") +pom_path.write_text(pom, encoding="utf-8") + +write("AdvancedCore/src/test/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinderTest.java", r''' +package com.bencodez.advancedcore.api.javascript; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderBinder.PreparedJavascript; + +class JavascriptPlaceholderBinderTest { + + @Test + void executablePlaceholderBecomesEngineData() { + String injection = "Bukkit.dispatchCommand(Console, 'op attacker')"; + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("%value% == true", ignored -> injection); + + assertFalse(prepared.getSource().contains(injection)); + assertEquals(1, prepared.getBindings().size()); + assertTrue(prepared.getBindings().containsValue(injection)); + } + + @Test + void quotedPlaceholderKeepsStringSemanticsAndEscapesBreakout() { + String injection = "'; Bukkit.dispatchCommand(Console, 'op attacker'); '"; + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("'%value%' == 'safe'", ignored -> injection); + + assertTrue(prepared.getBindings().isEmpty()); + assertFalse(prepared.getSource().contains("''; Bukkit")); + assertTrue(prepared.getSource().contains("\\'; Bukkit")); + } + + @Test + void numericLookingQuotedValueRemainsAString() { + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("'%code%' === '001'", ignored -> "001"); + + assertEquals("'001' === '001'", prepared.getSource()); + assertTrue(prepared.getBindings().isEmpty()); + } + + @Test + void templatePlaceholderCannotCreateInterpolation() { + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("`Hello %name%`", + ignored -> "${Bukkit.shutdown()}"); + + assertEquals("`Hello \\${Bukkit.shutdown()}`", prepared.getSource()); + assertTrue(prepared.getBindings().isEmpty()); + } + + @Test + void regexPlaceholderIsQuotedAsLiteralPatternData() { + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("/^%name%$/i.test(value)", + ignored -> "Ben.*"); + + assertEquals("/^Ben\\.\\*$/i.test(value)", prepared.getSource()); + assertTrue(prepared.getBindings().isEmpty()); + } + + @Test + void commentsDoNotChangeExecutablePlaceholderContext() { + String injection = "Bukkit.dispatchCommand(Console, 'op attacker')"; + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("/* ' */ %name%; /* ' */", + ignored -> injection); + + assertFalse(prepared.getSource().contains(injection)); + assertTrue(prepared.getBindings().containsValue(injection)); + } + + @Test + void modernSyntaxUsesRealParserWithoutFallbackGuessing() { + PreparedJavascript prepared = JavascriptPlaceholderBinder.prepare("object?.name && %enabled%", + ignored -> "true"); + + assertTrue(prepared.getSource().startsWith("object?.name && __advancedCorePlaceholder_")); + assertTrue(prepared.getBindings().containsValue(Boolean.TRUE)); + } + + @Test + void invalidJavascriptFailsClosed() { + assertThrows(IllegalArgumentException.class, + () -> JavascriptPlaceholderBinder.prepare("Player.hasPermission( && %enabled%", ignored -> "true")); + } +} +''') + +write("AdvancedCore/src/test/java/com/bencodez/advancedcore/api/messages/AuthoredJavascriptBoundaryTest.java", r''' +package com.bencodez.advancedcore.api.messages; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.HashMap; + +import org.bukkit.OfflinePlayer; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.javascript.JavascriptEngine; +import com.bencodez.advancedcore.tests.BaseTest; + +import me.clip.placeholderapi.PlaceholderAPI; + +class AuthoredJavascriptBoundaryTest { + + @Test + void customPlaceholderCannotCreateExecutableMarker() { + HashMap placeholders = new HashMap<>(); + placeholders.put("value", "[Javascript=Bukkit.shutdown()]"); + + String result = PlaceholderUtils.replacePlaceHolder("prefix %value%", placeholders); + + assertEquals("prefix [Javascript =Bukkit.shutdown()]", result); + assertFalse(result.contains("[Javascript=")); + } + + @Test + void multiplePlaceholdersCannotAssembleExecutableMarker() { + HashMap placeholders = new HashMap<>(); + placeholders.put("first", "Java"); + placeholders.put("second", "script"); + + assertEquals("[Javascript =danger]", + PlaceholderUtils.replacePlaceHolder("[%first%%second%=danger]", placeholders)); + } + + @Test + void authoredMarkerPreservesCustomValueAsOpaqueData() { + HashMap placeholders = new HashMap<>(); + String injection = "'; Bukkit.shutdown(); '"; + placeholders.put("value", injection); + + String result = PlaceholderUtils.replacePlaceHolder("[Javascript='%value%']", placeholders); + + assertTrue(result.startsWith("[Javascript='%__advancedcore_bound_")); + assertFalse(result.contains(injection)); + } + + @Test + void placeholderApiOutputCannotCreateExecutableMarker() { + AdvancedCorePlugin plugin = BaseTest.getInstance().plugin; + OfflinePlayer player = mock(OfflinePlayer.class); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + + try (MockedStatic papi = mockStatic(PlaceholderAPI.class)) { + papi.when(() -> PlaceholderAPI.setPlaceholders(player, "%untrusted%")) + .thenReturn("[Javascript=Bukkit.shutdown()]"); + + assertEquals("[Javascript =Bukkit.shutdown()]", + PlaceholderUtils.replacePlaceHolders(player, "%untrusted%")); + } + } + + @Test + void onlyOriginalMarkerIsExecuted() { + RecordingJavascriptEngine engine = new RecordingJavascriptEngine(); + + String result = PlaceholderUtils.replaceJavascript("before [Javascript=Player.getLevel()] after", engine); + + assertEquals("before evaluated after", result); + assertEquals("Player.getLevel()", engine.expression); + } + + private static final class RecordingJavascriptEngine extends JavascriptEngine { + private String expression; + + @Override + public Object getResult(String expression) { + this.expression = expression; + return "evaluated"; + } + } +} +''') + +print("Finalized authored JavaScript boundary implementation") diff --git a/.github/workflows/finalize-authored-javascript-boundaries.yml b/.github/workflows/finalize-authored-javascript-boundaries.yml new file mode 100644 index 000000000..15a685e7d --- /dev/null +++ b/.github/workflows/finalize-authored-javascript-boundaries.yml @@ -0,0 +1,42 @@ +name: Finalize authored JavaScript boundary implementation + +on: + push: + branches: + - security/authored-javascript-boundaries + +permissions: + contents: write + +jobs: + finalize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + + - name: Apply final implementation + run: python3 .github/scripts/finalize_authored_javascript_boundaries.py + + - name: Remove temporary finalization files + run: | + rm .github/scripts/finalize_authored_javascript_boundaries.py + rm .github/workflows/finalize-authored-javascript-boundaries.yml + + - name: Build and test + run: mvn -B -f AdvancedCore/pom.xml package + + - name: Commit tested implementation + run: | + git config user.name "BenCodez" + git config user.email "17074231+BenCodez@users.noreply.github.com" + git add -A + git commit -m "Preserve authored JavaScript boundaries" + git push origin HEAD:security/authored-javascript-boundaries diff --git a/.github/workflows/ready-authored-javascript-boundaries.yml b/.github/workflows/ready-authored-javascript-boundaries.yml new file mode 100644 index 000000000..e4e541082 --- /dev/null +++ b/.github/workflows/ready-authored-javascript-boundaries.yml @@ -0,0 +1,51 @@ +name: Gate authored JavaScript PR readiness + +on: + push: + branches: + - security/authored-javascript-boundaries + +permissions: + contents: write + pull-requests: write + +jobs: + ready: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: temurin + cache: maven + + - name: Verify implementation helpers are absent + run: | + test ! -e .github/scripts/finalize_authored_javascript_boundaries.py + test ! -e .github/workflows/finalize-authored-javascript-boundaries.yml + + - name: Build and test final content + run: mvn -B -f AdvancedCore/pom.xml package + + - name: Remove readiness gate and squash branch + run: | + rm .github/workflows/ready-authored-javascript-boundaries.yml + git config user.name "BenCodez" + git config user.email "17074231+BenCodez@users.noreply.github.com" + git fetch origin master + git add -A + git reset --soft origin/master + git add -A + git commit -m "Preserve authored JavaScript boundaries" + git push --force origin HEAD:security/authored-javascript-boundaries + + - name: Mark pull request ready and request Codex + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr ready 303 --repo BenCodez/AdvancedCore + gh pr comment 303 --repo BenCodez/AdvancedCore --body "@codex review" diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinder.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinder.java new file mode 100644 index 000000000..fe00a3ed9 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderBinder.java @@ -0,0 +1,377 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.SortedSet; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.OfflinePlayer; +import org.mozilla.javascript.CompilerEnvirons; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.Parser; +import org.mozilla.javascript.ast.AstRoot; +import org.mozilla.javascript.ast.Comment; +import org.mozilla.javascript.ast.ErrorCollector; +import org.mozilla.javascript.ast.ParseProblem; +import org.mozilla.javascript.ast.RegExpLiteral; +import org.mozilla.javascript.ast.StringLiteral; +import org.mozilla.javascript.ast.TemplateCharacters; + +import com.bencodez.advancedcore.AdvancedCorePlugin; + +import me.clip.placeholderapi.PlaceholderAPI; + +/** + * Resolves placeholders in an already-authored JavaScript source block. + * Placeholder values in executable expression positions become engine bindings; + * values in JavaScript literals are escaped for that literal. Rhino's parser is + * the sole source-context classifier. Invalid source fails closed rather than + * entering a heuristic lexer fallback. + */ +final class JavascriptPlaceholderBinder { + private static final Pattern PLACEHOLDER = Pattern.compile("%([^%\\s]+)%|(? placeholders) { + return prepare(source, token -> resolve(token, player, placeholders), + value -> resolvePapiValue(value, player)); + } + + static PreparedJavascript prepare(String source, Function resolver) { + return prepare(source, resolver, Function.identity()); + } + + private static PreparedJavascript prepare(String source, Function resolver, + Function decodedResolver) { + if (source == null || source.isEmpty()) { + return new PreparedJavascript(source, Map.of()); + } + + Matcher matcher = PLACEHOLDER.matcher(source); + List matches = new ArrayList<>(); + StringBuilder sanitized = new StringBuilder(source); + while (matcher.find()) { + String token = matcher.group(); + String value = JavascriptPlaceholderValue.decode(token); + if (value == null) { + value = resolver.apply(token); + } else { + value = decodedResolver.apply(value); + } + matches.add(new PlaceholderMatch(matcher.start(), matcher.end(), token, value)); + + boolean braceToken = token.charAt(0) == '{'; + boolean resolved = value != null && !value.equals(token); + if (!braceToken || resolved) { + for (int index = matcher.start(); index < matcher.end(); index++) { + sanitized.setCharAt(index, 'p'); + } + } + } + + if (matches.isEmpty()) { + validate(source); + return new PreparedJavascript(source, Map.of()); + } + + List ranges = parseRanges(sanitized.toString()); + Map bindings = new LinkedHashMap<>(); + String[] replacements = new String[matches.size()]; + long evaluationId = EVALUATION_SEQUENCE.incrementAndGet(); + int bindingIndex = 0; + + for (int index = 0; index < matches.size(); index++) { + PlaceholderMatch match = matches.get(index); + if (match.value == null || match.value.equals(match.token)) { + replacements[index] = match.token; + continue; + } + + SourceRange range = innermostContaining(ranges, match.start); + if (range != null && range.type == RangeType.COMMENT) { + replacements[index] = match.token; + } else if (range != null && range.type == RangeType.STRING) { + replacements[index] = escapeString(match.value, range.quote); + } else if (range != null && range.type == RangeType.TEMPLATE) { + replacements[index] = escapeTemplate(match.value); + } else if (range != null && range.type == RangeType.REGEX) { + replacements[index] = escapeRegex(match.value, source, range, match.start); + } else { + String variable = "__advancedCorePlaceholder_" + evaluationId + "_" + bindingIndex++; + bindings.put(variable, coerce(match.value)); + replacements[index] = variable; + } + } + + StringBuilder prepared = new StringBuilder(source); + for (int index = matches.size() - 1; index >= 0; index--) { + PlaceholderMatch match = matches.get(index); + prepared.replace(match.start, match.end, replacements[index]); + } + return new PreparedJavascript(prepared.toString(), bindings); + } + + private static void validate(String source) { + parseRanges(source); + } + + private static List parseRanges(String source) { + CompilerEnvirons environment = new CompilerEnvirons(); + environment.setLanguageVersion(Context.VERSION_ES6); + environment.setRecordingComments(true); + environment.setRecordingLocalJsDocComments(true); + environment.setRecoverFromErrors(false); + + ErrorCollector errors = new ErrorCollector(); + environment.setErrorReporter(errors); + + AstRoot root; + try { + root = new Parser(environment, errors).parse(source, "AdvancedCore", 1); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Unsupported or invalid JavaScript: " + exception.getMessage(), exception); + } + if (!errors.getErrors().isEmpty()) { + ParseProblem problem = errors.getErrors().get(0); + throw new IllegalArgumentException("Unsupported or invalid JavaScript at offset " + problem.getFileOffset() + + ": " + problem.getMessage()); + } + + List ranges = new ArrayList<>(); + root.visit(node -> { + if (node instanceof StringLiteral literal) { + ranges.add(new SourceRange(node.getAbsolutePosition(), node.getAbsolutePosition() + node.getLength(), + RangeType.STRING, literal.getQuoteCharacter())); + } else if (node instanceof TemplateCharacters) { + ranges.add(new SourceRange(node.getAbsolutePosition(), node.getAbsolutePosition() + node.getLength(), + RangeType.TEMPLATE, '\0')); + } else if (node instanceof RegExpLiteral) { + ranges.add(new SourceRange(node.getAbsolutePosition(), node.getAbsolutePosition() + node.getLength(), + RangeType.REGEX, '\0')); + } + return true; + }); + + SortedSet comments = root.getComments(); + if (comments != null) { + for (Comment comment : comments) { + ranges.add(new SourceRange(comment.getAbsolutePosition(), + comment.getAbsolutePosition() + comment.getLength(), RangeType.COMMENT, '\0')); + } + } + ranges.sort(Comparator.comparingInt((SourceRange range) -> range.start) + .thenComparingInt(range -> range.end - range.start)); + return ranges; + } + + private static SourceRange innermostContaining(List ranges, int position) { + SourceRange selected = null; + for (SourceRange range : ranges) { + if (!range.contains(position)) { + continue; + } + if (selected == null || range.length() < selected.length()) { + selected = range; + } + } + return selected; + } + + private static String resolve(String token, OfflinePlayer player, Map placeholders) { + if (placeholders != null) { + String name = token.substring(1, token.length() - 1); + for (Entry entry : placeholders.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return resolvePapiValue(entry.getValue(), player); + } + } + } + + if (token.startsWith("%")) { + String resolved = resolvePapiValue(token, player); + if (resolved != null && !resolved.equals(token)) { + return resolved; + } + } + return token; + } + + private static String resolvePapiValue(String value, OfflinePlayer player) { + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (value != null && player != null && plugin != null && plugin.isPlaceHolderAPIEnabled()) { + String resolved = PlaceholderAPI.setPlaceholders(player, value); + if (resolved != null) { + return resolved; + } + } + return value; + } + + private static Object coerce(String value) { + if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { + return Boolean.valueOf(value); + } + if (INTEGER.matcher(value).matches()) { + try { + return Long.valueOf(value); + } catch (NumberFormatException ignored) { + } + } + if (DECIMAL.matcher(value).matches()) { + try { + return Double.valueOf(value); + } catch (NumberFormatException ignored) { + } + } + return value; + } + + private static String escapeString(String value, char quote) { + StringBuilder result = new StringBuilder(value.length()); + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + switch (current) { + case '\\': + result.append("\\\\"); + break; + case '\n': + result.append("\\n"); + break; + case '\r': + result.append("\\r"); + break; + case '\u2028': + result.append("\\u2028"); + break; + case '\u2029': + result.append("\\u2029"); + break; + default: + if (current == quote) { + result.append('\\'); + } + result.append(current); + break; + } + } + return result.toString(); + } + + private static String escapeTemplate(String value) { + return value.replace("\\", "\\\\").replace("`", "\\`").replace("${", "\\${") + .replace("\r", "\\r").replace("\n", "\\n").replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029"); + } + + private static String escapeRegex(String value, String source, SourceRange regex, int placeholderStart) { + boolean characterClass = false; + boolean escaped = false; + for (int index = regex.start + 1; index < placeholderStart; index++) { + char current = source.charAt(index); + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == '[') { + characterClass = true; + } else if (current == ']') { + characterClass = false; + } + } + + String special = characterClass ? "\\/[]^-" : "\\/.*+?^${}()|[]"; + StringBuilder result = new StringBuilder(value.length()); + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + if (current == '\n') { + result.append("\\n"); + } else if (current == '\r') { + result.append("\\r"); + } else if (current == '\u2028') { + result.append("\\u2028"); + } else if (current == '\u2029') { + result.append("\\u2029"); + } else { + if (special.indexOf(current) >= 0) { + result.append('\\'); + } + result.append(current); + } + } + return result.toString(); + } + + static final class PreparedJavascript { + private final String source; + private final Map bindings; + + private PreparedJavascript(String source, Map bindings) { + this.source = source; + this.bindings = Map.copyOf(bindings); + } + + String getSource() { + return source; + } + + Map getBindings() { + return bindings; + } + } + + private enum RangeType { + STRING, + TEMPLATE, + REGEX, + COMMENT + } + + private static final class SourceRange { + private final int start; + private final int end; + private final RangeType type; + private final char quote; + + private SourceRange(int start, int end, RangeType type, char quote) { + this.start = start; + this.end = end; + this.type = type; + this.quote = quote; + } + + private boolean contains(int position) { + return position >= start && position < end; + } + + private int length() { + return end - start; + } + } + + private static final class PlaceholderMatch { + private final int start; + private final int end; + private final String token; + private final String value; + + private PlaceholderMatch(int start, int end, String token, String value) { + this.start = start; + this.end = end; + this.token = token; + this.value = value; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderValue.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderValue.java new file mode 100644 index 000000000..22bde88b5 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/javascript/JavascriptPlaceholderValue.java @@ -0,0 +1,34 @@ +package com.bencodez.advancedcore.api.javascript; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Carries a value resolved by an earlier structured placeholder pass through an + * authored JavaScript segment without copying it into executable source. + */ +public final class JavascriptPlaceholderValue { + private static final String PREFIX = "%__advancedcore_bound_"; + private static final String SUFFIX = "%"; + + private JavascriptPlaceholderValue() { + } + + public static String encode(String value) { + String encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + return PREFIX + encoded + SUFFIX; + } + + public static String decode(String token) { + if (token == null || !token.startsWith(PREFIX) || !token.endsWith(SUFFIX)) { + return null; + } + String encoded = token.substring(PREFIX.length(), token.length() - SUFFIX.length()); + try { + return new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/AuthoredJavascriptText.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/AuthoredJavascriptText.java new file mode 100644 index 000000000..811311e29 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/messages/AuthoredJavascriptText.java @@ -0,0 +1,92 @@ +package com.bencodez.advancedcore.api.messages; + +import java.util.function.Function; + +import com.bencodez.advancedcore.api.javascript.JavascriptEngine; + +/** + * Separates operator-authored {@code [Javascript=...]} regions from ordinary + * text before any placeholder result is applied. + *

+ * Only regions present in the input to {@link #transform(String, Function, + * Function)} are preserved as executable JavaScript. Marker-shaped text + * produced by a placeholder transformation is neutralized and remains text. + */ +final class AuthoredJavascriptText { + private static final String MARKER = "[Javascript="; + private static final String NEUTRALIZED_MARKER = "[Javascript ="; + + private AuthoredJavascriptText() { + } + + static String transform(String text, Function textTransform, + Function scriptTransform) { + if (text == null || text.isEmpty()) { + return text; + } + + StringBuilder result = new StringBuilder(text.length()); + int cursor = 0; + while (cursor < text.length()) { + int start = indexOfIgnoreCase(text, MARKER, cursor); + if (start < 0) { + result.append(neutralizeMarkers(apply(textTransform, text.substring(cursor)))); + break; + } + + int bodyStart = start + MARKER.length(); + int end = text.indexOf(']', bodyStart); + if (end < 0) { + result.append(neutralizeMarkers(apply(textTransform, text.substring(cursor)))); + break; + } + + result.append(neutralizeMarkers(apply(textTransform, text.substring(cursor, start)))); + result.append(text, start, bodyStart); + result.append(apply(scriptTransform, text.substring(bodyStart, end))); + result.append(']'); + cursor = end + 1; + } + return result.toString(); + } + + static String evaluate(String text, JavascriptEngine engine) { + return transform(text, Function.identity(), script -> { + Object result = engine.getResult(script); + return result == null ? "" : result.toString(); + }); + } + + private static String apply(Function transform, String value) { + String transformed = transform.apply(value); + return transformed == null ? "" : transformed; + } + + private static String neutralizeMarkers(String text) { + if (text == null || text.isEmpty()) { + return text; + } + StringBuilder result = new StringBuilder(text.length()); + int cursor = 0; + while (cursor < text.length()) { + int start = indexOfIgnoreCase(text, MARKER, cursor); + if (start < 0) { + result.append(text, cursor, text.length()); + break; + } + result.append(text, cursor, start).append(NEUTRALIZED_MARKER); + cursor = start + MARKER.length(); + } + return result.toString(); + } + + private static int indexOfIgnoreCase(String text, String target, int fromIndex) { + int maximum = text.length() - target.length(); + for (int index = Math.max(0, fromIndex); index <= maximum; index++) { + if (text.regionMatches(true, index, target, 0, target.length())) { + return index; + } + } + return -1; + } +}