diff --git a/CHANGELOG.md b/CHANGELOG.md index 12865c1..f30d1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## 1.5.0 - 2026-07-24 +## 1.6.0 - 2026-07-25 + +### Added +- **Commands from the page.** A page can now run a Minecraft command, executed **as the player** — exactly as if they typed it in chat, so there is no privilege escalation. Commands are only accepted from the main frame of an origin the server declared trusted via `trustedCommandOrigins` in `config/webgui/server.json`; requests from any other origin (e.g. after a redirect or from an iframe) are dropped. The trusted-origin list is sent to the client on join and cleared on disconnect, so it never carries across servers. +- `@webgui/react`: `runCommand(command)` and the `useRunCommand()` hook. ### Added - `window.webgui.client` now includes more player data: `health`, `maxHealth`, `food`, `xpLevel`, `gamemode`, and a `look` object with `yaw`/`pitch`. diff --git a/gradle.properties b/gradle.properties index 6fe870c..11e6c56 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx4G org.gradle.parallel=true dev.kikugie.stonecutter.hard_mode=true -mod_version=1.5.0 +mod_version=1.6.0 maven_group=land.webgui archives_base_name=webgui diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index 1398d46..8b19203 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -54,6 +54,10 @@ public void onInitializeClient() { ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewEntityContextS2CPayload.ID, (payload, context) -> { context.client().execute(() -> WebviewClientBridge.setEntityContext(payload.entityJson())); }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, (payload, context) -> { + context.client().execute(() -> WebGUITrustedOrigins.set(payload.origins())); + }); //? } else { /*ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OPEN_WEB_CHANNEL, (client, handler, buf, responseSender) -> { int protocolVersion = buf.readVarInt(); @@ -74,6 +78,11 @@ public void onInitializeClient() { String eventName = buf.readString(WebviewPayloads.MAX_EVENT_NAME_LENGTH); String jsonPayload = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); client.execute(() -> WebviewClientEmit.dispatch(eventName, jsonPayload)); + }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.TRUSTED_ORIGINS_CHANNEL, (client, handler, buf, responseSender) -> { + String origins = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); + client.execute(() -> WebGUITrustedOrigins.set(origins)); });*/ //? } @@ -95,6 +104,7 @@ private static void onLeaveWorld() { WebHudOverlay.reset(); WebSession.dispose(); WebGUIMainMenuUrl.setUrl(""); + WebGUITrustedOrigins.clear(); } private static void handleOpenPayload(net.minecraft.client.MinecraftClient client, int mode, String url) { @@ -142,6 +152,7 @@ private static void onLeaveWorld() { WebHudOverlay.reset(); WebSession.dispose(); WebGUIMainMenuUrl.setUrl(""); + WebGUITrustedOrigins.clear(); } // Called only on the client (from WebviewNetworking.registerPayloadTypes) so @@ -159,6 +170,8 @@ public static void registerClientReceivers(RegisterPayloadHandlersEvent event) { (payload, ctx) -> ctx.enqueueWork(() -> WebviewClientEmit.dispatch(payload.eventName(), payload.jsonPayload()))); reg.playToClient(WebviewPayloads.WebviewEntityContextS2CPayload.TYPE, WebviewPayloads.WebviewEntityContextS2CPayload.STREAM_CODEC, (payload, ctx) -> ctx.enqueueWork(() -> WebviewClientBridge.setEntityContext(payload.entityJson()))); + reg.playToClient(WebviewPayloads.WebviewTrustedOriginsS2CPayload.TYPE, WebviewPayloads.WebviewTrustedOriginsS2CPayload.STREAM_CODEC, + (payload, ctx) -> ctx.enqueueWork(() -> WebGUITrustedOrigins.set(payload.origins()))); } private static void onClientTick(ClientTickEvent.Post event) { diff --git a/src/main/java/land/webgui/WebGUITrustedOrigins.java b/src/main/java/land/webgui/WebGUITrustedOrigins.java new file mode 100644 index 0000000..8b3bce6 --- /dev/null +++ b/src/main/java/land/webgui/WebGUITrustedOrigins.java @@ -0,0 +1,67 @@ +package land.webgui; + +import java.net.URI; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Client-side registry of origins ({@code scheme://host[:port]}) whose pages may run commands as + * the player. Populated from the server on join and cleared on disconnect, so trust never carries + * across servers. A page whose origin is not listed cannot trigger command execution. + */ +public final class WebGUITrustedOrigins { + private static volatile Set origins = Collections.emptySet(); + + private WebGUITrustedOrigins() {} + + /** Replaces the trusted set from a newline-joined list sent by the server. */ + public static void set(String joined) { + Set next = new HashSet<>(); + if (joined != null) { + for (String line : joined.split("\n")) { + String o = normalize(line); + if (o != null) next.add(o); + } + } + origins = next; + } + + public static void clear() { + origins = Collections.emptySet(); + } + + /** True if the given page URL's origin is trusted for command execution. */ + public static boolean isTrusted(String url) { + String o = normalize(url); + return o != null && origins.contains(o); + } + + /** Reduces a URL to {@code scheme://host[:port]} (default ports dropped), lowercased; null if unusable. */ + static String normalize(String url) { + if (url == null) return null; + String s = url.trim(); + if (s.isEmpty()) return null; + try { + URI u = URI.create(s); + String scheme = u.getScheme(); + String host = u.getHost(); + if (scheme == null || host == null) return null; + scheme = scheme.toLowerCase(Locale.ROOT); + host = host.toLowerCase(Locale.ROOT); + String origin = scheme + "://" + host; + int port = u.getPort(); + if (port != -1 && !isDefaultPort(scheme, port)) { + origin = origin + ":" + port; + } + return origin; + } catch (RuntimeException e) { + return null; + } + } + + private static boolean isDefaultPort(String scheme, int port) { + return ("https".equals(scheme) && port == 443) || ("http".equals(scheme) && port == 80); + } +} diff --git a/src/main/java/land/webgui/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index ef53d5c..d414ca9 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -23,6 +23,8 @@ public static void register() { return; } + WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); if (!mainMenuUrl.isEmpty()) { WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); @@ -49,6 +51,8 @@ public static void register() { private static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { ServerPlayer player = (ServerPlayer) event.getEntity(); + WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); if (!mainMenuUrl.isEmpty()) { WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); diff --git a/src/main/java/land/webgui/WebviewNetworking.java b/src/main/java/land/webgui/WebviewNetworking.java index 54376da..9ad8840 100644 --- a/src/main/java/land/webgui/WebviewNetworking.java +++ b/src/main/java/land/webgui/WebviewNetworking.java @@ -36,6 +36,7 @@ public static void registerPayloadTypes() { PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebUIMainMenuPayload.ID, WebviewPayloads.WebUIMainMenuPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewEmitS2CPayload.ID, WebviewPayloads.WebviewEmitS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewEntityContextS2CPayload.ID, WebviewPayloads.WebviewEntityContextS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, WebviewPayloads.WebviewTrustedOriginsS2CPayload.CODEC); PayloadTypeRegistry.playC2S().register(WebviewPayloads.WebviewPageEventC2SPayload.ID, WebviewPayloads.WebviewPageEventC2SPayload.CODEC); //? } } @@ -68,6 +69,8 @@ public static void registerPayloadTypes() { WebviewPayloads.WebviewEmitS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); reg.playToClient(WebviewPayloads.WebviewEntityContextS2CPayload.TYPE, WebviewPayloads.WebviewEntityContextS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); + reg.playToClient(WebviewPayloads.WebviewTrustedOriginsS2CPayload.TYPE, + WebviewPayloads.WebviewTrustedOriginsS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); } }); }*/ @@ -172,6 +175,17 @@ public static void sendMainMenuUrl(ServerPlayerEntity player, String url) { //? } } + public static void sendTrustedOrigins(ServerPlayerEntity player, String origins) { + String o = origins == null ? "" : origins; + //? if >=1.20.5 { + ServerPlayNetworking.send(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(o)); + //? } else { + /*PacketByteBuf buf = PacketByteBufs.create(); + buf.writeString(o, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + ServerPlayNetworking.send(player, WebviewPayloads.TRUSTED_ORIGINS_CHANNEL, buf);*/ + //? } + } + private static String withPlayerToken(ServerPlayerEntity player, String url) { if (!WebviewServerConfig.enableTokens()) { return sanitizeUrl(url); @@ -217,6 +231,10 @@ public static void sendMainMenuUrl(ServerPlayer player, String url) { PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebUIMainMenuPayload(sanitizeUrl(url))); } + public static void sendTrustedOrigins(ServerPlayer player, String origins) { + PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(origins == null ? "" : origins)); + } + private static String withPlayerToken(ServerPlayer player, String url) { if (!WebviewServerConfig.enableTokens()) { return sanitizeUrl(url); diff --git a/src/main/java/land/webgui/WebviewPageToClientBridge.java b/src/main/java/land/webgui/WebviewPageToClientBridge.java index 958e4f6..d739782 100644 --- a/src/main/java/land/webgui/WebviewPageToClientBridge.java +++ b/src/main/java/land/webgui/WebviewPageToClientBridge.java @@ -22,7 +22,9 @@ import org.cef.callback.CefQueryCallback; import org.cef.handler.CefMessageRouterHandlerAdapter; -// Built-in channels: "close" — closes active GUI/HUD; "log" — logs to console. Others logged at INFO. +// Built-in channels: "close" — closes active GUI/HUD; "log" — logs to console; +// "command" — runs a command as the player, only from a trusted origin (see WebGUITrustedOrigins). +// Other channels are forwarded to the server as page events. public final class WebviewPageToClientBridge { private WebviewPageToClientBridge() {} @@ -33,7 +35,7 @@ public static void register() { public boolean onQuery(CefBrowser browser, CefFrame frame, long queryId, String request, boolean persistent, CefQueryCallback callback) { try { - dispatch(request, callback); + dispatch(frame, request, callback); } catch (Throwable t) { WebGUIMod.LOGGER.warn("[webgui page→game] handler error", t); callback.failure(-1, t.getMessage() != null ? t.getMessage() : "error"); @@ -44,7 +46,7 @@ public boolean onQuery(CefBrowser browser, CefFrame frame, long queryId, MCEF.getClient().getHandle().addMessageRouter(router); } - private static void dispatch(String request, CefQueryCallback callback) { + private static void dispatch(CefFrame frame, String request, CefQueryCallback callback) { if (request == null || request.isBlank()) { callback.failure(-2, "empty request"); return; @@ -96,6 +98,41 @@ private static void dispatch(String request, CefQueryCallback callback) { } }); } + case "command" -> { + String cmd = obj.has("command") && !obj.get("command").isJsonNull() + ? obj.get("command").getAsString() : null; + if (cmd == null || cmd.isBlank()) { + callback.failure(-3, "empty command"); + return; + } + // Only the main frame of a server-declared trusted origin may run commands. + if (frame == null || !frame.isMain() || !WebGUITrustedOrigins.isTrusted(frame.getURL())) { + WebGUIMod.LOGGER.warn("[webgui] blocked command from untrusted origin: {}", + frame != null ? frame.getURL() : "?"); + callback.failure(-4, "untrusted origin"); + return; + } + String raw = cmd.startsWith("/") ? cmd.substring(1) : cmd; + if (raw.length() > WebviewPayloads.MAX_EVENT_DATA_LENGTH) { + callback.failure(-5, "command too long"); + return; + } + //? if fabric { + MinecraftClient mc = MinecraftClient.getInstance(); + mc.execute(() -> { + if (mc.player != null && mc.getNetworkHandler() != null) { + mc.player.networkHandler.sendChatCommand(raw); + } + }); + //? } else { + /*Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> { + if (mc.player != null && mc.getConnection() != null) { + mc.player.connection.sendCommand(raw); + } + });*/ + //? } + } default -> { if (request.length() > WebviewPayloads.MAX_EVENT_DATA_LENGTH) { WebGUIMod.LOGGER.warn("[webgui page→game] [{}] payload too large ({} bytes), dropping", channel, request.length()); diff --git a/src/main/java/land/webgui/WebviewPayloads.java b/src/main/java/land/webgui/WebviewPayloads.java index 894bd98..4636d22 100644 --- a/src/main/java/land/webgui/WebviewPayloads.java +++ b/src/main/java/land/webgui/WebviewPayloads.java @@ -36,6 +36,7 @@ private WebviewPayloads() {} public static final Identifier EMIT_TO_PAGE_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "emit_to_page"); public static final Identifier PAGE_EVENT_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "page_event"); public static final Identifier ENTITY_CONTEXT_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "entity_context"); + public static final Identifier TRUSTED_ORIGINS_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "trusted_origins"); //? } else { /*//? if >=1.21.5 { public static final Identifier OPEN_WEB_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "open_web"); @@ -43,12 +44,14 @@ private WebviewPayloads() {} public static final Identifier EMIT_TO_PAGE_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "emit_to_page"); public static final Identifier PAGE_EVENT_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "page_event"); public static final Identifier ENTITY_CONTEXT_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "entity_context"); + public static final Identifier TRUSTED_ORIGINS_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "trusted_origins"); //? } else { public static final ResourceLocation OPEN_WEB_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "open_web"); public static final ResourceLocation MAIN_MENU_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "set_main_menu"); public static final ResourceLocation EMIT_TO_PAGE_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "emit_to_page"); public static final ResourceLocation PAGE_EVENT_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "page_event"); public static final ResourceLocation ENTITY_CONTEXT_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "entity_context"); + public static final ResourceLocation TRUSTED_ORIGINS_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "trusted_origins"); //? }*/ //? } @@ -131,6 +134,21 @@ public Id getId() { return ID; } } + + /** S2C: newline-joined origins whose pages may run commands as the player. */ + public record WebviewTrustedOriginsS2CPayload(String origins) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(TRUSTED_ORIGINS_CHANNEL); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.string(MAX_EVENT_DATA_LENGTH), + WebviewTrustedOriginsS2CPayload::origins, + WebviewTrustedOriginsS2CPayload::new); + + @Override + public Id getId() { + return ID; + } + } //? } else { /*// S2C: server emits a named event to the page. public record WebviewEmitS2CPayload(String eventName, String jsonPayload) implements CustomPacketPayload { @@ -197,6 +215,20 @@ public record WebviewEntityContextS2CPayload(String entityJson) implements Custo WebviewEntityContextS2CPayload::entityJson, WebviewEntityContextS2CPayload::new); + @Override + public CustomPacketPayload.Type type() { return TYPE; } + } + + // S2C: newline-joined origins whose pages may run commands as the player. + public record WebviewTrustedOriginsS2CPayload(String origins) implements CustomPacketPayload { + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(TRUSTED_ORIGINS_CHANNEL); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(MAX_EVENT_DATA_LENGTH), + WebviewTrustedOriginsS2CPayload::origins, + WebviewTrustedOriginsS2CPayload::new); + @Override public CustomPacketPayload.Type type() { return TYPE; } }*/ diff --git a/src/main/java/land/webgui/server/WebviewServerConfig.java b/src/main/java/land/webgui/server/WebviewServerConfig.java index f1516ad..6dc40cb 100644 --- a/src/main/java/land/webgui/server/WebviewServerConfig.java +++ b/src/main/java/land/webgui/server/WebviewServerConfig.java @@ -10,12 +10,17 @@ /*import net.neoforged.fml.loading.FMLPaths;*/ //? } +import com.google.gson.JsonArray; + import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; +import java.util.List; public final class WebviewServerConfig { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); @@ -42,6 +47,10 @@ private static Path configPath() { // {"version":"1.2.3"} or GitHub Releases {"tag_name":"v1.2.3","html_url":"..."}; empty = disabled private String updateCheckUrl = ""; + // Origins (scheme://host[:port]) whose pages may run commands as the player. Sent to the client + // on join; the client rejects command requests from any other origin. Empty = no page may run commands. + private List trustedCommandOrigins = new ArrayList<>(); + private static WebviewServerConfig instance = new WebviewServerConfig(); private WebviewServerConfig() {} @@ -69,6 +78,10 @@ private static void writeExample() { o.addProperty("updateCheckUrl", ""); + JsonArray origins = new JsonArray(); + origins.add("https://your-site.example"); + o.add("trustedCommandOrigins", origins); + String json = GSON.toJson(o); Files.writeString(examplePath(), json, StandardCharsets.UTF_8); } catch (IOException e) { @@ -125,6 +138,15 @@ private void applyDefaultsAfterLoad() { } else { updateCheckUrl = updateCheckUrl.trim(); } + List cleaned = new ArrayList<>(); + if (trustedCommandOrigins != null) { + for (String o : trustedCommandOrigins) { + if (o == null) continue; + String t = o.trim(); + if (!t.isEmpty()) cleaned.add(t); + } + } + trustedCommandOrigins = cleaned; } static Gson gson() { @@ -180,6 +202,17 @@ public static String updateCheckUrl() { return u == null ? "" : u.trim(); } + /** Origins whose pages may run commands as the player. Never null. */ + public static List trustedCommandOrigins() { + List o = instance.trustedCommandOrigins; + return o == null ? Collections.emptyList() : o; + } + + /** Newline-joined trusted origins, for compact transport to the client. */ + public static String trustedCommandOriginsJoined() { + return String.join("\n", trustedCommandOrigins()); + } + /** Reloads server.json from disk. Returns a human-readable status line for command feedback. */ public static String reload() { Path path = configPath();