| /* | |
| * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved. | |
| * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. | |
| * | |
| * This code is free software; you can redistribute it and/or modify it | |
| * under the terms of the GNU General Public License version 2 only, as | |
| * published by the Free Software Foundation. | |
| * | |
| * This code is distributed in the hope that it will be useful, but WITHOUT | |
| * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | |
| * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License | |
| * version 2 for more details (a copy is included in the LICENSE file that | |
| * accompanied this code). | |
| * | |
| * You should have received a copy of the GNU General Public License version | |
| * 2 along with this work; if not, write to the Free Software Foundation, | |
| * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. | |
| * | |
| * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA | |
| * or visit www.oracle.com if you need additional information or have any | |
| * questions. | |
| */ | |
| import com.sun.net.httpserver.BasicAuthenticator; | |
| import com.sun.net.httpserver.HttpServer; | |
| import com.sun.net.httpserver.HttpsConfigurator; | |
| import com.sun.net.httpserver.HttpsParameters; | |
| import com.sun.net.httpserver.HttpsServer; | |
| import java.io.Closeable; | |
| import java.io.IOException; | |
| import java.io.InputStream; | |
| import java.io.OutputStream; | |
| import java.io.OutputStreamWriter; | |
| import java.io.PrintWriter; | |
| import java.io.Writer; | |
| import java.math.BigInteger; | |
| import java.net.Authenticator; | |
| import java.net.HttpURLConnection; | |
| import java.net.InetAddress; | |
| import java.net.InetSocketAddress; | |
| import java.net.MalformedURLException; | |
| import java.net.PasswordAuthentication; | |
| import java.net.ServerSocket; | |
| import java.net.Socket; | |
| import java.net.StandardSocketOptions; | |
| import java.net.URI; | |
| import java.net.URISyntaxException; | |
| import java.net.URL; | |
| import java.nio.charset.StandardCharsets; | |
| import java.security.MessageDigest; | |
| import java.security.NoSuchAlgorithmException; | |
| import java.time.Instant; | |
| import java.util.ArrayList; | |
| import java.util.Arrays; | |
| import java.util.Base64; | |
| import java.util.HexFormat; | |
| import java.util.List; | |
| import java.util.Locale; | |
| import java.util.Objects; | |
| import java.util.Optional; | |
| import java.util.Random; | |
| import java.util.StringTokenizer; | |
| import java.util.concurrent.CompletableFuture; | |
| import java.util.concurrent.CopyOnWriteArrayList; | |
| import java.util.concurrent.atomic.AtomicInteger; | |
| import java.util.stream.Collectors; | |
| import java.util.stream.Stream; | |
| import javax.net.ssl.SSLContext; | |
| import jdk.httpclient.test.lib.common.HttpServerAdapters.AbstractHttpAuthFilter.HttpAuthMode; | |
| import sun.net.www.HeaderParser; | |
| import java.net.http.HttpClient.Version; | |
| import jdk.httpclient.test.lib.common.HttpServerAdapters; | |
| import jdk.httpclient.test.lib.http2.Http2TestServer; | |
| /** | |
| * A simple HTTP server that supports Basic or Digest authentication. | |
| * By default this server will echo back whatever is present | |
| * in the request body. Note that the Digest authentication is | |
| * a test implementation implemented only for tests purposes. | |
| * @author danielfuchs | |
| */ | |
| public abstract class DigestEchoServer implements HttpServerAdapters { | |
| public static final boolean DEBUG = | |
| Boolean.parseBoolean(System.getProperty("test.debug", "false")); | |
| public static final boolean NO_LINGER = | |
| Boolean.parseBoolean(System.getProperty("test.nolinger", "false")); | |
| public static final boolean TUNNEL_REQUIRES_HOST = | |
| Boolean.parseBoolean(System.getProperty("test.requiresHost", "false")); | |
| public enum HttpAuthType { | |
| SERVER, PROXY, SERVER307, PROXY305; | |
| /* add PROXY_AND_SERVER and SERVER_PROXY_NONE */ | |
| public HttpAuthMode authMode() { | |
| return switch (this) { | |
| case SERVER, SERVER307 -> HttpAuthMode.SERVER; | |
| case PROXY, PROXY305 -> HttpAuthMode.PROXY; | |
| }; | |
| } | |
| }; | |
| public enum HttpAuthSchemeType { NONE, BASICSERVER, BASIC, DIGEST }; | |
| public static final HttpAuthType DEFAULT_HTTP_AUTH_TYPE = HttpAuthType.SERVER; | |
| public static final String DEFAULT_PROTOCOL_TYPE = "https"; | |
| public static final HttpAuthSchemeType DEFAULT_SCHEME_TYPE = HttpAuthSchemeType.DIGEST; | |
| public static class HttpTestAuthenticator extends Authenticator { | |
| private final String realm; | |
| private final String username; | |
| // Used to prevent incrementation of 'count' when calling the | |
| // authenticator from the server side. | |
| private final ThreadLocal<Boolean> skipCount = new ThreadLocal<>(); | |
| // count will be incremented every time getPasswordAuthentication() | |
| // is called from the client side. | |
| final AtomicInteger count = new AtomicInteger(); | |
| public HttpTestAuthenticator(String realm, String username) { | |
| this.realm = realm; | |
| this.username = username; | |
| } | |
| @Override | |
| protected PasswordAuthentication getPasswordAuthentication() { | |
| if (skipCount.get() == null || skipCount.get().booleanValue() == false) { | |
| System.out.println("Authenticator called: " + count.incrementAndGet()); | |
| } | |
| return new PasswordAuthentication(getUserName(), | |
| new char[] {'d','e','n', 't'}); | |
| } | |
| // Called by the server side to get the password of the user | |
| // being authentified. | |
| public final char[] getPassword(String user) { | |
| if (user.equals(username)) { | |
| skipCount.set(Boolean.TRUE); | |
| try { | |
| return getPasswordAuthentication().getPassword(); | |
| } finally { | |
| skipCount.set(Boolean.FALSE); | |
| } | |
| } | |
| throw new SecurityException("User unknown: " + user); | |
| } | |
| public final String getUserName() { | |
| return username; | |
| } | |
| public final String getRealm() { | |
| return realm; | |
| } | |
| } | |
| public static final HttpTestAuthenticator AUTHENTICATOR; | |
| static { | |
| AUTHENTICATOR = new HttpTestAuthenticator("earth", "arthur"); | |
| } | |
| final HttpTestServer serverImpl; // this server endpoint | |
| final DigestEchoServer redirect; // the target server where to redirect 3xx | |
| final HttpTestHandler delegate; // unused | |
| final String key; | |
| DigestEchoServer(String key, | |
| HttpTestServer server, | |
| DigestEchoServer target, | |
| HttpTestHandler delegate) { | |
| this.key = key; | |
| this.serverImpl = server; | |
| this.redirect = target; | |
| this.delegate = delegate; | |
| } | |
| public static void main(String[] args) | |
| throws IOException { | |
| DigestEchoServer server = create(Version.HTTP_1_1, | |
| DEFAULT_PROTOCOL_TYPE, | |
| DEFAULT_HTTP_AUTH_TYPE, | |
| AUTHENTICATOR, | |
| DEFAULT_SCHEME_TYPE); | |
| try { | |
| System.out.println("Server created at " + server.getAddress()); | |
| System.out.println("Strike <Return> to exit"); | |
| System.in.read(); | |
| } finally { | |
| System.out.println("stopping server"); | |
| server.stop(); | |
| } | |
| } | |
| private static String toString(HttpTestRequestHeaders headers) { | |
| return headers.entrySet().stream() | |
| .map((e) -> e.getKey() + ": " + e.getValue()) | |
| .collect(Collectors.joining("\n")); | |
| } | |
| public static DigestEchoServer create(Version version, | |
| String protocol, | |
| HttpAuthType authType, | |
| HttpAuthSchemeType schemeType) | |
| throws IOException { | |
| return create(version, protocol, authType, AUTHENTICATOR, schemeType); | |
| } | |
| public static DigestEchoServer create(Version version, | |
| String protocol, | |
| HttpAuthType authType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthSchemeType schemeType) | |
| throws IOException { | |
| return create(version, protocol, authType, auth, schemeType, null); | |
| } | |
| public static DigestEchoServer create(Version version, | |
| String protocol, | |
| HttpAuthType authType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthSchemeType schemeType, | |
| HttpTestHandler delegate) | |
| throws IOException { | |
| Objects.requireNonNull(authType); | |
| Objects.requireNonNull(auth); | |
| switch(authType) { | |
| // A server that performs Server Digest authentication. | |
| case SERVER: return createServer(version, protocol, authType, auth, | |
| schemeType, delegate, "/"); | |
| // A server that pretends to be a Proxy and performs | |
| // Proxy Digest authentication. If protocol is HTTPS, | |
| // then this will create a HttpsProxyTunnel that will | |
| // handle the CONNECT request for tunneling. | |
| case PROXY: return createProxy(version, protocol, authType, auth, | |
| schemeType, delegate, "/"); | |
| // A server that sends 307 redirect to a server that performs | |
| // Digest authentication. | |
| // Note: 301 doesn't work here because it transforms POST into GET. | |
| case SERVER307: return createServerAndRedirect(version, | |
| protocol, | |
| HttpAuthType.SERVER, | |
| auth, schemeType, | |
| delegate, 307); | |
| // A server that sends 305 redirect to a proxy that performs | |
| // Digest authentication. | |
| // Note: this is not correctly stubbed/implemented in this test. | |
| case PROXY305: return createServerAndRedirect(version, | |
| protocol, | |
| HttpAuthType.PROXY, | |
| auth, schemeType, | |
| delegate, 305); | |
| default: | |
| throw new InternalError("Unknown server type: " + authType); | |
| } | |
| } | |
| /** | |
| * The SocketBindableFactory ensures that the local port used by an HttpServer | |
| * or a proxy ServerSocket previously created by the current test/VM will not | |
| * get reused by a subsequent test in the same VM. | |
| * This is to avoid having the test client trying to reuse cached connections. | |
| */ | |
| private static abstract class SocketBindableFactory<B> { | |
| private static final int MAX = 10; | |
| private static final CopyOnWriteArrayList<String> addresses = | |
| new CopyOnWriteArrayList<>(); | |
| protected B createInternal() throws IOException { | |
| final int max = addresses.size() + MAX; | |
| final List<B> toClose = new ArrayList<>(); | |
| try { | |
| for (int i = 1; i <= max; i++) { | |
| B bindable = createBindable(); | |
| InetSocketAddress address = getAddress(bindable); | |
| String key = "localhost:" + address.getPort(); | |
| if (addresses.addIfAbsent(key)) { | |
| System.out.println("Socket bound to: " + key | |
| + " after " + i + " attempt(s)"); | |
| return bindable; | |
| } | |
| System.out.println("warning: address " + key | |
| + " already used. Retrying bind."); | |
| // keep the port bound until we get a port that we haven't | |
| // used already | |
| toClose.add(bindable); | |
| } | |
| } finally { | |
| // if we had to retry, then close the socket we're not | |
| // going to use. | |
| for (B b : toClose) { | |
| try { close(b); } catch (Exception x) { /* ignore */ } | |
| } | |
| } | |
| throw new IOException("Couldn't bind socket after " + max + " attempts: " | |
| + "addresses used before: " + addresses); | |
| } | |
| protected abstract B createBindable() throws IOException; | |
| protected abstract InetSocketAddress getAddress(B bindable); | |
| protected abstract void close(B bindable) throws IOException; | |
| } | |
| /* | |
| * Used to create ServerSocket for a proxy. | |
| */ | |
| private static final class ServerSocketFactory | |
| extends SocketBindableFactory<ServerSocket> { | |
| private static final ServerSocketFactory instance = new ServerSocketFactory(); | |
| static ServerSocket create() throws IOException { | |
| return instance.createInternal(); | |
| } | |
| @Override | |
| protected ServerSocket createBindable() throws IOException { | |
| ServerSocket ss = new ServerSocket(); | |
| ss.setReuseAddress(false); | |
| ss.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); | |
| return ss; | |
| } | |
| @Override | |
| protected InetSocketAddress getAddress(ServerSocket socket) { | |
| return new InetSocketAddress(socket.getInetAddress(), socket.getLocalPort()); | |
| } | |
| @Override | |
| protected void close(ServerSocket socket) throws IOException { | |
| socket.close(); | |
| } | |
| } | |
| /* | |
| * Used to create HttpServer | |
| */ | |
| private static abstract class H1ServerFactory<S extends HttpServer> | |
| extends SocketBindableFactory<S> { | |
| @Override | |
| protected S createBindable() throws IOException { | |
| S server = newHttpServer(); | |
| server.bind(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); | |
| return server; | |
| } | |
| @Override | |
| protected InetSocketAddress getAddress(S server) { | |
| return server.getAddress(); | |
| } | |
| @Override | |
| protected void close(S server) throws IOException { | |
| server.stop(1); | |
| } | |
| /* | |
| * Returns a HttpServer or a HttpsServer in different subclasses. | |
| */ | |
| protected abstract S newHttpServer() throws IOException; | |
| } | |
| /* | |
| * Used to create Http2TestServer | |
| */ | |
| private static abstract class H2ServerFactory<S extends Http2TestServer> | |
| extends SocketBindableFactory<S> { | |
| @Override | |
| protected S createBindable() throws IOException { | |
| final S server; | |
| try { | |
| server = newHttpServer(); | |
| } catch (IOException io) { | |
| throw io; | |
| } catch (Exception x) { | |
| throw new IOException(x); | |
| } | |
| return server; | |
| } | |
| @Override | |
| protected InetSocketAddress getAddress(S server) { | |
| return server.getAddress(); | |
| } | |
| @Override | |
| protected void close(S server) throws IOException { | |
| server.stop(); | |
| } | |
| /* | |
| * Returns a HttpServer or a HttpsServer in different subclasses. | |
| */ | |
| protected abstract S newHttpServer() throws Exception; | |
| } | |
| private static final class Http2ServerFactory extends H2ServerFactory<Http2TestServer> { | |
| private static final Http2ServerFactory instance = new Http2ServerFactory(); | |
| static Http2TestServer create() throws IOException { | |
| return instance.createInternal(); | |
| } | |
| @Override | |
| protected Http2TestServer newHttpServer() throws Exception { | |
| return new Http2TestServer("localhost", false, 0); | |
| } | |
| } | |
| private static final class Https2ServerFactory extends H2ServerFactory<Http2TestServer> { | |
| private static final Https2ServerFactory instance = new Https2ServerFactory(); | |
| static Http2TestServer create() throws IOException { | |
| return instance.createInternal(); | |
| } | |
| @Override | |
| protected Http2TestServer newHttpServer() throws Exception { | |
| return new Http2TestServer("localhost", true, 0); | |
| } | |
| } | |
| private static final class Http1ServerFactory extends H1ServerFactory<HttpServer> { | |
| private static final Http1ServerFactory instance = new Http1ServerFactory(); | |
| static HttpServer create() throws IOException { | |
| return instance.createInternal(); | |
| } | |
| @Override | |
| protected HttpServer newHttpServer() throws IOException { | |
| return HttpServer.create(); | |
| } | |
| } | |
| private static final class Https1ServerFactory extends H1ServerFactory<HttpsServer> { | |
| private static final Https1ServerFactory instance = new Https1ServerFactory(); | |
| static HttpsServer create() throws IOException { | |
| return instance.createInternal(); | |
| } | |
| @Override | |
| protected HttpsServer newHttpServer() throws IOException { | |
| return HttpsServer.create(); | |
| } | |
| } | |
| static Http2TestServer createHttp2Server(String protocol) throws IOException { | |
| final Http2TestServer server; | |
| if ("http".equalsIgnoreCase(protocol)) { | |
| server = Http2ServerFactory.create(); | |
| } else if ("https".equalsIgnoreCase(protocol)) { | |
| server = Https2ServerFactory.create(); | |
| } else { | |
| throw new InternalError("unsupported protocol: " + protocol); | |
| } | |
| return server; | |
| } | |
| static HttpTestServer createHttpServer(Version version, String protocol) | |
| throws IOException | |
| { | |
| switch(version) { | |
| case HTTP_1_1: | |
| return HttpTestServer.of(createHttp1Server(protocol)); | |
| case HTTP_2: | |
| return HttpTestServer.of(createHttp2Server(protocol)); | |
| default: | |
| throw new InternalError("Unexpected version: " + version); | |
| } | |
| } | |
| static HttpServer createHttp1Server(String protocol) throws IOException { | |
| final HttpServer server; | |
| if ("http".equalsIgnoreCase(protocol)) { | |
| server = Http1ServerFactory.create(); | |
| } else if ("https".equalsIgnoreCase(protocol)) { | |
| server = configure(Https1ServerFactory.create()); | |
| } else { | |
| throw new InternalError("unsupported protocol: " + protocol); | |
| } | |
| return server; | |
| } | |
| static HttpsServer configure(HttpsServer server) throws IOException { | |
| try { | |
| SSLContext ctx = SSLContext.getDefault(); | |
| server.setHttpsConfigurator(new Configurator(ctx)); | |
| } catch (NoSuchAlgorithmException ex) { | |
| throw new IOException(ex); | |
| } | |
| return server; | |
| } | |
| static BasicAuthenticator toBasicAuthenticator(HttpTestAuthenticator auth) { | |
| final String realm = auth.getRealm(); | |
| return new BasicAuthenticator(realm) { | |
| @Override | |
| public boolean checkCredentials(String username, String pwd) { | |
| return auth.getUserName().equals(username) | |
| && new String(auth.getPassword(username)).equals(pwd); | |
| } | |
| }; | |
| } | |
| static void setContextAuthenticator(HttpTestContext ctxt, | |
| HttpTestAuthenticator auth) { | |
| ctxt.setAuthenticator(toBasicAuthenticator(auth)); | |
| } | |
| public static DigestEchoServer createServer(Version version, | |
| String protocol, | |
| HttpAuthType authType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthSchemeType schemeType, | |
| HttpTestHandler delegate, | |
| String path) | |
| throws IOException { | |
| Objects.requireNonNull(authType); | |
| Objects.requireNonNull(auth); | |
| HttpTestServer impl = createHttpServer(version, protocol); | |
| String key = String.format("DigestEchoServer[PID=%s,PORT=%s]:%s:%s:%s:%s", | |
| ProcessHandle.current().pid(), | |
| impl.getAddress().getPort(), | |
| version, protocol, authType, schemeType); | |
| final DigestEchoServer server = new DigestEchoServerImpl(key, impl, null, delegate); | |
| final HttpTestHandler handler = | |
| server.createHandler(schemeType, auth, authType, false); | |
| HttpTestContext context = impl.addHandler(handler, path); | |
| server.configureAuthentication(context, schemeType, auth, authType); | |
| impl.start(); | |
| return server; | |
| } | |
| public static DigestEchoServer createProxy(Version version, | |
| String protocol, | |
| HttpAuthType authType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthSchemeType schemeType, | |
| HttpTestHandler delegate, | |
| String path) | |
| throws IOException { | |
| Objects.requireNonNull(authType); | |
| Objects.requireNonNull(auth); | |
| if (version == Version.HTTP_2 && protocol.equalsIgnoreCase("http")) { | |
| System.out.println("WARNING: can't use HTTP/1.1 proxy with unsecure HTTP/2 server"); | |
| version = Version.HTTP_1_1; | |
| } | |
| HttpTestServer impl = createHttpServer(version, protocol); | |
| String key = String.format("DigestEchoServer[PID=%s,PORT=%s]:%s:%s:%s:%s", | |
| ProcessHandle.current().pid(), | |
| impl.getAddress().getPort(), | |
| version, protocol, authType, schemeType); | |
| final DigestEchoServer server = "https".equalsIgnoreCase(protocol) | |
| ? new HttpsProxyTunnel(key, impl, null, delegate) | |
| : new DigestEchoServerImpl(key, impl, null, delegate); | |
| final HttpTestHandler hh = server.createHandler(HttpAuthSchemeType.NONE, | |
| null, HttpAuthType.SERVER, | |
| server instanceof HttpsProxyTunnel); | |
| HttpTestContext ctxt = impl.addHandler(hh, path); | |
| server.configureAuthentication(ctxt, schemeType, auth, authType); | |
| impl.start(); | |
| return server; | |
| } | |
| public static DigestEchoServer createServerAndRedirect( | |
| Version version, | |
| String protocol, | |
| HttpAuthType targetAuthType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthSchemeType schemeType, | |
| HttpTestHandler targetDelegate, | |
| int code300) | |
| throws IOException { | |
| Objects.requireNonNull(targetAuthType); | |
| Objects.requireNonNull(auth); | |
| // The connection between client and proxy can only | |
| // be a plain connection: SSL connection to proxy | |
| // is not supported by our client connection. | |
| String targetProtocol = targetAuthType == HttpAuthType.PROXY | |
| ? "http" | |
| : protocol; | |
| DigestEchoServer redirectTarget = | |
| (targetAuthType == HttpAuthType.PROXY) | |
| ? createProxy(version, protocol, targetAuthType, | |
| auth, schemeType, targetDelegate, "/") | |
| : createServer(version, targetProtocol, targetAuthType, | |
| auth, schemeType, targetDelegate, "/"); | |
| HttpTestServer impl = createHttpServer(version, protocol); | |
| String key = String.format("RedirectingServer[PID=%s,PORT=%s]:%s:%s:%s:%s", | |
| ProcessHandle.current().pid(), | |
| impl.getAddress().getPort(), | |
| version, protocol, | |
| HttpAuthType.SERVER, code300) | |
| + "->" + redirectTarget.key; | |
| final DigestEchoServer redirectingServer = | |
| new DigestEchoServerImpl(key, impl, redirectTarget, null); | |
| InetSocketAddress redirectAddr = redirectTarget.getAddress(); | |
| URL locationURL = url(targetProtocol, redirectAddr, "/"); | |
| final HttpTestHandler hh = redirectingServer.create300Handler(key, locationURL, | |
| HttpAuthType.SERVER, code300); | |
| impl.addHandler(hh,"/"); | |
| impl.start(); | |
| return redirectingServer; | |
| } | |
| public abstract InetSocketAddress getServerAddress(); | |
| public abstract InetSocketAddress getProxyAddress(); | |
| public abstract InetSocketAddress getAddress(); | |
| public abstract void stop(); | |
| public abstract Version getServerVersion(); | |
| private static class DigestEchoServerImpl extends DigestEchoServer { | |
| DigestEchoServerImpl(String key, | |
| HttpTestServer server, | |
| DigestEchoServer target, | |
| HttpTestHandler delegate) { | |
| super(key, Objects.requireNonNull(server), target, delegate); | |
| } | |
| public InetSocketAddress getAddress() { | |
| return new InetSocketAddress(InetAddress.getLoopbackAddress(), | |
| serverImpl.getAddress().getPort()); | |
| } | |
| public InetSocketAddress getServerAddress() { | |
| return new InetSocketAddress(InetAddress.getLoopbackAddress(), | |
| serverImpl.getAddress().getPort()); | |
| } | |
| public InetSocketAddress getProxyAddress() { | |
| return new InetSocketAddress(InetAddress.getLoopbackAddress(), | |
| serverImpl.getAddress().getPort()); | |
| } | |
| public Version getServerVersion() { | |
| return serverImpl.getVersion(); | |
| } | |
| public void stop() { | |
| serverImpl.stop(); | |
| if (redirect != null) { | |
| redirect.stop(); | |
| } | |
| } | |
| } | |
| protected void writeResponse(HttpTestExchange he) throws IOException { | |
| if (delegate == null) { | |
| he.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1); | |
| he.getResponseBody().write(he.getRequestBody().readAllBytes()); | |
| } else { | |
| delegate.handle(he); | |
| } | |
| } | |
| private HttpTestHandler createHandler(HttpAuthSchemeType schemeType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthType authType, | |
| boolean tunelled) { | |
| return new HttpNoAuthHandler(key, authType, tunelled); | |
| } | |
| void configureAuthentication(HttpTestContext ctxt, | |
| HttpAuthSchemeType schemeType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthType authType) { | |
| switch(schemeType) { | |
| case DIGEST: | |
| // DIGEST authentication is handled by the filter. | |
| ctxt.addFilter(new HttpDigestFilter(key, auth, authType)); | |
| break; | |
| case BASIC: | |
| // BASIC authentication is handled by the filter. | |
| ctxt.addFilter(new HttpBasicFilter(key, auth, authType)); | |
| break; | |
| case BASICSERVER: | |
| switch(authType) { | |
| case PROXY: case PROXY305: | |
| // HttpServer can't support Proxy-type authentication | |
| // => we do as if BASIC had been specified, and we will | |
| // handle authentication in the handler. | |
| ctxt.addFilter(new HttpBasicFilter(key, auth, authType)); | |
| break; | |
| case SERVER: case SERVER307: | |
| if (ctxt.getVersion() == Version.HTTP_1_1) { | |
| // Basic authentication is handled by HttpServer | |
| // directly => the filter should not perform | |
| // authentication again. | |
| setContextAuthenticator(ctxt, auth); | |
| ctxt.addFilter(new HttpNoAuthFilter(key, authType)); | |
| } else { | |
| ctxt.addFilter(new HttpBasicFilter(key, auth, authType)); | |
| } | |
| break; | |
| default: | |
| throw new InternalError(key + ": Invalid combination scheme=" | |
| + schemeType + " authType=" + authType); | |
| } | |
| break; | |
| case NONE: | |
| // No authentication at all. | |
| ctxt.addFilter(new HttpNoAuthFilter(key, authType)); | |
| break; | |
| default: | |
| throw new InternalError(key + ": No such scheme: " + schemeType); | |
| } | |
| } | |
| private HttpTestHandler create300Handler(String key, URL proxyURL, | |
| HttpAuthType type, int code300) | |
| throws MalformedURLException | |
| { | |
| return new Http3xxHandler(key, proxyURL, type, code300); | |
| } | |
| // WARNING: This is not a full fledged implementation of DIGEST. | |
| // It does contain bugs and inaccuracy. | |
| final static class DigestResponse { | |
| final String realm; | |
| final String username; | |
| final String nonce; | |
| final String cnonce; | |
| final String nc; | |
| final String uri; | |
| final String algorithm; | |
| final String response; | |
| final String qop; | |
| final String opaque; | |
| public DigestResponse(String realm, String username, String nonce, | |
| String cnonce, String nc, String uri, | |
| String algorithm, String qop, String opaque, | |
| String response) { | |
| this.realm = realm; | |
| this.username = username; | |
| this.nonce = nonce; | |
| this.cnonce = cnonce; | |
| this.nc = nc; | |
| this.uri = uri; | |
| this.algorithm = algorithm; | |
| this.qop = qop; | |
| this.opaque = opaque; | |
| this.response = response; | |
| } | |
| String getAlgorithm(String defval) { | |
| return algorithm == null ? defval : algorithm; | |
| } | |
| String getQoP(String defval) { | |
| return qop == null ? defval : qop; | |
| } | |
| // Code stolen from DigestAuthentication: | |
| private static String encode(String src, char[] passwd, MessageDigest md) { | |
| try { | |
| md.update(src.getBytes("ISO-8859-1")); | |
| } catch (java.io.UnsupportedEncodingException uee) { | |
| assert false; | |
| } | |
| if (passwd != null) { | |
| byte[] passwdBytes = new byte[passwd.length]; | |
| for (int i=0; i<passwd.length; i++) | |
| passwdBytes[i] = (byte)passwd[i]; | |
| md.update(passwdBytes); | |
| Arrays.fill(passwdBytes, (byte)0x00); | |
| } | |
| byte[] digest = md.digest(); | |
| return HexFormat.of().formatHex(digest); | |
| } | |
| public static String computeDigest(boolean isRequest, | |
| String reqMethod, | |
| char[] password, | |
| DigestResponse params) | |
| throws NoSuchAlgorithmException | |
| { | |
| String A1, HashA1; | |
| String algorithm = params.getAlgorithm("MD5"); | |
| boolean md5sess = algorithm.equalsIgnoreCase ("MD5-sess"); | |
| MessageDigest md = MessageDigest.getInstance(md5sess?"MD5":algorithm); | |
| if (params.username == null) { | |
| throw new IllegalArgumentException("missing username"); | |
| } | |
| if (params.realm == null) { | |
| throw new IllegalArgumentException("missing realm"); | |
| } | |
| if (params.uri == null) { | |
| throw new IllegalArgumentException("missing uri"); | |
| } | |
| if (params.nonce == null) { | |
| throw new IllegalArgumentException("missing nonce"); | |
| } | |
| A1 = params.username + ":" + params.realm + ":"; | |
| HashA1 = encode(A1, password, md); | |
| String A2; | |
| if (isRequest) { | |
| A2 = reqMethod + ":" + params.uri; | |
| } else { | |
| A2 = ":" + params.uri; | |
| } | |
| String HashA2 = encode(A2, null, md); | |
| String combo, finalHash; | |
| if ("auth".equals(params.qop)) { /* RRC2617 when qop=auth */ | |
| if (params.cnonce == null) { | |
| throw new IllegalArgumentException("missing nonce"); | |
| } | |
| if (params.nc == null) { | |
| throw new IllegalArgumentException("missing nonce"); | |
| } | |
| combo = HashA1+ ":" + params.nonce + ":" + params.nc + ":" + | |
| params.cnonce + ":auth:" +HashA2; | |
| } else { /* for compatibility with RFC2069 */ | |
| combo = HashA1 + ":" + | |
| params.nonce + ":" + | |
| HashA2; | |
| } | |
| finalHash = encode(combo, null, md); | |
| return finalHash; | |
| } | |
| public static DigestResponse create(String raw) { | |
| String username, realm, nonce, nc, uri, response, cnonce, | |
| algorithm, qop, opaque; | |
| HeaderParser parser = new HeaderParser(raw); | |
| username = parser.findValue("username"); | |
| realm = parser.findValue("realm"); | |
| nonce = parser.findValue("nonce"); | |
| nc = parser.findValue("nc"); | |
| uri = parser.findValue("uri"); | |
| cnonce = parser.findValue("cnonce"); | |
| response = parser.findValue("response"); | |
| algorithm = parser.findValue("algorithm"); | |
| qop = parser.findValue("qop"); | |
| opaque = parser.findValue("opaque"); | |
| return new DigestResponse(realm, username, nonce, cnonce, nc, uri, | |
| algorithm, qop, opaque, response); | |
| } | |
| } | |
| private static class HttpNoAuthFilter extends AbstractHttpAuthFilter { | |
| static String type(String key, HttpAuthType authType) { | |
| String type = authType.authMode() == HttpAuthMode.SERVER | |
| ? "NoAuth Server Filter" : "NoAuth Proxy Filter"; | |
| return "["+type+"]:"+key; | |
| } | |
| public HttpNoAuthFilter(String key, HttpAuthType authType) { | |
| super(authType.authMode(), type(key, authType)); | |
| } | |
| @Override | |
| protected boolean isAuthentified(HttpTestExchange he) throws IOException { | |
| return true; | |
| } | |
| @Override | |
| protected void requestAuthentication(HttpTestExchange he) throws IOException { | |
| throw new InternalError("Should not com here"); | |
| } | |
| @Override | |
| public String description() { | |
| return "Passthrough Filter"; | |
| } | |
| } | |
| // An HTTP Filter that performs Basic authentication | |
| private static class HttpBasicFilter extends HttpBasicAuthFilter { | |
| static String type(String key, HttpAuthType authType) { | |
| String type = authType.authMode() == HttpAuthMode.SERVER | |
| ? "Basic Server Filter" : "Basic Proxy Filter"; | |
| return "["+type+"]:"+key; | |
| } | |
| public HttpBasicFilter(String key, HttpTestAuthenticator auth, | |
| HttpAuthType authType) { | |
| super(toBasicAuthenticator(auth), authType.authMode(), type(key, authType)); | |
| } | |
| @Override | |
| public String description() { | |
| return "Filter for BASIC authentication: " + type(); | |
| } | |
| } | |
| // An HTTP Filter that performs Digest authentication | |
| // WARNING: This is not a full fledged implementation of DIGEST. | |
| // It does contain bugs and inaccuracy. | |
| private static class HttpDigestFilter extends AbstractHttpAuthFilter { | |
| static String type(String key, HttpAuthType authType) { | |
| String type = authType.authMode() == HttpAuthMode.SERVER | |
| ? "Digest Server Filter" : "Digest Proxy Filter"; | |
| return "["+type+"]:"+key; | |
| } | |
| // This is a very basic DIGEST - used only for the purpose of testing | |
| // the client implementation. Therefore we can get away with never | |
| // updating the server nonce as it makes the implementation of the | |
| // server side digest simpler. | |
| private final HttpTestAuthenticator auth; | |
| private final byte[] nonce; | |
| private final String ns; | |
| private final String type; | |
| public HttpDigestFilter(String key, HttpTestAuthenticator auth, HttpAuthType authType) { | |
| super(authType.authMode(), type(key, authType)); | |
| this.auth = auth; | |
| nonce = new byte[16]; | |
| new Random(Instant.now().toEpochMilli()).nextBytes(nonce); | |
| ns = new BigInteger(1, nonce).toString(16); | |
| this.type = type(); | |
| } | |
| @Override | |
| protected void requestAuthentication(HttpTestExchange he) | |
| throws IOException { | |
| String separator; | |
| Version v = he.getExchangeVersion(); | |
| if (v == Version.HTTP_1_1) { | |
| separator = "\r\n "; | |
| } else if (v == Version.HTTP_2) { | |
| separator = " "; | |
| } else { | |
| throw new InternalError(String.valueOf(v)); | |
| } | |
| String headerName = getAuthenticate(); | |
| String headerValue = "Digest realm=\"" + auth.getRealm() + "\"," | |
| + separator + "qop=\"auth\"," | |
| + separator + "nonce=\"" + ns +"\""; | |
| he.getResponseHeaders().addHeader(headerName, headerValue); | |
| System.out.println(type + ": Requesting Digest Authentication, " | |
| + headerName + " : " + headerValue); | |
| } | |
| @Override | |
| protected boolean isAuthentified(HttpTestExchange he) { | |
| if (he.getRequestHeaders().containsKey(getAuthorization())) { | |
| List<String> authorization = he.getRequestHeaders().get(getAuthorization()); | |
| for (String a : authorization) { | |
| System.out.println(type + ": processing " + a); | |
| int sp = a.indexOf(' '); | |
| if (sp < 0) return false; | |
| String scheme = a.substring(0, sp); | |
| if (!"Digest".equalsIgnoreCase(scheme)) { | |
| System.out.println(type + ": Unsupported scheme '" + scheme +"'"); | |
| return false; | |
| } | |
| if (a.length() <= sp+1) { | |
| System.out.println(type + ": value too short for '" + scheme +"'"); | |
| return false; | |
| } | |
| a = a.substring(sp+1); | |
| DigestResponse dgr = DigestResponse.create(a); | |
| return validate(he.getRequestURI(), he.getRequestMethod(), dgr); | |
| } | |
| return false; | |
| } | |
| return false; | |
| } | |
| boolean validate(URI uri, String reqMethod, DigestResponse dg) { | |
| if (!"MD5".equalsIgnoreCase(dg.getAlgorithm("MD5"))) { | |
| System.out.println(type + ": Unsupported algorithm " | |
| + dg.algorithm); | |
| return false; | |
| } | |
| if (!"auth".equalsIgnoreCase(dg.getQoP("auth"))) { | |
| System.out.println(type + ": Unsupported qop " | |
| + dg.qop); | |
| return false; | |
| } | |
| try { | |
| if (!dg.nonce.equals(ns)) { | |
| System.out.println(type + ": bad nonce returned by client: " | |
| + nonce + " expected " + ns); | |
| return false; | |
| } | |
| if (dg.response == null) { | |
| System.out.println(type + ": missing digest response."); | |
| return false; | |
| } | |
| char[] pa = auth.getPassword(dg.username); | |
| return verify(uri, reqMethod, dg, pa); | |
| } catch(IllegalArgumentException | SecurityException | |
| | NoSuchAlgorithmException e) { | |
| System.out.println(type + ": " + e.getMessage()); | |
| return false; | |
| } | |
| } | |
| boolean verify(URI uri, String reqMethod, DigestResponse dg, char[] pw) | |
| throws NoSuchAlgorithmException { | |
| String response = DigestResponse.computeDigest(true, reqMethod, pw, dg); | |
| if (!dg.response.equals(response)) { | |
| System.out.println(type + ": bad response returned by client: " | |
| + dg.response + " expected " + response); | |
| return false; | |
| } else { | |
| // A real server would also verify the uri=<request-uri> | |
| // parameter - but this is just a test... | |
| System.out.println(type + ": verified response " + response); | |
| } | |
| return true; | |
| } | |
| @Override | |
| public String description() { | |
| return "Filter for DIGEST authentication: " + type; | |
| } | |
| } | |
| // Abstract HTTP handler class. | |
| private abstract static class AbstractHttpHandler implements HttpTestHandler { | |
| final HttpAuthType authType; | |
| final String type; | |
| public AbstractHttpHandler(HttpAuthType authType, String type) { | |
| this.authType = authType; | |
| this.type = type; | |
| } | |
| String getLocation() { | |
| return "Location"; | |
| } | |
| @Override | |
| public void handle(HttpTestExchange he) throws IOException { | |
| try { | |
| sendResponse(he); | |
| } catch (RuntimeException | Error | IOException t) { | |
| System.err.println(type | |
| + ": Unexpected exception while handling request: " + t); | |
| t.printStackTrace(System.err); | |
| throw t; | |
| } finally { | |
| he.close(); | |
| } | |
| } | |
| protected abstract void sendResponse(HttpTestExchange he) throws IOException; | |
| } | |
| static String stype(String type, String key, HttpAuthType authType, boolean tunnelled) { | |
| type = type + (authType == HttpAuthType.SERVER | |
| ? " Server" : " Proxy") | |
| + (tunnelled ? " Tunnelled" : ""); | |
| return "["+type+"]:"+key; | |
| } | |
| private class HttpNoAuthHandler extends AbstractHttpHandler { | |
| // true if this server is behind a proxy tunnel. | |
| final boolean tunnelled; | |
| public HttpNoAuthHandler(String key, HttpAuthType authType, boolean tunnelled) { | |
| super(authType, stype("NoAuth", key, authType, tunnelled)); | |
| this.tunnelled = tunnelled; | |
| } | |
| @Override | |
| protected void sendResponse(HttpTestExchange he) throws IOException { | |
| if (DEBUG) { | |
| System.out.println(type + ": headers are: " | |
| + DigestEchoServer.toString(he.getRequestHeaders())); | |
| } | |
| if (authType == HttpAuthType.SERVER && tunnelled) { | |
| // Verify that the client doesn't send us proxy-* headers | |
| // used to establish the proxy tunnel | |
| Optional<String> proxyAuth = he.getRequestHeaders() | |
| .keySet().stream() | |
| .filter("proxy-authorization"::equalsIgnoreCase) | |
| .findAny(); | |
| if (proxyAuth.isPresent()) { | |
| System.out.println(type + " found " | |
| + proxyAuth.get() + ": failing!"); | |
| throw new IOException(proxyAuth.get() | |
| + " found by " + type + " for " | |
| + he.getRequestURI()); | |
| } | |
| } | |
| DigestEchoServer.this.writeResponse(he); | |
| } | |
| } | |
| // A dummy HTTP Handler that redirects all incoming requests | |
| // by sending a back 3xx response code (301, 305, 307 etc..) | |
| private class Http3xxHandler extends AbstractHttpHandler { | |
| private final URL redirectTargetURL; | |
| private final int code3XX; | |
| public Http3xxHandler(String key, URL proxyURL, HttpAuthType authType, int code300) { | |
| super(authType, stype("Server" + code300, key, authType, false)); | |
| this.redirectTargetURL = proxyURL; | |
| this.code3XX = code300; | |
| } | |
| int get3XX() { | |
| return code3XX; | |
| } | |
| @Override | |
| public void sendResponse(HttpTestExchange he) throws IOException { | |
| System.out.println(type + ": Got " + he.getRequestMethod() | |
| + ": " + he.getRequestURI() | |
| + "\n" + DigestEchoServer.toString(he.getRequestHeaders())); | |
| System.out.println(type + ": Redirecting to " | |
| + (authType == HttpAuthType.PROXY305 | |
| ? "proxy" : "server")); | |
| he.getResponseHeaders().addHeader(getLocation(), | |
| redirectTargetURL.toExternalForm().toString()); | |
| he.sendResponseHeaders(get3XX(), -1); | |
| System.out.println(type + ": Sent back " + get3XX() + " " | |
| + getLocation() + ": " + redirectTargetURL.toExternalForm().toString()); | |
| } | |
| } | |
| static class Configurator extends HttpsConfigurator { | |
| public Configurator(SSLContext ctx) { | |
| super(ctx); | |
| } | |
| @Override | |
| public void configure (HttpsParameters params) { | |
| params.setSSLParameters (getSSLContext().getSupportedSSLParameters()); | |
| } | |
| } | |
| static final long start = System.nanoTime(); | |
| public static String now() { | |
| long now = System.nanoTime() - start; | |
| long secs = now / 1000_000_000; | |
| long mill = (now % 1000_000_000) / 1000_000; | |
| long nan = now % 1000_000; | |
| return String.format("[%d s, %d ms, %d ns] ", secs, mill, nan); | |
| } | |
| static class ProxyAuthorization { | |
| final HttpAuthSchemeType schemeType; | |
| final HttpTestAuthenticator authenticator; | |
| private final byte[] nonce; | |
| private final String ns; | |
| private final String key; | |
| ProxyAuthorization(String key, HttpAuthSchemeType schemeType, HttpTestAuthenticator auth) { | |
| this.key = key; | |
| this.schemeType = schemeType; | |
| this.authenticator = auth; | |
| nonce = new byte[16]; | |
| new Random(Instant.now().toEpochMilli()).nextBytes(nonce); | |
| ns = new BigInteger(1, nonce).toString(16); | |
| } | |
| String doBasic(Optional<String> authorization) { | |
| String offset = "proxy-authorization: basic "; | |
| String authstring = authorization.orElse(""); | |
| if (!authstring.toLowerCase(Locale.US).startsWith(offset)) { | |
| return "Proxy-Authenticate: BASIC " + "realm=\"" | |
| + authenticator.getRealm() +"\""; | |
| } | |
| authstring = authstring | |
| .substring(offset.length()) | |
| .trim(); | |
| byte[] base64 = Base64.getDecoder().decode(authstring); | |
| String up = new String(base64, StandardCharsets.UTF_8); | |
| int colon = up.indexOf(':'); | |
| if (colon < 1) { | |
| return "Proxy-Authenticate: BASIC " + "realm=\"" | |
| + authenticator.getRealm() +"\""; | |
| } | |
| String u = up.substring(0, colon); | |
| String p = up.substring(colon+1); | |
| char[] pw = authenticator.getPassword(u); | |
| if (!p.equals(new String(pw))) { | |
| return "Proxy-Authenticate: BASIC " + "realm=\"" | |
| + authenticator.getRealm() +"\""; | |
| } | |
| System.out.println(now() + key + " Proxy basic authentication success"); | |
| return null; | |
| } | |
| String doDigest(Optional<String> authorization) { | |
| String offset = "proxy-authorization: digest "; | |
| String authstring = authorization.orElse(""); | |
| if (!authstring.toLowerCase(Locale.US).startsWith(offset)) { | |
| return "Proxy-Authenticate: " + | |
| "Digest realm=\"" + authenticator.getRealm() + "\"," | |
| + "\r\n qop=\"auth\"," | |
| + "\r\n nonce=\"" + ns +"\""; | |
| } | |
| authstring = authstring | |
| .substring(offset.length()) | |
| .trim(); | |
| boolean validated = false; | |
| try { | |
| DigestResponse dgr = DigestResponse.create(authstring); | |
| validated = validate("CONNECT", dgr); | |
| } catch (Throwable t) { | |
| t.printStackTrace(); | |
| } | |
| if (!validated) { | |
| return "Proxy-Authenticate: " + | |
| "Digest realm=\"" + authenticator.getRealm() + "\"," | |
| + "\r\n qop=\"auth\"," | |
| + "\r\n nonce=\"" + ns +"\""; | |
| } | |
| return null; | |
| } | |
| boolean validate(String reqMethod, DigestResponse dg) { | |
| String type = now() + this.getClass().getSimpleName() + ":" + key; | |
| if (!"MD5".equalsIgnoreCase(dg.getAlgorithm("MD5"))) { | |
| System.out.println(type + ": Unsupported algorithm " | |
| + dg.algorithm); | |
| return false; | |
| } | |
| if (!"auth".equalsIgnoreCase(dg.getQoP("auth"))) { | |
| System.out.println(type + ": Unsupported qop " | |
| + dg.qop); | |
| return false; | |
| } | |
| try { | |
| if (!dg.nonce.equals(ns)) { | |
| System.out.println(type + ": bad nonce returned by client: " | |
| + nonce + " expected " + ns); | |
| return false; | |
| } | |
| if (dg.response == null) { | |
| System.out.println(type + ": missing digest response."); | |
| return false; | |
| } | |
| char[] pa = authenticator.getPassword(dg.username); | |
| return verify(type, reqMethod, dg, pa); | |
| } catch(IllegalArgumentException | SecurityException | |
| | NoSuchAlgorithmException e) { | |
| System.out.println(type + ": " + e.getMessage()); | |
| return false; | |
| } | |
| } | |
| boolean verify(String type, String reqMethod, DigestResponse dg, char[] pw) | |
| throws NoSuchAlgorithmException { | |
| String response = DigestResponse.computeDigest(true, reqMethod, pw, dg); | |
| if (!dg.response.equals(response)) { | |
| System.out.println(type + ": bad response returned by client: " | |
| + dg.response + " expected " + response); | |
| return false; | |
| } else { | |
| // A real server would also verify the uri=<request-uri> | |
| // parameter - but this is just a test... | |
| System.out.println(type + ": verified response " + response); | |
| } | |
| return true; | |
| } | |
| public boolean authorize(StringBuilder response, String requestLine, String headers) { | |
| String message = "<html><body><p>Authorization Failed%s</p></body></html>\r\n"; | |
| if (authenticator == null && schemeType != HttpAuthSchemeType.NONE) { | |
| message = String.format(message, " No Authenticator Set"); | |
| response.append("HTTP/1.1 407 Proxy Authentication Failed\r\n"); | |
| response.append("Content-Length: ") | |
| .append(message.getBytes(StandardCharsets.UTF_8).length) | |
| .append("\r\n\r\n"); | |
| response.append(message); | |
| return false; | |
| } | |
| Optional<String> authorization = Stream.of(headers.split("\r\n")) | |
| .filter((k) -> k.toLowerCase(Locale.US).startsWith("proxy-authorization:")) | |
| .findFirst(); | |
| String authenticate = null; | |
| switch(schemeType) { | |
| case BASIC: | |
| case BASICSERVER: | |
| authenticate = doBasic(authorization); | |
| break; | |
| case DIGEST: | |
| authenticate = doDigest(authorization); | |
| break; | |
| case NONE: | |
| response.append("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); | |
| return true; | |
| default: | |
| throw new InternalError("Unknown scheme type: " + schemeType); | |
| } | |
| if (authenticate != null) { | |
| message = String.format(message, ""); | |
| response.append("HTTP/1.1 407 Proxy Authentication Required\r\n"); | |
| response.append("Content-Length: ") | |
| .append(message.getBytes(StandardCharsets.UTF_8).length) | |
| .append("\r\n") | |
| .append(authenticate) | |
| .append("\r\n\r\n"); | |
| response.append(message); | |
| return false; | |
| } | |
| response.append("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); | |
| return true; | |
| } | |
| } | |
| public interface TunnelingProxy { | |
| InetSocketAddress getProxyAddress(); | |
| void stop(); | |
| } | |
| // This is a bit hacky: HttpsProxyTunnel is an HTTPTestServer hidden | |
| // behind a fake proxy that only understands CONNECT requests. | |
| // The fake proxy is just a server socket that intercept the | |
| // CONNECT and then redirect streams to the real server. | |
| static class HttpsProxyTunnel extends DigestEchoServer | |
| implements Runnable, TunnelingProxy { | |
| final ServerSocket ss; | |
| final CopyOnWriteArrayList<CompletableFuture<Void>> connectionCFs | |
| = new CopyOnWriteArrayList<>(); | |
| volatile ProxyAuthorization authorization; | |
| volatile boolean stopped; | |
| public HttpsProxyTunnel(String key, HttpTestServer server, DigestEchoServer target, | |
| HttpTestHandler delegate) | |
| throws IOException { | |
| this(key, server, target, delegate, ServerSocketFactory.create()); | |
| } | |
| private HttpsProxyTunnel(String key, HttpTestServer server, DigestEchoServer target, | |
| HttpTestHandler delegate, ServerSocket ss) | |
| throws IOException { | |
| super("HttpsProxyTunnel:" + ss.getLocalPort() + ":" + key, | |
| server, target, delegate); | |
| System.out.flush(); | |
| System.err.println("WARNING: HttpsProxyTunnel is an experimental test class"); | |
| this.ss = ss; | |
| start(); | |
| } | |
| final void start() throws IOException { | |
| Thread t = new Thread(this, "ProxyThread"); | |
| t.setDaemon(true); | |
| t.start(); | |
| } | |
| @Override | |
| public Version getServerVersion() { | |
| // serverImpl is not null when this proxy | |
| // serves a single server. It will be null | |
| // if this proxy can serve multiple servers. | |
| if (serverImpl != null) return serverImpl.getVersion(); | |
| return null; | |
| } | |
| @Override | |
| public void stop() { | |
| stopped = true; | |
| if (serverImpl != null) { | |
| serverImpl.stop(); | |
| } | |
| if (redirect != null) { | |
| redirect.stop(); | |
| } | |
| try { | |
| ss.close(); | |
| } catch (IOException ex) { | |
| if (DEBUG) ex.printStackTrace(System.out); | |
| } | |
| } | |
| @Override | |
| void configureAuthentication(HttpTestContext ctxt, | |
| HttpAuthSchemeType schemeType, | |
| HttpTestAuthenticator auth, | |
| HttpAuthType authType) { | |
| if (authType == HttpAuthType.PROXY || authType == HttpAuthType.PROXY305) { | |
| authorization = new ProxyAuthorization(key, schemeType, auth); | |
| } else { | |
| super.configureAuthentication(ctxt, schemeType, auth, authType); | |
| } | |
| } | |
| boolean badRequest(StringBuilder response, String hostport, List<String> hosts) { | |
| String message = null; | |
| if (hosts.isEmpty()) { | |
| message = "No host header provided\r\n"; | |
| } else if (hosts.size() > 1) { | |
| message = "Multiple host headers provided\r\n"; | |
| for (String h : hosts) { | |
| message = message + "host: " + h + "\r\n"; | |
| } | |
| } else { | |
| String h = hosts.get(0); | |
| if (!hostport.equalsIgnoreCase(h) | |
| && !hostport.equalsIgnoreCase(h + ":80") | |
| && !hostport.equalsIgnoreCase(h + ":443")) { | |
| message = "Bad host provided: [" + h | |
| + "] doesnot match [" + hostport + "]\r\n"; | |
| } | |
| } | |
| if (message != null) { | |
| int length = message.getBytes(StandardCharsets.UTF_8).length; | |
| response.append("HTTP/1.1 400 BadRequest\r\n") | |
| .append("Content-Length: " + length) | |
| .append("\r\n\r\n") | |
| .append(message); | |
| return true; | |
| } | |
| return false; | |
| } | |
| boolean authorize(StringBuilder response, String requestLine, String headers) { | |
| if (authorization != null) { | |
| return authorization.authorize(response, requestLine, headers); | |
| } | |
| response.append("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); | |
| return true; | |
| } | |
| // Pipe the input stream to the output stream. | |
| private synchronized Thread pipe(InputStream is, OutputStream os, char tag, CompletableFuture<Void> end) { | |
| return new Thread("TunnelPipe("+tag+")") { | |
| @Override | |
| public void run() { | |
| try { | |
| int len = 0; | |
| byte[] buf = new byte[16 * 1024]; | |
| try { | |
| while ((len = is.read(buf)) != -1) { | |
| os.write(buf, 0, len); | |
| os.flush(); | |
| // if DEBUG prints a + or a - for each transferred | |
| // character. | |
| if (DEBUG) System.out.print(String.valueOf(tag).repeat(len)); | |
| } | |
| is.close(); | |
| } catch (IOException ex) { | |
| if (DEBUG || !stopped && len > -1) | |
| ex.printStackTrace(System.out); | |
| end.completeExceptionally(ex); | |
| } finally { | |
| try {os.close();} catch (Throwable t) {} | |
| } | |
| } finally { | |
| end.complete(null); | |
| } | |
| } | |
| }; | |
| } | |
| @Override | |
| public InetSocketAddress getAddress() { | |
| return new InetSocketAddress(InetAddress.getLoopbackAddress(), | |
| ss.getLocalPort()); | |
| } | |
| @Override | |
| public InetSocketAddress getProxyAddress() { | |
| return getAddress(); | |
| } | |
| @Override | |
| public InetSocketAddress getServerAddress() { | |
| // serverImpl can be null if this proxy can serve | |
| // multiple servers. | |
| if (serverImpl != null) { | |
| return serverImpl.getAddress(); | |
| } | |
| return null; | |
| } | |
| // This is a bit shaky. It doesn't handle continuation | |
| // lines, but our client shouldn't send any. | |
| // Read a line from the input stream, swallowing the final | |
| // \r\n sequence. Stops at the first \n, doesn't complain | |
| // if it wasn't preceded by '\r'. | |
| // | |
| String readLine(InputStream r) throws IOException { | |
| StringBuilder b = new StringBuilder(); | |
| int c; | |
| while ((c = r.read()) != -1) { | |
| if (c == '\n') break; | |
| b.appendCodePoint(c); | |
| } | |
| if (b.codePointAt(b.length() -1) == '\r') { | |
| b.delete(b.length() -1, b.length()); | |
| } | |
| return b.toString(); | |
| } | |
| @Override | |
| public void run() { | |
| Socket clientConnection = null; | |
| Socket targetConnection = null; | |
| try { | |
| while (!stopped) { | |
| System.out.println(now() + "Tunnel: Waiting for client"); | |
| Socket toClose; | |
| targetConnection = clientConnection = null; | |
| try { | |
| toClose = clientConnection = ss.accept(); | |
| if (NO_LINGER) { | |
| // can be useful to trigger "Connection reset by peer" | |
| // errors on the client side. | |
| clientConnection.setOption(StandardSocketOptions.SO_LINGER, 0); | |
| } | |
| } catch (IOException io) { | |
| if (DEBUG || !stopped) io.printStackTrace(System.out); | |
| break; | |
| } | |
| System.out.println(now() + "Tunnel: Client accepted"); | |
| StringBuilder headers = new StringBuilder(); | |
| InputStream ccis = clientConnection.getInputStream(); | |
| OutputStream ccos = clientConnection.getOutputStream(); | |
| Writer w = new OutputStreamWriter( | |
| clientConnection.getOutputStream(), "UTF-8"); | |
| PrintWriter pw = new PrintWriter(w); | |
| System.out.println(now() + "Tunnel: Reading request line"); | |
| String requestLine = readLine(ccis); | |
| System.out.println(now() + "Tunnel: Request line: " + requestLine); | |
| if (requestLine.startsWith("CONNECT ")) { | |
| // We should probably check that the next word following | |
| // CONNECT is the host:port of our HTTPS serverImpl. | |
| // Some improvement for a followup! | |
| StringTokenizer tokenizer = new StringTokenizer(requestLine); | |
| String connect = tokenizer.nextToken(); | |
| assert connect.equalsIgnoreCase("connect"); | |
| String hostport = tokenizer.nextToken(); | |
| InetSocketAddress targetAddress; | |
| List<String> hosts = new ArrayList<>(); | |
| try { | |
| URI uri = new URI("https", hostport, "/", null, null); | |
| int port = uri.getPort(); | |
| port = port == -1 ? 443 : port; | |
| targetAddress = new InetSocketAddress(uri.getHost(), port); | |
| if (serverImpl != null) { | |
| assert targetAddress.getHostString() | |
| .equalsIgnoreCase(serverImpl.getAddress().getHostString()); | |
| assert targetAddress.getPort() == serverImpl.getAddress().getPort(); | |
| } | |
| } catch (Throwable x) { | |
| System.err.printf("Bad target address: \"%s\" in \"%s\"%n", | |
| hostport, requestLine); | |
| toClose.close(); | |
| continue; | |
| } | |
| // Read all headers until we find the empty line that | |
| // signals the end of all headers. | |
| String line = requestLine; | |
| while(!line.equals("")) { | |
| System.out.println(now() + "Tunnel: Reading header: " | |
| + (line = readLine(ccis))); | |
| headers.append(line).append("\r\n"); | |
| int index = line.indexOf(':'); | |
| if (index >= 0) { | |
| String key = line.substring(0, index).trim(); | |
| if (key.equalsIgnoreCase("host")) { | |
| hosts.add(line.substring(index+1).trim()); | |
| } | |
| } | |
| } | |
| StringBuilder response = new StringBuilder(); | |
| if (TUNNEL_REQUIRES_HOST) { | |
| if (badRequest(response, hostport, hosts)) { | |
| System.out.println(now() + "Tunnel: Sending " + response); | |
| // send the 400 response | |
| pw.print(response.toString()); | |
| pw.flush(); | |
| toClose.close(); | |
| continue; | |
| } else { | |
| assert hosts.size() == 1; | |
| System.out.println(now() | |
| + "Tunnel: Host header verified " + hosts); | |
| } | |
| } | |
| final boolean authorize = authorize(response, requestLine, headers.toString()); | |
| if (!authorize) { | |
| System.out.println(now() + "Tunnel: Sending " | |
| + response); | |
| // send the 407 response | |
| pw.print(response.toString()); | |
| pw.flush(); | |
| toClose.close(); | |
| continue; | |
| } | |
| System.out.println(now() | |
| + "Tunnel connecting to target server at " | |
| + targetAddress.getAddress() + ":" + targetAddress.getPort()); | |
| targetConnection = new Socket( | |
| targetAddress.getAddress(), | |
| targetAddress.getPort()); | |
| // Then send the 200 OK response to the client | |
| System.out.println(now() + "Tunnel: Sending " | |
| + response); | |
| pw.print(response); | |
| pw.flush(); | |
| } else { | |
| // This should not happen. If it does then just print an | |
| // error - both on out and err, and close the accepted | |
| // socket | |
| System.out.println("WARNING: Tunnel: Unexpected status line: " | |
| + requestLine + " received by " | |
| + ss.getLocalSocketAddress() | |
| + " from " | |
| + toClose.getRemoteSocketAddress() | |
| + " - closing accepted socket"); | |
| // Print on err | |
| System.err.println("WARNING: Tunnel: Unexpected status line: " | |
| + requestLine + " received by " | |
| + ss.getLocalSocketAddress() | |
| + " from " | |
| + toClose.getRemoteSocketAddress()); | |
| // close accepted socket. | |
| toClose.close(); | |
| System.err.println("Tunnel: accepted socket closed."); | |
| continue; | |
| } | |
| // Pipe the input stream of the client connection to the | |
| // output stream of the target connection and conversely. | |
| // Now the client and target will just talk to each other. | |
| System.out.println(now() + "Tunnel: Starting tunnel pipes"); | |
| CompletableFuture<Void> end, end1, end2; | |
| Thread t1 = pipe(ccis, targetConnection.getOutputStream(), '+', | |
| end1 = new CompletableFuture<>()); | |
| Thread t2 = pipe(targetConnection.getInputStream(), ccos, '-', | |
| end2 = new CompletableFuture<>()); | |
| var end11 = end1.whenComplete((r, t) -> exceptionally(end2, t)); | |
| var end22 = end2.whenComplete((r, t) -> exceptionally(end1, t)); | |
| end = CompletableFuture.allOf(end11, end22); | |
| Socket tc = targetConnection; | |
| end.whenComplete( | |
| (r,t) -> { | |
| try { toClose.close(); } catch (IOException x) { } | |
| try { tc.close(); } catch (IOException x) { } | |
| finally {connectionCFs.remove(end);} | |
| }); | |
| connectionCFs.add(end); | |
| targetConnection = clientConnection = null; | |
| t1.start(); | |
| t2.start(); | |
| } | |
| } catch (Throwable ex) { | |
| close(clientConnection, ex); | |
| close(targetConnection, ex); | |
| close(ss, ex); | |
| ex.printStackTrace(System.err); | |
| } finally { | |
| System.out.println(now() + "Tunnel: exiting (stopped=" + stopped + ")"); | |
| connectionCFs.forEach(cf -> cf.complete(null)); | |
| } | |
| } | |
| void exceptionally(CompletableFuture<?> cf, Throwable t) { | |
| if (t != null) cf.completeExceptionally(t); | |
| } | |
| void close(Closeable c, Throwable e) { | |
| if (c == null) return; | |
| try { | |
| c.close(); | |
| } catch (IOException x) { | |
| e.addSuppressed(x); | |
| } | |
| } | |
| } | |
| /** | |
| * Creates a TunnelingProxy that can serve multiple servers. | |
| * The server address is extracted from the CONNECT request line. | |
| * @param authScheme The authentication scheme supported by the proxy. | |
| * Typically one of DIGEST, BASIC, NONE. | |
| * @return A new TunnelingProxy able to serve multiple servers. | |
| * @throws IOException If the proxy could not be created. | |
| */ | |
| public static TunnelingProxy createHttpsProxyTunnel(HttpAuthSchemeType authScheme) | |
| throws IOException { | |
| HttpsProxyTunnel result = new HttpsProxyTunnel("", null, null, null); | |
| if (authScheme != HttpAuthSchemeType.NONE) { | |
| result.configureAuthentication(null, | |
| authScheme, | |
| AUTHENTICATOR, | |
| HttpAuthType.PROXY); | |
| } | |
| return result; | |
| } | |
| private static String protocol(String protocol) { | |
| if ("http".equalsIgnoreCase(protocol)) return "http"; | |
| else if ("https".equalsIgnoreCase(protocol)) return "https"; | |
| else throw new InternalError("Unsupported protocol: " + protocol); | |
| } | |
| public static URL url(String protocol, InetSocketAddress address, | |
| String path) throws MalformedURLException { | |
| return new URL(protocol(protocol), | |
| address.getHostString(), | |
| address.getPort(), path); | |
| } | |
| public static URI uri(String protocol, InetSocketAddress address, | |
| String path) throws URISyntaxException { | |
| return new URI(protocol(protocol) + "://" + | |
| address.getHostString() + ":" + | |
| address.getPort() + path); | |
| } | |
| } |