Tests jsoup's client support for HTTPS (#2032)

Tests jsoup's client support for HTTPS

During test time, adds a HTTPS listener to the Jetty test server. Uses a local self-signed cert.

Also refactored the test proxy to use Jetty's ProxyServlet, and ConnectHandler to support TLS proxy tunneling.
diff --git a/CHANGES b/CHANGES
index b199135..ab2de95 100644
--- a/CHANGES
+++ b/CHANGES
@@ -30,6 +30,10 @@
     DOMException. Now, said doctype is discarded, and the conversion continues.
 
   * Build Improvement: added a local test proxy implementation, for proxy integration tests.
+    <https://github.com/jhy/jsoup/pull/2029>
+
+  * Build Improvement: added tests for HTTPS request support, using a local self-signed cert. Includes proxy tests.
+    <https://github.com/jhy/jsoup/pull/2032>
 
 Release 1.16.2 [20-Oct-2023]
   * Improvement: optimized the performance of complex CSS selectors, by adding a cost-based query planner. Evaluators
diff --git a/pom.xml b/pom.xml
index 60164f3..5a0e1dd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -406,6 +406,14 @@
     </dependency>
 
     <dependency>
+      <!-- jetty proxy, for integration tests -->
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-proxy</artifactId>
+      <version>${jetty.version}</version>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
       <!-- javax.annotations.nonnull, with Apache 2 (not GPL) license. Build time only. -->
       <groupId>com.google.code.findbugs</groupId>
       <artifactId>jsr305</artifactId>
diff --git a/src/test/java/org/jsoup/integration/ConnectTest.java b/src/test/java/org/jsoup/integration/ConnectTest.java
index 1043489..424ba48 100644
--- a/src/test/java/org/jsoup/integration/ConnectTest.java
+++ b/src/test/java/org/jsoup/integration/ConnectTest.java
@@ -16,6 +16,8 @@
 import org.jsoup.parser.XmlTreeBuilder;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -23,8 +25,10 @@
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.net.URLDecoder;
+import java.nio.file.Files;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Stream;
 
 import static org.jsoup.helper.HttpConnection.CONTENT_TYPE;
 import static org.jsoup.helper.HttpConnection.MULTIPART_FORM_DATA;
@@ -51,6 +55,13 @@
         assertEquals("Hello, World!", p.text());
     }
 
