From 2ab9d8c2642bf7d1f9f23886d9ce8067414d6d42 Mon Sep 17 00:00:00 2001 From: Liam Sage Date: Wed, 4 Nov 2026 18:42:24 +0100 Subject: [PATCH 1/2] Fix shader consent and validate pack downloads --- build.gradle.kts | 8 ++ .../client/ShaderReceivePayloadHandler.kt | 72 +++++++---- .../shaderapi/ShaderConsentClientTest.java | 114 ++++++++++++++++++ src/gametest/resources/fabric.mod.json | 9 ++ 4 files changed, 177 insertions(+), 26 deletions(-) create mode 100644 src/gametest/java/com/liamxsage/shaderapi/ShaderConsentClientTest.java create mode 100644 src/gametest/resources/fabric.mod.json diff --git a/build.gradle.kts b/build.gradle.kts index 5588031..f2f7d54 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -74,6 +74,14 @@ dependencies { } +fabricApi { + configureTests { + createSourceSet = true + modId = "shaderapi_test" + enableGameTests = false + } +} + tasks.processResources { inputs.property("version", project.version) inputs.property("minecraft_version", project.property("minecraft_version")) diff --git a/src/client/kotlin/com/liamxsage/shaderapi/client/ShaderReceivePayloadHandler.kt b/src/client/kotlin/com/liamxsage/shaderapi/client/ShaderReceivePayloadHandler.kt index 49eee00..07ebfae 100644 --- a/src/client/kotlin/com/liamxsage/shaderapi/client/ShaderReceivePayloadHandler.kt +++ b/src/client/kotlin/com/liamxsage/shaderapi/client/ShaderReceivePayloadHandler.kt @@ -10,10 +10,12 @@ import net.minecraft.client.Minecraft import net.minecraft.client.gui.screens.ConfirmScreen import net.minecraft.network.chat.Component import java.io.File +import java.io.OutputStream import java.net.HttpURLConnection import java.net.URI +import java.security.DigestInputStream +import java.security.MessageDigest import java.util.* -import kotlin.reflect.jvm.jvmName class ShaderReceivePayloadHandler : ClientPlayNetworking.PlayPayloadHandler { override fun receive(payload: ShaderReceivePayload, context: ClientPlayNetworking.Context) { @@ -21,8 +23,20 @@ class ShaderReceivePayloadHandler : ClientPlayNetworking.PlayPayloadHandler + val directory = File(minecraftClient.gameDirectory, "downloads") + if (!directory.isDirectory && !directory.mkdirs()) return null + tmpFile = File.createTempFile("shaderapi-", ".tmp", directory) + connection = URI(url).toURL().openConnection() as HttpURLConnection + connection.connectTimeout = 10_000 + connection.readTimeout = 30_000 + connection.inputStream.use { inputStream -> tmpFile.outputStream().use { outputStream -> inputStream.copyTo(outputStream) } } - - val renamed = tmpFile.renameTo(File(minecraftClient.gameDirectory, "downloads/$hash.zip")) - return if (renamed) { - File(minecraftClient.gameDirectory, "downloads/$hash.zip") - } else { - sendShaderStatusResponse(ShaderStatusResponse.IO_EXCEPTION) - null + if (!hashMatches(tmpFile, hash)) { + logger.warn("Downloaded shader pack has an incorrect SHA-1 digest") + return null } + val destination = File(directory, "$hash.zip") + return if (tmpFile.renameTo(destination)) destination else null } catch (exception: Exception) { - exception.printStackTrace() - sendShaderStatusResponse(exception::class.jvmName.uppercase()) + logger.warn("Could not download shader pack", exception) + } finally { + connection?.disconnect() + tmpFile?.delete() } return null } @@ -171,4 +191,4 @@ private object IrisBridge { fun reload() { reloadMethod.invoke(null) } -} \ No newline at end of file +} diff --git a/src/gametest/java/com/liamxsage/shaderapi/ShaderConsentClientTest.java b/src/gametest/java/com/liamxsage/shaderapi/ShaderConsentClientTest.java new file mode 100644 index 0000000..5a3f429 --- /dev/null +++ b/src/gametest/java/com/liamxsage/shaderapi/ShaderConsentClientTest.java @@ -0,0 +1,114 @@ +package com.liamxsage.shaderapi; + +import com.liamxsage.shaderapi.client.ShaderReceivePayloadHandler; +import com.liamxsage.shaderapi.client.config.ConfigManager; +import com.liamxsage.shaderapi.client.config.ShaderPackAcceptState; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import com.sun.net.httpserver.HttpServer; +import net.minecraft.client.gui.screens.ConfirmScreen; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public final class ShaderConsentClientTest implements FabricClientGameTest { + @Override + public void runTest(ClientGameTestContext context) { + var status = new AtomicReference(); + var statuses = new CopyOnWriteArrayList(); + ClientPlayNetworking.registerGlobalReceiver(ShaderReceivePayload.ID, new ShaderReceivePayloadHandler()); + ServerPlayNetworking.registerGlobalReceiver(ShaderStatusResponsePayload.ID, + (payload, ignored) -> { statuses.add(payload.statusResponse()); status.set(payload.statusResponse()); }); + try (var world = context.worldBuilder().create()) { + context.runOnClient(client -> ConfigManager.INSTANCE.addServerGroup("denied-test", ShaderPackAcceptState.DENY)); + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload("https://example.com/pack.zip", "0000000000000000000000000000000000000000", "denied-test"))); + context.waitFor(client -> status.get() != null || client.gui.screen() instanceof ConfirmScreen, 200); + if (!"DENIED".equals(status.get())) throw new AssertionError("A saved rejection must send DENIED without prompting"); + context.runOnClient(client -> { + if (client.gui.screen() instanceof ConfirmScreen) throw new AssertionError("Saved rejection prompted again"); + }); + statuses.clear(); + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload("https://example.com/pack.zip", "../../unsafe", "denied-test"))); + context.waitFor(client -> !statuses.isEmpty(), 200); + if (!statuses.equals(List.of("DENIED"))) + throw new AssertionError("A saved rejection must remain DENIED even for an invalid offer: " + statuses); + statuses.clear(); + context.runOnClient(client -> ConfigManager.INSTANCE.addServerGroup("accepted-test", ShaderPackAcceptState.ACCEPT)); + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload("http://127.0.0.1:1/missing.zip", "0000000000000000000000000000000000000000", "accepted-test"))); + context.waitFor(client -> statuses.contains("DOWNLOAD_FAILED"), 200); + context.waitTicks(20); + if (!statuses.equals(List.of("DOWNLOAD_FAILED"))) + throw new AssertionError("A failed download must send one DOWNLOAD_FAILED status: " + statuses); + + try { + var name = "shaderapi-escape-" + UUID.randomUUID(); + var gameDir = context.computeOnClient(client -> client.gameDirectory.toPath()); + var outside = gameDir.resolve("downloads/../../" + name + ".zip").normalize(); + var wrongHash = "1111111111111111111111111111111111111111"; + var cached = gameDir.resolve("downloads/" + wrongHash + ".zip"); + var validHash = "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"; + var validCache = gameDir.resolve("downloads/" + validHash + ".zip"); + if (Files.exists(outside) || Files.exists(cached) || Files.exists(validCache)) + throw new AssertionError("Test destination already exists"); + var http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + var requests = new AtomicInteger(); + http.createContext("/pack", exchange -> { + requests.incrementAndGet(); + byte[] data = "test".getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, data.length); + try (var body = exchange.getResponseBody()) { body.write(data); } + }); + http.start(); + try { + statuses.clear(); + var url = "http://127.0.0.1:" + http.getAddress().getPort() + "/pack"; + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload(url, "../../" + name, "accepted-test"))); + context.waitFor(client -> !statuses.isEmpty(), 200); + context.waitTicks(10); + if (Files.exists(outside) || !statuses.equals(List.of("DOWNLOAD_FAILED"))) + throw new AssertionError("An unsafe hash must not write outside the download directory: " + statuses); + + statuses.clear(); + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload(url, wrongHash, "accepted-test"))); + context.waitFor(client -> !statuses.isEmpty(), 200); + context.waitTicks(10); + if (Files.exists(cached) || !statuses.equals(List.of("DOWNLOAD_FAILED"))) + throw new AssertionError("A mismatched download must not be cached or applied: " + statuses); + + statuses.clear(); + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload(url, validHash, "accepted-test"))); + context.waitFor(client -> !statuses.isEmpty(), 200); + if (requests.get() != 2 || !Files.readString(validCache).equals("test") || !statuses.equals(List.of("APPLY_FAILED"))) + throw new AssertionError("A matching download must be cached before Iris is invoked: " + statuses); + + Files.writeString(validCache, "corrupt"); + statuses.clear(); + world.getServer().runOnServer(server -> ServerPlayNetworking.send(world.getConnection().getServerPlayer(), + new ShaderReceivePayload(url, validHash, "accepted-test"))); + context.waitFor(client -> !statuses.isEmpty(), 200); + if (requests.get() != 3 || !Files.readString(validCache).equals("test") || !statuses.equals(List.of("APPLY_FAILED"))) + throw new AssertionError("A corrupt cached file must be replaced by a verified download: " + statuses); + } finally { + http.stop(0); + Files.deleteIfExists(outside); + Files.deleteIfExists(cached); + Files.deleteIfExists(validCache); + } + } catch (java.io.IOException e) { + throw new AssertionError("Could not run download fixture", e); + } + } + } +} diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json new file mode 100644 index 0000000..d2397ca --- /dev/null +++ b/src/gametest/resources/fabric.mod.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "id": "shaderapi_test", + "version": "1.0.0", + "environment": "client", + "entrypoints": { + "fabric-client-gametest": ["com.liamxsage.shaderapi.ShaderConsentClientTest"] + } +} From 62fbe056aaeba0a052b1f6831b67a430533a48a1 Mon Sep 17 00:00:00 2001 From: Liam Sage Date: Wed, 4 Nov 2026 18:42:32 +0100 Subject: [PATCH 2/2] Document shader verification and saved choices --- README.MD | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.MD b/README.MD index ecc765d..873e113 100644 --- a/README.MD +++ b/README.MD @@ -17,6 +17,8 @@ ShaderAPI disables its client integration when Iris is missing. Connect to a server implementing the ShaderAPI protocol. The client requests the server's shader pack and asks for approval before downloading and applying it. Acceptance is remembered by server group. +Offers must provide a 40-digit SHA-1 hash; downloaded and cached packs are checked against it. Rejection is also remembered by server group. To change a saved decision, remove that group's entry from `config/shaderapi.json` while the game is closed. + A server-side implementation for Paper is available in [ShaderAPI-Paper](https://github.com/Liams-Services/ShaderAPI-Paper). Client support for 26.3 does not establish compatibility of that separate server plugin. ## Building