package best.lolicon.felis.link; import com.sun.net.httpserver.HttpServer; import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; /** * FelisApiClientTest checks, against a stub felis-api on a loopback port, that no * caller-supplied value can re-aim a request at a different route. It is framework * free like the other test mains: a failed assertion throws and the process exits * non-zero. * *

The case that motivated it: a WakeRequest for {@code victim/join-event?} used to * become {@code POST /api/v1/internal/servers/victim/join-event}, which appends the * sender to another tenant's allowlist. The stub records every raw request path, so * "refused" here means no request reached the server at all. * *

Run: {@code javac -d shared/src/main/java/best/lolicon/felis/link/*.java * shared/test/best/lolicon/felis/link/FelisApiClientTest.java && java -cp * best.lolicon.felis.link.FelisApiClientTest}. */ public final class FelisApiClientTest { private static int checks; private static final List seen = new CopyOnWriteArrayList<>(); public static void main(String[] args) throws Exception { HttpServer stub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); stub.createContext("/", exchange -> { seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getRawPath()); String path = exchange.getRequestURI().getRawPath(); String body; int status; if (path.endsWith("/wake")) { status = 202; body = "{\"name\":\"alpha\",\"phase\":\"Starting\",\"ready\":false}"; } else if (path.endsWith("/approve")) { status = 200; body = "{\"approved\":true}"; } else { status = 200; body = "{\"name\":\"alpha\",\"phase\":\"Running\",\"ready\":true,\"claimable\":false}"; } byte[] b = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(status, b.length); try (OutputStream os = exchange.getResponseBody()) { os.write(b); } }); stub.start(); try { FelisApiClient api = new FelisApiClient(new LinkConfig( "http://127.0.0.1:" + stub.getAddress().getPort(), "test-token")); wellFormedNamesReachTheirRoute(api); pathBendingNamesNeverLeaveTheClient(api); opaqueSegmentsArePercentEncoded(api); } finally { stub.stop(0); } System.out.println("FelisApiClientTest OK (" + checks + " checks)"); } private static void wellFormedNamesReachTheirRoute(FelisApiClient api) throws LinkException { seen.clear(); UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001"); api.wake("alpha", id); api.menuStatus("my-server-2"); api.serverStatus("abc"); assertEq("routes hit", List.of( "POST /api/v1/internal/servers/alpha/wake", "GET /api/v1/internal/servers/my-server-2/menu", "GET /api/v1/internal/servers/abc/status"), seen); } private static void pathBendingNamesNeverLeaveTheClient(FelisApiClient api) { UUID id = UUID.fromString("00000000-0000-0000-0000-000000000002"); String[] bad = { "victim/join-event?", // the reported re-aim "victim/join-event", "../account/link/code", "a b", // used to throw IllegalArgumentException out of URI.create "Alpha", // server names are lower-case "-lead", "trail-", "ab", // too short "a".repeat(33), // too long "alpha#frag", "alpha%2Fjoin-event", "", }; seen.clear(); for (String name : bad) { expectRefused("wake " + name, () -> api.wake(name, id)); expectRefused("reportJoin " + name, () -> api.reportJoin(name, id)); expectRefused("claim " + name, () -> api.claim(name, id)); expectRefused("menuStatus " + name, () -> api.menuStatus(name)); expectRefused("serverStatus " + name, () -> api.serverStatus(name)); } assertEq("requests sent for malformed names", List.of(), seen); } private static void opaqueSegmentsArePercentEncoded(FelisApiClient api) throws LinkException { assertEq("slash", "a%2Fb", FelisApiClient.segment("a/b")); assertEq("query", "a%3Fb", FelisApiClient.segment("a?b")); assertEq("space", "a%20b", FelisApiClient.segment("a b")); expectRefused("dot", () -> FelisApiClient.segment(".")); expectRefused("dotdot", () -> FelisApiClient.segment("..")); seen.clear(); api.opLoginApprove("0123abcd", UUID.fromString("00000000-0000-0000-0000-000000000003")); assertEq("approve route", List.of("POST /api/v1/internal/op-login/0123abcd/approve"), seen); } // ---- harness ---- interface Call { void run() throws LinkException; } private static void expectRefused(String what, Call call) { try { call.run(); } catch (LinkException e) { if (e.statusCode() != 400) { throw new AssertionError(what + ": refused with status " + e.statusCode() + ", want 400"); } checks++; return; } catch (RuntimeException e) { throw new AssertionError(what + ": threw " + e + " instead of a LinkException", e); } throw new AssertionError(what + ": was not refused"); } private static void assertEq(String what, Object want, Object got) { if (want == null ? got != null : !want.equals(got)) { throw new AssertionError(what + " = " + got + ", want " + want); } checks++; } }