+    @Test void canConnectToLocalTlsServer() throws IOException {
+        String url = HelloServlet.TlsUrl;
+        Document doc = Jsoup.connect(url).get();
+        Element p = doc.selectFirst("p");
+        assertEquals("Hello, World!", p.text());
+    }
+
     @Test
     public void fetchURl() throws IOException {
         Document doc = Jsoup.parse(new URL(echoUrl), 10 * 1000);
@@ -283,16 +294,16 @@
     /**
      * Tests upload of content to a remote service.
      */
-    @Test
-    public void postFiles() throws IOException {
+    @ParameterizedTest @MethodSource("echoUrls") // http and https
+    public void postFiles(String url) throws IOException {
         File thumb = ParseTest.getFile("/htmltests/thumb.jpg");
         File html = ParseTest.getFile("/htmltests/large.html");
 
         Document res = Jsoup
-            .connect(EchoServlet.Url)
+            .connect(url)
             .data("firstname", "Jay")
-            .data("firstPart", thumb.getName(), new FileInputStream(thumb), "image/jpeg")
-            .data("secondPart", html.getName(), new FileInputStream(html)) // defaults to "application-octetstream";
+            .data("firstPart", thumb.getName(), Files.newInputStream(thumb.toPath()), "image/jpeg")
+            .data("secondPart", html.getName(), Files.newInputStream(html.toPath())) // defaults to "application-octetstream";
             .data("surname", "Soup")
             .post();
 
@@ -786,4 +797,11 @@
         assertEquals("%E9%8D%B5=%E5%80%A4", ihVal("Query String", doc));
         assertEquals("鍵=値", URLDecoder.decode(ihVal("Query String", doc), DataUtil.UTF_8.name()));
     }
+
+    /**
+     Provides HTTP and HTTPS EchoServlet URLs
+     */
+    private static Stream<String> echoUrls() {
+        return Stream.of(EchoServlet.Url, EchoServlet.TlsUrl);
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/ProxyTest.java b/src/test/java/org/jsoup/integration/ProxyTest.java
index 2650bf4..a02bb18 100644
--- a/src/test/java/org/jsoup/integration/ProxyTest.java
+++ b/src/test/java/org/jsoup/integration/ProxyTest.java
@@ -11,15 +11,17 @@
 import org.jsoup.nodes.Element;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.IOException;
+import java.util.stream.Stream;
 
 import static org.jsoup.integration.ConnectTest.ihVal;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 /**
- Tests Jsoup.connect proxy support
- */
+ Tests Jsoup.connect proxy support */
 public class ProxyTest {
     private static String echoUrl;
     private static TestServer.ProxySettings proxy;
@@ -30,18 +32,23 @@
         proxy = ProxyServlet.ProxySettings;
     }
 
-    @Test void fetchViaProxy() throws IOException {
-        Connection con = Jsoup.connect(HelloServlet.Url)
+    @ParameterizedTest @MethodSource("helloUrls")
+    void fetchViaProxy(String url) throws IOException {
+        Connection con = Jsoup.connect(url)
             .proxy(proxy.hostname, proxy.port);
 
         Connection.Response res = con.execute();
-        assertVia(res);
+        if (url.startsWith("http:/")) assertVia(res); // HTTPS CONNECT won't have Via
 
         Document doc = res.parse();
         Element p = doc.expectFirst("p");
         assertEquals("Hello, World!", p.text());
     }
 
+    private static Stream<String> helloUrls() {
+        return Stream.of(HelloServlet.Url, HelloServlet.TlsUrl);
+    }
+
     private static void assertVia(Connection.Response res) {
         assertEquals(res.header("Via"), ProxyServlet.Via);
     }
@@ -71,5 +78,11 @@
         assertVia(largeRes);
         assertEquals("Medium HTML", medRes.parse().title());
         assertEquals("Large HTML", largeRes.parse().title());
+
+        Connection.Response smedRes = session.newRequest().url(FileServlet.tlsUrlTo("/htmltests/medium.html")).execute();
+        Connection.Response slargeRes = session.newRequest().url(FileServlet.tlsUrlTo("/htmltests/large.html")).execute();
+
+        assertEquals("Medium HTML", smedRes.parse().title());
+        assertEquals("Large HTML", slargeRes.parse().title());
     }
 }
diff --git a/src/test/java/org/jsoup/integration/TestServer.java b/src/test/java/org/jsoup/integration/TestServer.java
index a615cf8..67b7b84 100644
--- a/src/test/java/org/jsoup/integration/TestServer.java
+++ b/src/test/java/org/jsoup/integration/TestServer.java
@@ -1,75 +1,161 @@
 package org.jsoup.integration;
 
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.SecureRequestCustomizer;
 import org.eclipse.jetty.server.Server;
 import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
 import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.jsoup.integration.servlets.BaseServlet;
 import org.jsoup.integration.servlets.ProxyServlet;
 
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.security.KeyManagementException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
 
 public class TestServer {
-    private static final String localhost = "localhost";
-    private static final Server jetty = newServer();
-    private static final ServletHandler handler = new ServletHandler();
-    static int port;
+    static int Port;
+    static int TlsPort;
 
-    private static final Server proxy = newServer();
-    private static final ServletHandler proxyHandler = new ServletHandler();
-    private static final ProxySettings proxySettings = new ProxySettings();
+    private static final String Localhost = "localhost";
+    private static final String KeystorePassword = "hunter2";
+
+    private static final Server Jetty = newServer();
+    private static final ServletHandler JettyHandler = new ServletHandler();
+    private static final Server Proxy = newServer();
+    private static final HandlerWrapper ProxyHandler = new HandlerWrapper();
+    private static final ProxySettings ProxySettings = new ProxySettings();
+
 
     private static Server newServer() {
-        return new Server(new InetSocketAddress(localhost, 0));
+        return new Server(new InetSocketAddress(Localhost, 0));
     }
 
     static {
-        jetty.setHandler(handler);
-        proxy.setHandler(proxyHandler);
-        proxyHandler.addServletWithMapping(ProxyServlet.class, "/*");
+        Jetty.setHandler(JettyHandler);
+        Proxy.setHandler(ProxyHandler);
+
+        // TLS setup:
+        try {
+            File keystoreFile = ParseTest.getFile("/local-cert/server.pfx");
+            if (!keystoreFile.exists()) throw new FileNotFoundException(keystoreFile.toString());
+            addHttpsConnector(keystoreFile, Jetty);
+            setupDefaultTrust(keystoreFile);
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
     }
 
     private TestServer() {
     }
 
     public static void start() {
-        synchronized (jetty) {
-            if (jetty.isStarted()) return;
+        synchronized (Jetty) {
+            if (Jetty.isStarted()) return;
 
             try {
-                jetty.start(); // jetty will safely no-op a start on an already running instance
-                port = ((ServerConnector) jetty.getConnectors()[0]).getLocalPort();
+                Jetty.start();
+                Connector[] jcons = Jetty.getConnectors();
+                Port = ((ServerConnector) jcons[0]).getLocalPort();
+                TlsPort = ((ServerConnector) jcons[1]).getLocalPort();
 
-                proxy.start();
-                proxySettings.port = ((ServerConnector) proxy.getConnectors()[0]).getLocalPort();
+                ProxyHandler.setHandler(ProxyServlet.createHandler());
+                Proxy.start();
+                ProxySettings.port = ((ServerConnector) Proxy.getConnectors()[0]).getLocalPort();
             } catch (Exception e) {
                 throw new IllegalStateException(e);
             }
         }
     }
 
-    public static String map(Class<? extends BaseServlet> servletClass) {
-        synchronized (jetty) {
-            if (!jetty.isStarted())
+    public static ServletUrls map(Class<? extends BaseServlet> servletClass) {
+        synchronized (Jetty) {
+            if (!Jetty.isStarted())
                 start(); // if running out of the test cases
 
             String path = "/" + servletClass.getSimpleName();
-            handler.addServletWithMapping(servletClass, path + "/*");
-            return "http://" + localhost + ":" + port + path;
+            JettyHandler.addServletWithMapping(servletClass, path + "/*");
+            String url = "http://" + Localhost + ":" + Port + path;
+            String tlsUrl = "https://" + Localhost + ":" + TlsPort + path;
+
+            return new ServletUrls(url, tlsUrl);
+        }
+    }
+
+    public static class ServletUrls {
+        public final String url;
+        public final String tlsUrl;
+
+        public ServletUrls(String url, String tlsUrl) {
+            this.url = url;
+            this.tlsUrl = tlsUrl;
         }
     }
 
     public static ProxySettings proxySettings() {
-        synchronized (jetty) {
-            if (!jetty.isStarted())
-                start(); // if running out of the test cases
+        synchronized (Jetty) {
+            if (!Jetty.isStarted())
+                start();
 
-            return proxySettings;
+            return ProxySettings;
         }
     }
 
     //public static String proxy
     public static class ProxySettings {
-        final String hostname = localhost;
+        final String hostname = Localhost;
         int port;
     }
+
+    private static void addHttpsConnector(File keystoreFile, Server server) {
+        // Cribbed from https://github.com/jetty/jetty.project/blob/jetty-9.4.x/examples/embedded/src/main/java/org/eclipse/jetty/embedded/LikeJettyXml.java
+        SslContextFactory sslContextFactory = new SslContextFactory.Server();
+        String path = keystoreFile.getAbsolutePath();
+        sslContextFactory.setKeyStorePath(path);
+        sslContextFactory.setKeyStorePassword(KeystorePassword);
+        sslContextFactory.setKeyManagerPassword(KeystorePassword);
+        sslContextFactory.setTrustStorePath(path);
+        sslContextFactory.setTrustStorePassword(KeystorePassword);
+
+        HttpConfiguration httpConfig = new HttpConfiguration();
+        httpConfig.setSecureScheme("https");
+        HttpConfiguration httpsConfig = new HttpConfiguration(httpConfig);
+        httpsConfig.addCustomizer(new SecureRequestCustomizer());
+
+        ServerConnector sslConnector = new ServerConnector(
+            server,
+            new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()),
+            new HttpConnectionFactory(httpsConfig));
+        server.addConnector(sslConnector);
+    }
+
+    private static void setupDefaultTrust(File keystoreFile) throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException, KeyManagementException {
+        // Configure HttpsUrlConnection (jsoup) to trust (only) this cert
+        KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+        trustStore.load(Files.newInputStream(keystoreFile.toPath()), KeystorePassword.toCharArray());
+        TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+        trustManagerFactory.init(trustStore);
+        TrustManager[] managers = trustManagerFactory.getTrustManagers();
+        SSLContext tls = SSLContext.getInstance("TLS");
+        tls.init(null, managers, null);
+        SSLSocketFactory socketFactory = tls.getSocketFactory();
+        HttpsURLConnection.setDefaultSSLSocketFactory(socketFactory);
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/CookieServlet.java b/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
index fefe3fe..ef5955f 100644
--- a/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/CookieServlet.java
@@ -9,7 +9,13 @@
 import java.io.PrintWriter;
 
 public class CookieServlet extends BaseServlet {
-    public static final String Url = TestServer.map(CookieServlet.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(CookieServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
     public static final String SetCookiesParam = "setCookies";
     public static final String LocationParam = "loc";
 
diff --git a/src/test/java/org/jsoup/integration/servlets/DeflateServlet.java b/src/test/java/org/jsoup/integration/servlets/DeflateServlet.java
index bf321c1..22cf4ea 100644
--- a/src/test/java/org/jsoup/integration/servlets/DeflateServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/DeflateServlet.java
@@ -10,7 +10,13 @@
 import java.util.zip.DeflaterOutputStream;
 
 public class DeflateServlet extends BaseServlet {
-    public static final String Url = TestServer.map(DeflateServlet.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(DeflateServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
 
     @Override
     protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
diff --git a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
index 0039c04..6ff31a2 100644
--- a/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/EchoServlet.java
@@ -22,8 +22,14 @@
 
 public class EchoServlet extends BaseServlet {
     public static final String CodeParam = "code";
-    public static final String Url = TestServer.map(EchoServlet.class);
     private static final int DefaultCode = HttpServletResponse.SC_OK;
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(EchoServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
 
     @Override
     protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
@@ -114,7 +120,7 @@
     // allow the servlet to run as a main program, for local test
     public static void main(String[] args) {
         TestServer.start();
-        System.out.println(Url);
+        System.out.println("Listening on " + Url + " and " + TlsUrl);
     }
 
     private static boolean maybeEnableMultipart(HttpServletRequest req) {
diff --git a/src/test/java/org/jsoup/integration/servlets/FileServlet.java b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
index db68e18..a97c43e 100644
--- a/src/test/java/org/jsoup/integration/servlets/FileServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/FileServlet.java
@@ -11,7 +11,13 @@
 import java.nio.file.Files;
 
 public class FileServlet extends BaseServlet {
-    public static final String Url = TestServer.map(FileServlet.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(FileServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
     public static final String ContentTypeParam = "contentType";
     public static final String DefaultType = "text/html";
 
@@ -40,4 +46,8 @@
     public static String urlTo(String path) {
         return Url + path;
     }
+
+    public static String tlsUrlTo(String path) {
+        return TlsUrl + path;
+    }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/HelloServlet.java b/src/test/java/org/jsoup/integration/servlets/HelloServlet.java
index 4c9f380..aaae1fa 100644
--- a/src/test/java/org/jsoup/integration/servlets/HelloServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/HelloServlet.java
@@ -7,7 +7,13 @@
 import java.io.IOException;
 
 public class HelloServlet extends BaseServlet {
-    public static final String Url = TestServer.map(HelloServlet.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(HelloServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
 
     @Override
     protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException {
diff --git a/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java b/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java
index 67554c5..26b2fef 100644
--- a/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/InterruptedServlet.java
@@ -8,7 +8,13 @@
 import java.io.IOException;
 
 public class InterruptedServlet extends BaseServlet {
-    public static final String Url = TestServer.map(InterruptedServlet.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(InterruptedServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
     public static final String Magnitude = "magnitude";
     public static final String Larger = "larger";
 
diff --git a/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java b/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
index 16a50a5..5fda42c 100644
--- a/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/ProxyServlet.java
@@ -1,73 +1,37 @@
 package org.jsoup.integration.servlets;
 
-import org.jsoup.Connection;
-import org.jsoup.Jsoup;
+import org.eclipse.jetty.client.api.Response;
+import org.eclipse.jetty.proxy.AsyncProxyServlet;
+import org.eclipse.jetty.proxy.ConnectHandler;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.servlet.ServletHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
 import org.jsoup.integration.TestServer;
 
-import javax.servlet.ServletException;
-import javax.servlet.ServletOutputStream;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import java.io.BufferedInputStream;
-import java.io.IOException;
-import java.util.Enumeration;
-import java.util.List;
-import java.util.Map;
 
-public class ProxyServlet extends BaseServlet{
+public class ProxyServlet extends AsyncProxyServlet {
     public static TestServer.ProxySettings ProxySettings = TestServer.proxySettings();
     public static String Via = "1.1 jsoup test proxy";
 
+    public static Handler createHandler() {
+        // ConnectHandler wraps this ProxyServlet and handles CONNECT, which sets up a tunnel for HTTPS requests and is
+        // opaque to the proxy. The ProxyServlet handles simple HTTP requests.
+        ConnectHandler connectHandler = new ConnectHandler();
+        ServletHandler proxyHandler = new ServletHandler();
+        ServletHolder proxyServletHolder = new ServletHolder(ProxyServlet.class); // Holder wraps as it requires maxThreads initialization
+        proxyServletHolder.setAsyncSupported(true);
+        proxyServletHolder.setInitParameter("maxThreads", "8");
+        proxyHandler.addServletWithMapping(proxyServletHolder, "/*");
+        connectHandler.setHandler(proxyHandler);
+
+        return connectHandler;
+    }
+
     @Override
-    protected void doIt(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException {
-        StringBuffer urlBuf = req.getRequestURL();
-        if (req.getQueryString() != null) {
-            urlBuf.append('?').append(req.getQueryString());
-        }
-        String url = urlBuf.toString();
-        //log("Proxying URL: " + url);
-
-        Connection.Method method = Enum.valueOf(Connection.Method.class, req.getMethod());
-        Connection fetch = Jsoup.connect(url)
-            .method(method)
-            .followRedirects(false)
-            .ignoreHttpErrors(true);
-
-        // request headers
-        Enumeration<String> headerNames = req.getHeaderNames();
-        while (headerNames.hasMoreElements()) {
-            String name = headerNames.nextElement();
-            Enumeration<String> values = req.getHeaders(name);
-            while (values.hasMoreElements()) {
-                String value = values.nextElement();
-                //System.out.println("Header: " + name + " = " + value);
-                fetch.header(name, value); // todo - this invocation will replace existing header, not add
-            }
-        }
-
-        // execute
-        Connection.Response fetchRes = fetch.execute();
-        res.setStatus(fetchRes.statusCode());
-
-        // write the response headers
-        res.addHeader("Via", Via);
-        for (Map.Entry<String, List<String>> entry : fetchRes.multiHeaders().entrySet()) {
-            String header = entry.getKey();
-            for (String value : entry.getValue()) {
-                res.addHeader(header,value);
-            }
-        }
-
-        // write the body
-        ServletOutputStream outputStream = res.getOutputStream();
-        BufferedInputStream inputStream = fetchRes.bodyStream();
-        byte[] buffer = new byte[1024];
-        int bytesRead;
-        while ((bytesRead = inputStream.read(buffer)) != -1) {
-            outputStream.write(buffer, 0, bytesRead);
-        }
-
-        outputStream.close();
-        inputStream.close();
+    protected void onServerResponseHeaders(HttpServletRequest clientRequest, HttpServletResponse proxyResponse, Response serverResponse) {
+        super.onServerResponseHeaders(clientRequest, proxyResponse, serverResponse);
+        proxyResponse.addHeader("Via", Via);
     }
 }
diff --git a/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java b/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java
index 1f90c2f..0a937b7 100644
--- a/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java
+++ b/src/test/java/org/jsoup/integration/servlets/RedirectServlet.java
@@ -8,7 +8,13 @@
 import java.io.IOException;
 
 public class RedirectServlet extends BaseServlet {
-    public static final String Url = TestServer.map(RedirectServlet.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(RedirectServlet.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
     public static final String LocationParam = "loc";
     public static final String CodeParam = "code";
     public static final String SetCookiesParam = "setCookies";
diff --git a/src/test/java/org/jsoup/integration/servlets/SlowRider.java b/src/test/java/org/jsoup/integration/servlets/SlowRider.java
index 84e876e..7298e0b 100644
--- a/src/test/java/org/jsoup/integration/servlets/SlowRider.java
+++ b/src/test/java/org/jsoup/integration/servlets/SlowRider.java
@@ -11,7 +11,13 @@
  * Slowly, interminably writes output. For the purposes of testing timeouts and interrupts.
  */
 public class SlowRider extends BaseServlet {
-    public static final String Url = TestServer.map(SlowRider.class);
+    public static final String Url;
+    public static final String TlsUrl;
+    static {
+        TestServer.ServletUrls urls = TestServer.map(SlowRider.class);
+        Url = urls.url;
+        TlsUrl = urls.tlsUrl;
+    }
     private static final int SleepTime = 2000;
     public static final String MaxTimeParam = "maxTime";
 
diff --git a/src/test/resources/local-cert/README.md b/src/test/resources/local-cert/README.md
new file mode 100644
index 0000000..2d4cbe5
--- /dev/null
+++ b/src/test/resources/local-cert/README.md
@@ -0,0 +1,15 @@
+This directory contains resources for a self-signed TLS certificate, used in jsoup's local integration tests.
+
+Create the certificate:
+
+```sh
+openssl genrsa 2048 > server.key
+chmod 400 server.key
+openssl req -new -x509 -config cert.conf -nodes -sha256 -days 36135 -key server.key -out server.crt
+```
+
+Create the Java key store. Used by server, and trusted by client, in `TestServer.java`:
+```sh
+openssl pkcs12 -export -in server.crt -inkey server.key -out server.p12 -name jsoup -passout pass:hunter2
+keytool -importkeystore -srckeystore server.p12 -srcstoretype PKCS12 -destkeystore server.pfx -deststoretype PKCS12  -srcstorepass hunter2 -deststorepass hunter2
+```
diff --git a/src/test/resources/local-cert/cert.conf b/src/test/resources/local-cert/cert.conf
new file mode 100644
index 0000000..c9019e4
--- /dev/null
+++ b/src/test/resources/local-cert/cert.conf
@@ -0,0 +1,13 @@
+[ req ]
+distinguished_name  = subject
+x509_extensions     = x509_ext
+prompt              = no
+
+[ subject ]
+commonName          = jsoup test server
+
+[ x509_ext ]
+subjectAltName      = @alternate_names
+
+[ alternate_names ]
+DNS.1               = localhost
diff --git a/src/test/resources/local-cert/server.crt b/src/test/resources/local-cert/server.crt
new file mode 100644
index 0000000..27d549e
--- /dev/null
+++ b/src/test/resources/local-cert/server.crt
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIC/zCCAeegAwIBAgIUKEHmb0P5j+5mNjNk/PTdW6t9UTcwDQYJKoZIhvcNAQEL
+BQAwHDEaMBgGA1UEAwwRanNvdXAgdGVzdCBzZXJ2ZXIwIBcNMjMxMTAyMjM0OTE1
+WhgPMjEyMjEwMDkyMzQ5MTVaMBwxGjAYBgNVBAMMEWpzb3VwIHRlc3Qgc2VydmVy
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvkvtYwy7jnSPYM59EVsR
+SjTO8WsXHVs/UJ+Ns+7RiTeb2hUOd4lh38TOh9Yri/7WI5Ejif64FL6b1KEWRe9+
+60QKIOB0+7DUpnXomisD6TytwV8R8BSEZ4vLbMUVizr95Ze+w6SzMPshSvHBMIbU
+RimtmY1jBglHytETRBjO1etG120R1M45GJfxV8rIDOgM6FksOnWLQeKzeGKBf0vs
+5MlTz/GDs/YpXydg779QOmJAQWj78EMdetwmUPwnpC0kaO3dnlD+mzDrfeSkorrp
+5UKij1k4s2tG+E/VIskGyuc/MSU6dc8/ECzuK7c/UjpUz9ohSfLwhSGdjnx0qjXm
+kwIDAQABozcwNTAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFAWRk6Jd
+PJrlw3uJKEG7JLku9SwsMA0GCSqGSIb3DQEBCwUAA4IBAQAxEXk5d0ACzaxtOF9+
+/XF3Zt8X/eXxyoQUaG2PyfJkN1rnO7zyx/oPIIAckaZev0eFVwOk3M5K4xxYar/Y
+DqdioKwH8qAy4kk7sdCnTU8jlkUMcFqYCt7rLcDviugjg0VO6bYLrq++oeOuDybs
+M7J3CgzPAppSpRoTgss3bGzHt87rWJ2XcHxbE8Gg2GtoZnFpcSHkx40EdlDWN8dm
+/mZlMxjVFdktz9dpqtR4Q4cAbHETomJOHC2AnhEi3PjuYhGHMbIRgtIg0XX4H/0u
+eHVvkb9xJ3SmmdidYTDlOFzLon8NqSZmmt6EDpDio62bDem49jUtnYmxJKXAxhL0
+jnwQ
+-----END CERTIFICATE-----
diff --git a/src/test/resources/local-cert/server.key b/src/test/resources/local-cert/server.key
new file mode 100644
index 0000000..70c498c
--- /dev/null
+++ b/src/test/resources/local-cert/server.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+S+1jDLuOdI9g
+zn0RWxFKNM7xaxcdWz9Qn42z7tGJN5vaFQ53iWHfxM6H1iuL/tYjkSOJ/rgUvpvU
+oRZF737rRAog4HT7sNSmdeiaKwPpPK3BXxHwFIRni8tsxRWLOv3ll77DpLMw+yFK
+8cEwhtRGKa2ZjWMGCUfK0RNEGM7V60bXbRHUzjkYl/FXysgM6AzoWSw6dYtB4rN4
+YoF/S+zkyVPP8YOz9ilfJ2Dvv1A6YkBBaPvwQx163CZQ/CekLSRo7d2eUP6bMOt9
+5KSiuunlQqKPWTiza0b4T9UiyQbK5z8xJTp1zz8QLO4rtz9SOlTP2iFJ8vCFIZ2O
+fHSqNeaTAgMBAAECggEACY0zFaEqetyD49aJdYkOJZzf9EMtTlZpp6jSioEGuG33
+nysmZj6ZkItG2I+Z8PVyFyfuUjtcTwJAPRx2yzzZsIJiRcMubAG0ssRBUBevoxHe
+INIeSuAkwzPDmqqLycjEvLTwqM5IBkHcqm/XBBIIbpsh8Q6lNUTa+yWiY20hWKBX
+7I+mNg9qTsGkYCthZVBgkpmg3DCCX4l8hraHhev3KgdpaILaDSVqjd1IBwJ9ynJc
+mJ0/pvIVO7dwxJ7t7b+vNp8iJQjPlOZmz6hWKyFMhxnkOcri3OBYcr1JMkVZ38RD
+OjKhaaCnhhSH+IxwLxQQAs//S+EN3l6kOngN5cZ/aQKBgQDiEqp7kT7nAPRMq9Af
+okomKnQIpAuEfOauzH02PGkVYawCulWmr+FqdUZxz5SgPEp55IyTfD6iPaSb6QcO
+QuH3PvtZyVQv1ZrExquvd/3lS/cQwaDzV4YG46fBbw9K72BHkVV7dkxm+0p4Imid
+2XLRqT86difx1etovb7fzMXsCwKBgQDXfNh5Gk250Upyh3+7FDYr7bOvc0l9y/Xn
+eODM/yRI3MLaGTUXu90MK50AsOqxedvs4x5NvqG/n2Cr536b9C0tr09CfHeGsOMG
+OEfzxMrRv78ItBF7vLELYz1szi6JEZCeK1whgJ1osrTGWAhWkMTIErh3UOfZGgYG
+qFQGRFP8mQKBgG7FlqNVV+z4mru2tBPMAWkSBCj3uG0ChkXADNo2X4cKhK4Rf0Zd
+h6YSMKIzhC+/Wv6+7eKWTlpQugdq9voV64KqaZ5k98s4bs1cS2N+9/kSb8zWE3co
+u5NEmT4+nM+q2xI2NBx6qpULLEIRGhG+KnRw6XpLyubEWsTHtG8UdyZhAoGAVdm5
+bNYb7VICtQpiyyfMRUgYdGgb+XBO8f9ooINt81Fwl++/BUulT3n4vRO/DSIdio0Z
+v6OZUXyvyQ0blgp8DV1w2G46OIE0kX/OusHGhDY+Z7tF0+RjLMRG7pheVeGXmkxw
+EjDphZLdDsB34fUfUQ6US4UCOa5yhCiAAVcrltECgYEAlYNAELPKAcmWd+4G8Fr6
+07dIgJHZ7W45eZwwUwva9t09J/9d4wq7X4GaX98Jejdeh4nTHnBWX49m6EgQ0ccH
+4jcIvTj61aBuDNiW8p85O5gpBrCneFowFHsPElhG2nFSFhGtIST8fkiy5sBwxMFM
+1nauFIaX8tP0NxQDw+PvdDc=
+-----END PRIVATE KEY-----
diff --git a/src/test/resources/local-cert/server.p12 b/src/test/resources/local-cert/server.p12
new file mode 100644
index 0000000..e6804a0
--- /dev/null
+++ b/src/test/resources/local-cert/server.p12
Binary files differ
diff --git a/src/test/resources/local-cert/server.pfx b/src/test/resources/local-cert/server.pfx
new file mode 100644
index 0000000..746aef2
--- /dev/null
+++ b/src/test/resources/local-cert/server.pfx
Binary files differ