This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-kit.git
commit ac688d442ef8c3228158b3b09f68e98bcb91b319 Author: Andrea Cosentino <[email protected]> AuthorDate: Mon Nov 2 09:38:04 2026 +0100 Fix #137: validate every redirect hop's transport before following it Address review feedback. The client used HttpClient.Redirect.NORMAL, which follows HTTP->HTTP redirects (including loopback -> non-loopback host) without re-checking the scheme, so a local mirror could redirect the manifest, archive or release-discovery request to a remote cleartext backend. Switch to Redirect.NEVER and follow redirects manually in the shared send() path, calling requireFetchableUri() on each hop before the request, bounded to five hops, preserving the response deadline and body cleanup, and carrying Accept. HTTPS redirects (used by GitHub release assets) are unaffected. Add tests: a loopback -> non-loopback-http redirect is refused and a loopback -> loopback redirect is followed; plus independent rejection tests for the release-discovery endpoint, the discovered manifest URL, and the resolved archive URL -- the three guard sites the earlier single test missed. Co-authored-by: Claude Opus 4.8 <[email protected]> --- .../camel/kit/knowledge/mcp/IndexDownloader.java | 40 +++++- .../kit/knowledge/mcp/IndexDownloaderTest.java | 147 +++++++++++++++++++++ 2 files changed, 184 insertions(+), 3 deletions(-) diff --git a/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/IndexDownloader.java b/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/IndexDownloader.java index ce7c1723..ec940a26 100644 --- a/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/IndexDownloader.java +++ b/knowledge/mcp/src/main/java/org/apache/camel/kit/knowledge/mcp/IndexDownloader.java @@ -86,8 +86,10 @@ public class IndexDownloader { private final Duration manifestTimeout; private final Duration assetTimeout; private final ObjectMapper mapper = new ObjectMapper(); + // NEVER, not NORMAL: redirects are followed manually in send() so each hop's transport is validated + // (requireFetchableUri) before it is fetched. NORMAL would follow a loopback->non-loopback-http hop first. private final HttpClient httpClient = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) + .followRedirects(HttpClient.Redirect.NEVER) .connectTimeout(CONNECT_TIMEOUT) .build(); @@ -601,9 +603,41 @@ public class IndexDownloader { } private HttpResponse<InputStream> send(HttpRequest request) throws IOException, InterruptedException { + final int maxRedirects = 5; long deadline = System.nanoTime() + request.timeout().orElseThrow().toNanos(); - return httpClient.send(request, info -> HttpResponse.BodySubscribers.mapping( - HttpResponse.BodySubscribers.ofPublisher(), publisher -> new TimedBody(publisher, deadline))); + HttpRequest current = request; + for (int hop = 0;; hop++) { + HttpResponse<InputStream> response = httpClient.send(current, info -> HttpResponse.BodySubscribers.mapping( + HttpResponse.BodySubscribers.ofPublisher(), publisher -> new TimedBody(publisher, deadline))); + int status = response.statusCode(); + if (status != 301 && status != 302 && status != 303 && status != 307 && status != 308) { + return response; + } + // Validate the redirect target's transport BEFORE following it; a 304 is not a redirect and + // is returned above for the conditional-manifest path. + String target = response.headers().firstValue("Location").orElse(null); + response.body().close(); + if (hop >= maxRedirects) { + throw new IOException("Too many index redirects starting from " + request.uri()); + } + if (target == null) { + throw new IOException("Index redirect without a Location header from " + current.uri()); + } + URI next; + try { + next = current.uri().resolve(target); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid index redirect target: " + target, e); + } + requireFetchableUri(next); + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + throw new HttpTimeoutException("Index redirect handling timed out from " + request.uri()); + } + HttpRequest.Builder builder = HttpRequest.newBuilder(next).timeout(Duration.ofNanos(remaining)).GET(); + current.headers().firstValue("Accept").ifPresent(accept -> builder.header("Accept", accept)); + current = builder.build(); + } } /** Bounded HTTP buffering; only the resolving thread reads or writes index files. */ diff --git a/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/IndexDownloaderTest.java b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/IndexDownloaderTest.java index 64d189a2..24707d10 100644 --- a/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/IndexDownloaderTest.java +++ b/knowledge/mcp/src/test/java/org/apache/camel/kit/knowledge/mcp/IndexDownloaderTest.java @@ -205,6 +205,153 @@ class IndexDownloaderTest { assertTrue(refused, "A remote http manifest must be refused over cleartext: " + e); } + /** Asserts a cleartext-http refusal somewhere in the exception's cause chain. */ + private static void assertRefusedCleartext(Throwable thrown) { + for (Throwable t = thrown; t != null; t = t.getCause()) { + if (t.getMessage() != null && t.getMessage().contains("cleartext http")) { + return; + } + } + fail("Expected a cleartext-http refusal in the cause chain, got: " + thrown); + } + + /** A manifest-less config that drives release discovery against the given endpoint. */ + private IndexResolverConfig discoveryConfig(String releasesUrl) { + cacheDir = tmp.resolve("cache"); + return new IndexResolverConfig() { + @Override + public Optional<String> path() { + return Optional.empty(); + } + + @Override + public Optional<String> url() { + return Optional.empty(); + } + + @Override + public String releasesUrl() { + return releasesUrl; + } + + @Override + public Optional<String> version() { + return Optional.empty(); + } + + @Override + public String cacheDir() { + return cacheDir.toString(); + } + }; + } + + @Test + void manifestRedirectToNonLoopbackHttpIsRejected() throws Exception { + publishDir = tmp.resolve("publish"); + Files.createDirectories(publishDir); + cacheDir = tmp.resolve("cache"); + HttpServer redirector = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + redirector.createContext("/", exchange -> { + try (exchange) { + exchange.getResponseHeaders().set("Location", "http://example.org/index.json"); + exchange.sendResponseHeaders(302, -1); + } + }); + redirector.start(); + try { + String url = "http://127.0.0.1:" + redirector.getAddress().getPort() + "/index.json"; + assertRefusedCleartext(assertThrows(IOException.class, () -> downloader(null, url).resolve())); + assertFalse(Files.exists(cacheDir.resolve("current")), + "a rejected loopback->remote-http redirect must not activate an index"); + } finally { + redirector.stop(0); + } + } + + @Test + void loopbackHttpRedirectIsFollowed() throws Exception { + publish("2026.07.09", "redirect-followed", null); + HttpServer backend = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + backend.createContext("/", exchange -> { + try (exchange) { + String name = exchange.getRequestURI().getPath().endsWith("index.json") + ? "index.json" + : "knowledge-index.zip"; + byte[] body = Files.readAllBytes(publishDir.resolve(name)); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + }); + backend.start(); + int backendPort = backend.getAddress().getPort(); + HttpServer redirector = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + redirector.createContext("/", exchange -> { + try (exchange) { + exchange.getResponseHeaders() + .set("Location", "http://127.0.0.1:" + backendPort + exchange.getRequestURI().getPath()); + exchange.sendResponseHeaders(302, -1); + } + }); + redirector.start(); + try { + String url = "http://127.0.0.1:" + redirector.getAddress().getPort() + "/index.json"; + IndexDownloader.ResolvedIndex resolved = downloader(null, url).resolve(); + assertEquals("redirect-followed", Files.readString(resolved.dir().resolve("segments_1")), + "a loopback->loopback http redirect must be followed"); + } finally { + redirector.stop(0); + backend.stop(0); + } + } + + @Test + void releasesEndpointOverNonLoopbackHttpIsRejected() { + IndexDownloader downloader = new IndexDownloader(discoveryConfig("http://example.org/releases")); + assertRefusedCleartext(assertThrows(IOException.class, () -> downloader.resolve())); + assertFalse(Files.exists(cacheDir.resolve("current"))); + } + + @Test + void discoveredManifestUrlOverNonLoopbackHttpIsRejected() throws Exception { + HttpServer releases = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + releases.createContext("/", exchange -> { + try (exchange) { + byte[] body = ("[{\"tag_name\":\"knowledge-index-v1-2026.07.09-000000\",\"draft\":false," + + "\"prerelease\":false,\"assets\":[{\"name\":\"index.json\"," + + "\"browser_download_url\":\"http://example.org/index.json\"}]}]") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + }); + releases.start(); + try { + String releasesUrl = "http://127.0.0.1:" + releases.getAddress().getPort() + "/releases"; + IndexDownloader downloader = new IndexDownloader(discoveryConfig(releasesUrl)); + assertRefusedCleartext(assertThrows(IOException.class, () -> downloader.resolve())); + assertFalse(Files.exists(cacheDir.resolve("current"))); + } finally { + releases.stop(0); + } + } + + @Test + void resolvedArchiveUrlOverNonLoopbackHttpIsRejected() throws Exception { + publishDir = tmp.resolve("publish"); + Files.createDirectories(publishDir); + cacheDir = tmp.resolve("cache"); + Files.writeString(publishDir.resolve("index.json"), new ObjectMapper().createObjectNode() + .put("version", "2026.07.09") + .put("sha256", "0".repeat(64)) + .put("asset", "http://example.org/knowledge-index.zip") + .put("embeddingModel", "granite-embedding-small-english-r2-q8").toString()); + String url = "file://" + publishDir.resolve("index.json"); + + assertRefusedCleartext(assertThrows(IOException.class, () -> downloader(null, url).resolve())); + assertFalse(Files.exists(cacheDir.resolve("2026.07.09")), "a rejected archive URL must not be installed"); + } + @Test void newVersionReplacesOldAndPrunes() throws Exception { String url = publish("2026.07.01", "old-bytes", null);
