/* * Copyright (c) 2020, 2024, 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. */ /* * @test * @summary checks that receiving 403 for a HEAD request after * 401/407 doesn't cause any unexpected behavior. * @library /test/lib /test/jdk/java/net/httpclient/lib * @build DigestEchoServer ForbiddenHeadTest jdk.httpclient.test.lib.common.HttpServerAdapters * jdk.test.lib.net.SimpleSSLContext * @run testng/othervm * -Djdk.http.auth.tunneling.disabledSchemes * -Djdk.httpclient.HttpClient.log=headers,requests * -Djdk.internal.httpclient.debug=true * ForbiddenHeadTest */ import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsConfigurator; import com.sun.net.httpserver.HttpsServer; import jdk.test.lib.net.SimpleSSLContext; import org.testng.ITestContext; import org.testng.ITestResult; import org.testng.SkipException; import org.testng.annotations.AfterClass; import org.testng.annotations.AfterTest; import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeTest; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import javax.net.ssl.SSLContext; import java.io.IOException; import java.io.InputStream; import java.net.Authenticator; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.PasswordAuthentication; import java.net.Proxy; import java.net.ProxySelector; import java.net.SocketAddress; 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.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import jdk.httpclient.test.lib.common.HttpServerAdapters; import jdk.httpclient.test.lib.http2.Http2TestServer; import static java.lang.System.err; import static java.lang.System.out; import static java.net.http.HttpClient.Version.HTTP_1_1; import static java.net.http.HttpClient.Version.HTTP_2; import static java.nio.charset.StandardCharsets.UTF_8; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; public class ForbiddenHeadTest implements HttpServerAdapters { SSLContext sslContext; HttpTestServer httpTestServer; // HTTP/1.1 HttpTestServer httpsTestServer; // HTTPS/1.1 HttpTestServer http2TestServer; // HTTP/2 ( h2c ) HttpTestServer https2TestServer; // HTTP/2 ( h2 ) DigestEchoServer.TunnelingProxy proxy; DigestEchoServer.TunnelingProxy authproxy; String httpURI; String httpsURI; String http2URI; String https2URI; HttpClient authClient; HttpClient noAuthClient; final ReferenceTracker TRACKER = ReferenceTracker.INSTANCE; static final long SLEEP_AFTER_TEST = 0; // milliseconds static final int ITERATIONS = 3; static final Executor executor = new TestExecutor(Executors.newCachedThreadPool()); static final ConcurrentMap FAILURES = new ConcurrentHashMap<>(); static volatile boolean tasksFailed; static final AtomicLong serverCount = new AtomicLong(); static final AtomicLong clientCount = new AtomicLong(); 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 TestExecutor implements Executor { final AtomicLong tasks = new AtomicLong(); Executor executor; TestExecutor(Executor executor) { this.executor = executor; } @Override public void execute(Runnable command) { long id = tasks.incrementAndGet(); executor.execute(() -> { try { command.run(); } catch (Throwable t) { tasksFailed = true; out.printf(now() + "Task %s failed: %s%n", id, t); err.printf(now() + "Task %s failed: %s%n", id, t); FAILURES.putIfAbsent("Task " + id, t); throw t; } }); } } protected boolean stopAfterFirstFailure() { return Boolean.getBoolean("jdk.internal.httpclient.debug"); } final AtomicReference skiptests = new AtomicReference<>(); void checkSkip() { var skip = skiptests.get(); if (skip != null) throw skip; } static String name(ITestResult result) { var params = result.getParameters(); return result.getName() + (params == null ? "()" : Arrays.toString(result.getParameters())); } @BeforeMethod void beforeMethod(ITestContext context) { if (stopAfterFirstFailure() && context.getFailedTests().size() > 0) { if (skiptests.get() == null) { SkipException skip = new SkipException("some tests failed"); skip.setStackTrace(new StackTraceElement[0]); skiptests.compareAndSet(null, skip); } } } @AfterClass static final void printFailedTests(ITestContext context) { out.println("\n========================="); try { // Exceptions should already have been added to FAILURES // var failed = context.getFailedTests().getAllResults().stream() // .collect(Collectors.toMap(r -> name(r), ITestResult::getThrowable)); // FAILURES.putAll(failed); out.printf("%n%sCreated %d servers and %d clients%n", now(), serverCount.get(), clientCount.get()); if (FAILURES.isEmpty()) return; out.println("Failed tests: "); FAILURES.entrySet().forEach((e) -> { out.printf("\t%s: %s%n", e.getKey(), e.getValue()); e.getValue().printStackTrace(out); e.getValue().printStackTrace(); }); if (tasksFailed) { out.println("WARNING: Some tasks failed"); } } finally { out.println("\n=========================\n"); } } static final int UNAUTHORIZED = 401; static final int PROXY_UNAUTHORIZED = 407; static final int FORBIDDEN = 403; static final int HTTP_OK = 200; static final String MESSAGE = "Unauthorized"; @DataProvider(name = "all") public Object[][] allcases() { List result = new ArrayList<>(); for (boolean useAuth : List.of(true, false)) { for (boolean async : List.of(true, false)) { for (int code : List.of(UNAUTHORIZED, PROXY_UNAUTHORIZED)) { var srv = code == PROXY_UNAUTHORIZED ? "/proxy" : "/server"; for (var auth : List.of("/auth", "/noauth")) { var pcode = code; if (auth.equals("/noauth")) { if (useAuth) continue; pcode = FORBIDDEN; } for (var uri : List.of(httpURI, httpsURI, http2URI, https2URI)) { result.add(new Object[]{uri + srv + auth, pcode, async, useAuth}); } } } } } return result.toArray(new Object[0][0]); } static final AtomicLong requestCounter = new AtomicLong(); static final Authenticator authenticator = new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication("arthur",new char[] {'d', 'e', 'n', 't'}); } }; static final AtomicLong sleepCount = new AtomicLong(); @Test(dataProvider = "all") void test(String uriString, int code, boolean async, boolean useAuth) throws Throwable { checkSkip(); HttpClient client = useAuth ? authClient : noAuthClient; var name = String.format("test(%s, %d, %s, %s)", uriString, code, async ? "async" : "sync", client.authenticator().isPresent() ? "authClient" : "noAuthClient"); out.printf("%n---- starting %s ----%n", name); assert client.authenticator().isPresent() == useAuth; uriString = uriString + "/ForbiddenTest"; for (int i=0; i 1) out.printf("---- ITERATION %d%n",i); try { doTest(uriString, code, async, client); long count = sleepCount.incrementAndGet(); System.err.println(now() + " Sleeping: " + count); Thread.sleep(SLEEP_AFTER_TEST); System.err.println(now() + " Waking up: " + count); } catch (Throwable x) { FAILURES.putIfAbsent(name, x); throw x; } } } static String authHeaderName(int code) { return switch (code) { case UNAUTHORIZED -> "WWW-Authenticate"; case PROXY_UNAUTHORIZED -> "Proxy-Authenticate"; default -> null; }; } private void doTest(String uriString, int code, boolean async, HttpClient client) throws Throwable { URI uri = URI.create(uriString); HttpRequest.Builder requestBuilder = HttpRequest .newBuilder(uri) .method("HEAD", HttpRequest.BodyPublishers.noBody()); HttpRequest request = requestBuilder.build(); out.println("Initial request: " + request.uri()); String header = authHeaderName(code); // the request is expected to return 403 Forbidden if the client is authenticated, // or the server doesn't require authentication, 401 or 407 otherwise. boolean forbidden = client.authenticator().isPresent() || code == FORBIDDEN; HttpResponse response = null; if (async) { response = client.send(request, BodyHandlers.ofString()); } else { try { response = client.sendAsync(request, BodyHandlers.ofString()).get(); } catch (ExecutionException ex) { throw ex.getCause(); } } String prefix = uriString.contains("/proxy/") ? "Proxy-" : "WWW-"; String expectedValue; if (forbidden) { // The message body is generated by the server, after authentication was // successful. expectedValue = prefix + "FORBIDDEN"; } else if (uriString.contains("/proxy/") && uri.getScheme().equalsIgnoreCase("https")) { // In that case the tunnelling proxy itself is expected to return 407, // and the message will have no body (since the CONNECT request fails). assert code == PROXY_UNAUTHORIZED; expectedValue = null; } else { // the message body is generated by our fake server pretending to be // a proxy. expectedValue = prefix + MESSAGE; } out.println(" Got response: " + response); assertEquals(response.statusCode(), forbidden? FORBIDDEN : code); assertEquals(response.body(), expectedValue == null ? null : ""); assertEquals(response.headers().firstValue("X-value"), Optional.ofNullable(expectedValue)); // when the CONNECT request fails, its body is discarded - but // the response header may still contain its content length. // don't check content length in that case. if (expectedValue != null) { String clen = String.valueOf(expectedValue.getBytes(UTF_8).length); assertEquals(response.headers().firstValue("Content-Length"), Optional.of(clen)); } } // -- Infrastructure @BeforeTest public void setup() throws Exception { sslContext = new SimpleSSLContext().get(); if (sslContext == null) throw new AssertionError("Unexpected null sslContext"); httpTestServer = HttpTestServer.create(HTTP_1_1); httpTestServer.addHandler(new UnauthorizedHandler(), "/http1/"); httpTestServer.addHandler(new UnauthorizedHandler(), "/http2/proxy/"); httpURI = "http://" + httpTestServer.serverAuthority() + "/http1"; httpsTestServer = HttpTestServer.create(HTTP_1_1, sslContext); httpsTestServer.addHandler(new UnauthorizedHandler(),"/https1/"); httpsURI = "https://" + httpsTestServer.serverAuthority() + "/https1"; http2TestServer = HttpTestServer.create(HTTP_2); http2TestServer.addHandler(new UnauthorizedHandler(), "/http2/"); http2URI = "http://" + http2TestServer.serverAuthority() + "/http2"; https2TestServer = HttpTestServer.create(HTTP_2, sslContext); https2TestServer.addHandler(new UnauthorizedHandler(), "/https2/"); https2URI = "https://" + https2TestServer.serverAuthority() + "/https2"; proxy = DigestEchoServer.createHttpsProxyTunnel(DigestEchoServer.HttpAuthSchemeType.NONE); authproxy = DigestEchoServer.createHttpsProxyTunnel(DigestEchoServer.HttpAuthSchemeType.BASIC); authClient = TRACKER.track(HttpClient.newBuilder() .proxy(TestProxySelector.of(proxy, authproxy, httpTestServer)) .sslContext(sslContext) .executor(executor) .authenticator(authenticator) .build()); clientCount.incrementAndGet(); noAuthClient = TRACKER.track(HttpClient.newBuilder() .proxy(TestProxySelector.of(proxy, authproxy, httpTestServer)) .sslContext(sslContext) .executor(executor) .build()); clientCount.incrementAndGet(); httpTestServer.start(); serverCount.incrementAndGet(); httpsTestServer.start(); serverCount.incrementAndGet(); http2TestServer.start(); serverCount.incrementAndGet(); https2TestServer.start(); serverCount.incrementAndGet(); } @AfterTest public void teardown() throws Exception { authClient = noAuthClient = null; Thread.sleep(100); AssertionError fail = TRACKER.check(1500); try { proxy.stop(); authproxy.stop(); httpTestServer.stop(); httpsTestServer.stop(); http2TestServer.stop(); https2TestServer.stop(); } finally { if (fail != null) throw fail; } } static class TestProxySelector extends ProxySelector { final DigestEchoServer.TunnelingProxy proxy; final DigestEchoServer.TunnelingProxy authproxy; final HttpTestServer plain; private TestProxySelector(DigestEchoServer.TunnelingProxy proxy, DigestEchoServer.TunnelingProxy authproxy, HttpTestServer plain) { this.proxy = proxy; this.authproxy = authproxy; this.plain = plain; } @Override public List select(URI uri) { String path = uri.getPath(); out.println("Selecting proxy for: " + uri); if (path.contains("/proxy/")) { if (path.contains("/http1/")) { // Simple proxying - in our test the server pretends // to be the proxy. System.out.print("PROXY is server for " + uri); return List.of(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(uri.getHost(), uri.getPort()))); } else if (path.contains("/http2/")) { // HTTP/2 is downgraded to HTTP/1.1 if there is a proxy System.out.print("PROXY is plain server for " + uri); return List.of(new Proxy(Proxy.Type.HTTP, plain.getAddress())); } else { // Both HTTPS or HTTPS/2 require tunnelling var p = path.contains("/auth/") ? authproxy : proxy; if (p == authproxy) { out.println("PROXY is authenticating tunneling proxy for " + uri); } else { out.println("PROXY is plain tunneling proxy for " + uri); } return List.of(new Proxy(Proxy.Type.HTTP, p.getProxyAddress())); } } System.out.print("NO_PROXY for " + uri); return List.of(Proxy.NO_PROXY); } @Override public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { System.err.printf("Connect failed for: uri=\"%s\", sa=\"%s\", ioe=%s%n", uri, sa, ioe); } public static TestProxySelector of(DigestEchoServer.TunnelingProxy proxy, DigestEchoServer.TunnelingProxy authproxy, HttpTestServer plain) { return new TestProxySelector(proxy, authproxy, plain); } } static class UnauthorizedHandler implements HttpTestHandler { @Override public void handle(HttpTestExchange t) throws IOException { readAllRequestData(t); // shouldn't be any String method = t.getRequestMethod(); String path = t.getRequestURI().getPath(); HttpTestRequestHeaders reqh = t.getRequestHeaders(); HttpTestResponseHeaders rsph = t.getResponseHeaders(); String xValue; boolean noAuthRequired = path.contains("/noauth/"); boolean authenticated = path.contains("/server/") && reqh.containsKey("Authorization") || path.contains("/proxy/") && reqh.containsKey("Proxy-Authorization") || path.contains("/proxy/") && (path.contains("/https1/") || path.contains("/https2/")); String srv = path.contains("/proxy/") ? "proxy" : "server"; String prefix = path.contains("/proxy/") ? "Proxy-" : "WWW-"; int authcode = path.contains("/proxy/") ? PROXY_UNAUTHORIZED : UNAUTHORIZED; int code = (authenticated || noAuthRequired) ? FORBIDDEN : authcode; if (authenticated || noAuthRequired) { xValue = prefix + "FORBIDDEN"; } else { xValue = prefix + MESSAGE; rsph.addHeader(prefix + "Authenticate", "Basic realm=\"earth\", charset=\"UTF-8\""); } t.getResponseHeaders().addHeader("X-value", xValue); t.getResponseHeaders().addHeader("Content-Length", String.valueOf(xValue.getBytes(UTF_8).length)); t.sendResponseHeaders(code, 0); } } static void readAllRequestData(HttpTestExchange t) throws IOException { try (InputStream is = t.getRequestBody()) { is.readAllBytes(); } } }