From 4e9849cf17dad048e23f4ab4a30280b0f9e63ed7 Mon Sep 17 00:00:00 2001 From: Laxman Reddy Aileni Date: Fri, 12 Jun 2026 01:45:56 +0000 Subject: [PATCH] fix(amazonq): serve webview assets without Jetty PathResource (#560) Eclipse 2026-06 (4.40.0) bundles a newer Jetty whose PathResource#resolve builds a URI-style path such as "/C:/Users/.../amazonq-ui.js" and passes it to Path#resolve, which throws InvalidPathException on Windows (illegal ':' after the leading slash). This left the Amazon Q chat and login webviews showing a blank screen. Replace the ResourceHandler/PathResource-based asset serving with a small java.nio-based handler that resolves the requested path relative to the asset directory, which stays valid on every platform. The handler keeps a path-traversal guard and advertises content types by extension. The public API of WebviewAssetServer is unchanged, so callers are unaffected. Adds WebviewAssetServerTest covering content-type resolution and HTTP serving (asset retrieval, nested assets, and 404 handling). --- plugin/META-INF/MANIFEST.MF | 1 + .../amazonq/util/WebviewAssetServer.java | 128 ++++++++++++++++-- .../amazonq/util/WebviewAssetServerTest.java | 104 ++++++++++++++ 3 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServerTest.java diff --git a/plugin/META-INF/MANIFEST.MF b/plugin/META-INF/MANIFEST.MF index e1a0f2d18..2e2b2d238 100644 --- a/plugin/META-INF/MANIFEST.MF +++ b/plugin/META-INF/MANIFEST.MF @@ -25,6 +25,7 @@ Require-Bundle: org.eclipse.core.runtime;bundle-version="3.31.0", org.eclipse.mylyn.commons.ui;bundle-version="4.2.0", org.eclipse.jetty.server;bundle-version="12.0.9", org.eclipse.jetty.util;bundle-version="12.0.9", + org.eclipse.jetty.http;bundle-version="12.0.9", org.eclipse.core.net;bundle-version="1.5.400", org.apache.commons.logging;bundle-version="1.2.0", slf4j.api;bundle-version="2.0.13", diff --git a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServer.java b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServer.java index 447d3dc9f..4885b1097 100644 --- a/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServer.java +++ b/plugin/src/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServer.java @@ -3,35 +3,66 @@ package software.aws.toolkits.eclipse.amazonq.util; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.handler.ContextHandler; -import org.eclipse.jetty.server.handler.ResourceHandler; -import org.eclipse.jetty.util.resource.ResourceFactory; +import org.eclipse.jetty.util.Callback; import software.aws.toolkits.eclipse.amazonq.plugin.Activator; public final class WebviewAssetServer { + private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; + + private static final Map CONTENT_TYPES_BY_EXTENSION = Map.ofEntries( + Map.entry("js", "text/javascript; charset=utf-8"), + Map.entry("mjs", "text/javascript; charset=utf-8"), + Map.entry("css", "text/css; charset=utf-8"), + Map.entry("html", "text/html; charset=utf-8"), + Map.entry("htm", "text/html; charset=utf-8"), + Map.entry("json", "application/json; charset=utf-8"), + Map.entry("map", "application/json; charset=utf-8"), + Map.entry("svg", "image/svg+xml"), + Map.entry("png", "image/png"), + Map.entry("gif", "image/gif"), + Map.entry("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("ico", "image/x-icon"), + Map.entry("wasm", "application/wasm"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("ttf", "font/ttf"), + Map.entry("eot", "application/vnd.ms-fontobject")); + private Server server; /** * Sets up virtual host mapping for the given path using Jetty server. - * @param jsPath + * @param jsPath the absolute path to the directory containing the assets to serve * @return boolean indicating if server can be successfully launched */ public boolean resolve(final String jsPath) { try { + final Path baseDirectory = Path.of(jsPath).toAbsolutePath().normalize(); + server = new Server(0); var servletContext = new ContextHandler(); servletContext.setContextPath("/"); servletContext.addVirtualHosts(new String[] {"127.0.0.1"}); - var handler = new ResourceHandler(); - - ResourceFactory resourceFactory = ResourceFactory.of(server); - handler.setBaseResource(resourceFactory.newResource(jsPath)); - handler.setDirAllowed(true); - servletContext.setHandler(handler); + servletContext.setHandler(new StaticFileHandler(baseDirectory)); server.setHandler(servletContext); server.start(); @@ -57,4 +88,83 @@ public void stop() { } } } + + /** + * Resolves the HTTP content type to advertise for the given file name based on its extension. + * @param fileName the name of the file being served + * @return the matching content type, or {@code application/octet-stream} when the extension is unknown + */ + static String getContentType(final String fileName) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == fileName.length() - 1) { + return DEFAULT_CONTENT_TYPE; + } + String extension = fileName.substring(dotIndex + 1).toLowerCase(Locale.ROOT); + return CONTENT_TYPES_BY_EXTENSION.getOrDefault(extension, DEFAULT_CONTENT_TYPE); + } + + /** + * Serves files from a fixed base directory using {@link java.nio.file.Files}. + * + *

+ * This intentionally avoids Jetty's {@code ResourceHandler} / {@code PathResource#resolve}, which throws + * {@link InvalidPathException} on Windows with the Jetty version bundled in newer Eclipse releases. There, the + * requested path is combined into a URI-style string such as {@code /C:/Users/.../amazonq-ui.js} and then passed to + * {@link Path#resolve(String)}, which is illegal on Windows (the leading slash before the drive letter). Resolving + * the request path relative to the base directory ourselves keeps the resulting path valid on every platform. + * See issue #560. + *

+ */ + private static final class StaticFileHandler extends Handler.Abstract { + + private final Path baseDirectory; + + StaticFileHandler(final Path baseDirectory) { + this.baseDirectory = baseDirectory; + } + + @Override + public boolean handle(final Request request, final Response response, final Callback callback) { + String pathInContext = Request.getPathInContext(request); + if (pathInContext == null || pathInContext.isEmpty() || "/".equals(pathInContext)) { + Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404); + return true; + } + + // Strip leading slashes so the request path is resolved relative to (not as a sibling of) the base directory. + String relativePath = pathInContext; + while (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + + Path resolved; + try { + resolved = baseDirectory.resolve(relativePath).normalize(); + } catch (InvalidPathException e) { + Response.writeError(request, response, callback, HttpStatus.BAD_REQUEST_400); + return true; + } + + // Guard against path traversal outside of the served base directory. + if (!resolved.startsWith(baseDirectory)) { + Response.writeError(request, response, callback, HttpStatus.FORBIDDEN_403); + return true; + } + + if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) { + Response.writeError(request, response, callback, HttpStatus.NOT_FOUND_404); + return true; + } + + try { + byte[] contents = Files.readAllBytes(resolved); + response.setStatus(HttpStatus.OK_200); + response.getHeaders().put(HttpHeader.CONTENT_TYPE, getContentType(resolved.getFileName().toString())); + response.write(true, ByteBuffer.wrap(contents), callback); + } catch (IOException e) { + Response.writeError(request, response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, "Unable to read requested asset"); + } + return true; + } + } } diff --git a/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServerTest.java b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServerTest.java new file mode 100644 index 000000000..472fe0002 --- /dev/null +++ b/plugin/tst/software/aws/toolkits/eclipse/amazonq/util/WebviewAssetServerTest.java @@ -0,0 +1,104 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.eclipse.amazonq.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class WebviewAssetServerTest { + + private static final int HTTP_OK = 200; + private static final int HTTP_NOT_FOUND = 404; + + @Test + void getContentTypeResolvesKnownExtensionsCaseInsensitively() { + assertEquals("text/javascript; charset=utf-8", WebviewAssetServer.getContentType("amazonq-ui.js")); + assertEquals("text/javascript; charset=utf-8", WebviewAssetServer.getContentType("AMAZONQ-UI.JS")); + assertEquals("text/css; charset=utf-8", WebviewAssetServer.getContentType("styles.css")); + assertEquals("application/json; charset=utf-8", WebviewAssetServer.getContentType("manifest.json")); + } + + @Test + void getContentTypeFallsBackToOctetStreamForUnknownOrMissingExtension() { + assertEquals("application/octet-stream", WebviewAssetServer.getContentType("archive.unknownext")); + assertEquals("application/octet-stream", WebviewAssetServer.getContentType("no-extension")); + assertEquals("application/octet-stream", WebviewAssetServer.getContentType("trailing-dot.")); + } + + @Test + void servesRequestedAssetWithResolvedContentType(@TempDir final Path tempDir) throws Exception { + String expectedContents = "window.amazonQChat = {};"; + Files.writeString(tempDir.resolve("amazonq-ui.js"), expectedContents); + + WebviewAssetServer server = new WebviewAssetServer(); + try { + assertTrue(server.resolve(tempDir.toString())); + + HttpResponse response = get(server, "amazonq-ui.js"); + + assertEquals(HTTP_OK, response.statusCode()); + assertEquals(expectedContents, response.body()); + assertTrue(response.headers().firstValue("Content-Type").orElse("").startsWith("text/javascript")); + } finally { + server.stop(); + } + } + + @Test + void servesNestedAsset(@TempDir final Path tempDir) throws Exception { + Path nested = tempDir.resolve("assets").resolve("app.css"); + Files.createDirectories(nested.getParent()); + Files.writeString(nested, "body { margin: 0; }"); + + WebviewAssetServer server = new WebviewAssetServer(); + try { + assertTrue(server.resolve(tempDir.toString())); + + HttpResponse response = get(server, "assets/app.css"); + + assertEquals(HTTP_OK, response.statusCode()); + assertEquals("body { margin: 0; }", response.body()); + assertTrue(response.headers().firstValue("Content-Type").orElse("").startsWith("text/css")); + } finally { + server.stop(); + } + } + + @Test + void returnsNotFoundForMissingAsset(@TempDir final Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("amazonq-ui.js"), "noop"); + + WebviewAssetServer server = new WebviewAssetServer(); + try { + assertTrue(server.resolve(tempDir.toString())); + + HttpResponse response = get(server, "does-not-exist.js"); + + assertEquals(HTTP_NOT_FOUND, response.statusCode()); + } finally { + server.stop(); + } + } + + private static HttpResponse get(final WebviewAssetServer server, final String assetPath) throws Exception { + // Connect over 127.0.0.1 to satisfy the server's virtual host restriction, regardless of the host returned by getUri(). + int port = URI.create(server.getUri()).getPort(); + URI target = URI.create("http://127.0.0.1:" + port + "/" + assetPath); + HttpRequest request = HttpRequest.newBuilder(target).GET().build(); + HttpClient client = HttpClient.newHttpClient(); + return client.send(request, BodyHandlers.ofString(StandardCharsets.UTF_8)); + } +}