Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/main/java/land/webgui/WebGUIClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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));
});*/
//? }

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions src/main/java/land/webgui/WebGUITrustedOrigins.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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);
}
}
4 changes: 4 additions & 0 deletions src/main/java/land/webgui/WebviewJoinHud.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/land/webgui/WebviewNetworking.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
//? }
}
Expand Down Expand Up @@ -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) -> {});
}
});
}*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 40 additions & 3 deletions src/main/java/land/webgui/WebviewPageToClientBridge.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand All @@ -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");
Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/land/webgui/WebviewPayloads.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,22 @@ 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");
public static final Identifier MAIN_MENU_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "set_main_menu");
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");
//? }*/
//? }

Expand Down Expand Up @@ -131,6 +134,21 @@ public Id<? extends CustomPayload> 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<WebviewTrustedOriginsS2CPayload> ID =
new CustomPayload.Id<>(TRUSTED_ORIGINS_CHANNEL);
public static final PacketCodec<RegistryByteBuf, WebviewTrustedOriginsS2CPayload> CODEC = PacketCodec.tuple(
PacketCodecs.string(MAX_EVENT_DATA_LENGTH),
WebviewTrustedOriginsS2CPayload::origins,
WebviewTrustedOriginsS2CPayload::new);

@Override
public Id<? extends CustomPayload> getId() {
return ID;
}
}
//? } else {
/*// S2C: server emits a named event to the page.
public record WebviewEmitS2CPayload(String eventName, String jsonPayload) implements CustomPacketPayload {
Expand Down Expand Up @@ -197,6 +215,20 @@ public record WebviewEntityContextS2CPayload(String entityJson) implements Custo
WebviewEntityContextS2CPayload::entityJson,
WebviewEntityContextS2CPayload::new);

@Override
public CustomPacketPayload.Type<? extends CustomPacketPayload> 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<WebviewTrustedOriginsS2CPayload> TYPE =
new CustomPacketPayload.Type<>(TRUSTED_ORIGINS_CHANNEL);
public static final StreamCodec<RegistryFriendlyByteBuf, WebviewTrustedOriginsS2CPayload> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.stringUtf8(MAX_EVENT_DATA_LENGTH),
WebviewTrustedOriginsS2CPayload::origins,
WebviewTrustedOriginsS2CPayload::new);

@Override
public CustomPacketPayload.Type<? extends CustomPacketPayload> type() { return TYPE; }
}*/
Expand Down
Loading
Loading