diff --git a/BUILDING.txt b/BUILDING.txt
index 8ee307952018..6a4a83ba0ca0 100644
--- a/BUILDING.txt
+++ b/BUILDING.txt
@@ -603,6 +603,73 @@ NOTE: Cobertura is licensed under GPL v2 with parts of it being under
test.verbose=false
+(7.5) Running httpd integration tests
+
+ Tomcat includes integration tests that verify Tomcat's behavior when
+ running behind an Apache HTTP Server (httpd) reverse proxy. These tests
+ require a working httpd installation and are skipped when httpd is not
+ available.
+
+ 1. Prerequisites
+
+ An Apache HTTP Server (httpd) installation is required.
+ The following httpd modules must be available:
+
+ - mod_proxy
+ - mod_proxy_http
+ - mod_headers
+ - mod_ssl
+ - mod_authz_core
+ - mod_unixd (Linux and macOS only)
+ - mod_mpm_event or mod_mpm_prefork (Linux and macOS)
+
+ On most Linux distributions, httpd can be installed via the system
+ package manager:
+
+ # Debian/Ubuntu
+ sudo apt install apache2
+
+ # RHEL/Fedora
+ sudo dnf install httpd
+
+ On macOS, httpd is included with the system or can be installed via
+ Homebrew:
+
+ brew install httpd
+
+ 2. Configuration
+
+ If the httpd binary is on the system PATH, the tests will find it
+ automatically. If httpd is installed in a non-standard location, set
+ the "test.httpd.path" property in your build.properties file to point
+ to the httpd binary:
+
+ test.httpd.path=/usr/sbin/httpd
+
+ 3. Running the tests
+ Integration tests are excluded from the default test run. To run
+ them, use the integration test profile:
+
+ ant test -Dtest.profile=integration
+
+ The httpd integration tests are located in:
+
+ test/org/apache/tomcat/integration/httpd/
+
+ 4. How the tests work
+
+ Each test starts an httpd instance in foreground mode (-X) with a
+ generated configuration that proxies requests to a Tomcat instance
+ managed by the test framework. A file lock (httpd-binary.lock) is
+ used to serialize test classes so that only one httpd instance runs
+ at a time.
+
+ The base class (HttpdIntegrationBaseTest) handles platform-specific
+ httpd module loading automatically. Test subclasses only need to
+ provide the test-specific httpd directives (proxy rules, extra
+ modules, etc.).
+
+
(8) Source code checks
(8.1) Checkstyle
diff --git a/build.xml b/build.xml
index 14d2960f0528..31fe59cb9a45 100644
--- a/build.xml
+++ b/build.xml
@@ -222,6 +222,10 @@
+
+
+
+
@@ -2008,6 +2012,9 @@
+
+
+
@@ -2037,6 +2044,11 @@
+
+
+
+
+
@@ -2119,6 +2131,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2176,6 +2204,7 @@
+
@@ -2205,6 +2234,8 @@
+
+
diff --git a/test-profiles.properties.default b/test-profiles.properties.default
index da10ee9f2e7d..4920030801c6 100644
--- a/test-profiles.properties.default
+++ b/test-profiles.properties.default
@@ -98,3 +98,7 @@ test.profile.tribes=**/tribes/**/*Test*.java
# Build utility test profile: Tests for build tools (normally excluded)
# Note: These tests depend on classes not in output JARs and are excluded by default
test.profile.buildutil=**/buildutil/**/*Test*.java
+
+# Integration test profile: Tests requiring external processes (e.g. httpd)
+# Note: These tests are excluded by default as they require external binaries
+test.profile.integration=**/integration/**/Test*.java
\ No newline at end of file
diff --git a/test/org/apache/catalina/startup/LoggingBaseTest.java b/test/org/apache/catalina/startup/LoggingBaseTest.java
index 8a96e414361b..66c5f745c7d3 100644
--- a/test/org/apache/catalina/startup/LoggingBaseTest.java
+++ b/test/org/apache/catalina/startup/LoggingBaseTest.java
@@ -89,7 +89,7 @@ public static File getBuildDirectory() {
* that have to be deleted on cleanup, register them with
* {@link #addDeleteOnTearDown(File)}.
*/
- public File getTemporaryDirectory() {
+ public static File getTemporaryDirectory() {
return tempDir;
}
diff --git a/test/org/apache/catalina/startup/TomcatBaseTest.java b/test/org/apache/catalina/startup/TomcatBaseTest.java
index 21f744647a5c..10631c5af69c 100644
--- a/test/org/apache/catalina/startup/TomcatBaseTest.java
+++ b/test/org/apache/catalina/startup/TomcatBaseTest.java
@@ -22,6 +22,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
+import java.io.Serial;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URI;
@@ -466,6 +467,7 @@ public static RequestDescriptor parse(String body) {
*/
public static final class SnoopServlet extends HttpServlet {
+ @Serial
private static final long serialVersionUID = 1L;
@Override
@@ -481,7 +483,7 @@ public void service(HttpServletRequest request,
response.setCharacterEncoding("UTF-8");
ServletContext ctx = this.getServletContext();
- HttpSession session = request.getSession(false);
+ HttpSession session = request.getSession("true".equals(request.getParameter("createSession")));
PrintWriter out = response.getWriter();
out.println("CONTEXT-NAME: " + ctx.getServletContextName());
@@ -558,7 +560,7 @@ public void service(HttpServletRequest request,
e.hasMoreElements();) {
name = e.nextElement();
value = new StringBuilder();
- String values[] = request.getParameterValues(name);
+ String[] values = request.getParameterValues(name);
int m = values.length;
for (int j = 0; j < m; j++) {
value.append(values[j]);
@@ -597,7 +599,7 @@ public void service(HttpServletRequest request,
}
int bodySize = 0;
- if (Method.PUT.equals(request.getMethod())) {
+ if (Method.PUT.equals(request.getMethod()) || Method.POST.equals(request.getMethod())) {
InputStream is = request.getInputStream();
int read = 0;
byte[] buffer = new byte[8192];
diff --git a/test/org/apache/tomcat/integration/httpd/HttpdIntegrationBaseTest.java b/test/org/apache/tomcat/integration/httpd/HttpdIntegrationBaseTest.java
new file mode 100644
index 000000000000..f54c2d7cee9f
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/HttpdIntegrationBaseTest.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.ServerSocket;
+import java.nio.channels.FileLock;
+import java.util.List;
+
+import org.junit.AfterClass;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.LifecycleException;
+import org.apache.catalina.Valve;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.catalina.startup.TomcatBaseTest;
+import org.apache.tomcat.util.compat.JrePlatform;
+import org.apache.tomcat.util.net.TesterSupport;
+
+/**
+ * Base class for httpd integration tests.
+ * Manages httpd and Tomcat process lifecycle.
+ */
+public abstract class HttpdIntegrationBaseTest extends TomcatBaseTest {
+
+ private static final String HTTPD_CONFIG =
+ """
+ Listen %{HTTPD_PORT}
+ PidFile %{CONF_DIR}/httpd.pid
+ LoadModule authz_core_module modules/mod_authz_core.so
+ """
+ + (JrePlatform.IS_WINDOWS ?
+ """
+ ErrorLog "|C:/Windows/System32/more.com"
+ """
+ :
+ """
+ LoadModule unixd_module modules/mod_unixd.so
+ LoadModule mpm_event_module modules/mod_mpm_event.so
+ ErrorLog /dev/stderr
+ """
+ ) +
+ """
+ LogLevel warn
+ ServerName localhost:%{HTTPD_PORT}
+ """;
+
+ private static final String SERVLET_NAME = "snoop";
+
+ private static final File lockFile = new File("test/org/apache/tomcat/integration/httpd/httpd-binary.lock");
+ private static FileLock lock = null;
+
+ private TesterHttpd httpd;
+ private int httpdPort;
+ private int httpdSslPort;
+ protected File httpdConfDir;
+
+ private int tomcatPort;
+
+ protected abstract String getHttpdConfig();
+ protected abstract List getValveConfig();
+
+ @BeforeClass
+ public static void obtainHttpdBinaryLock() throws IOException {
+ @SuppressWarnings("resource")
+ FileOutputStream fos = new FileOutputStream(lockFile);
+ lock = fos.getChannel().lock();
+ }
+
+ @AfterClass
+ public static void releaseHttpdBinaryLock() throws IOException {
+ // Should not be null be in case obtaining the lock fails, avoid a second error.
+ if (lock != null) {
+ lock.release();
+ }
+ }
+
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ setUpTomcat();
+ setUpHttpd();
+ }
+
+ @Override
+ public void tearDown() throws Exception {
+ if (httpd != null) {
+ httpd.stop();
+ httpd = null;
+ }
+ super.tearDown();
+ }
+
+ private void setUpTomcat() throws LifecycleException {
+ Tomcat tomcat = getTomcatInstance();
+ Context ctx = getProgrammaticRootContext();
+ for (Valve valve : getValveConfig()) {
+ ctx.getPipeline().addValve(valve);
+ }
+ Tomcat.addServlet(ctx, SERVLET_NAME, new SnoopServlet());
+ ctx.addServletMappingDecoded("/" + SERVLET_NAME, SERVLET_NAME);
+ tomcat.start();
+ tomcatPort = getPort();
+ }
+
+ private void setUpHttpd() throws IOException {
+ httpdPort = findFreePort();
+ httpdSslPort = findFreePort();
+ httpdConfDir = getTemporaryDirectory();
+ generateHttpdConfig(getHttpdConfig());
+
+ httpd = new TesterHttpd(httpdConfDir, httpdPort);
+ try {
+ httpd.start();
+ } catch (IOException | InterruptedException ioe) {
+ httpd = null;
+ } catch (IllegalStateException ise) {
+ httpd = null;
+ Assume.assumeFalse("Required httpd module not available", ise.getMessage() != null && ise.getMessage().contains("Cannot load modules"));
+ throw ise;
+ }
+ }
+
+ private static int findFreePort() throws IOException {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ public void generateHttpdConfig(String httpdConf) throws IOException {
+
+ httpdConf = HTTPD_CONFIG + httpdConf;
+
+ httpdConf = httpdConf.replace("%{HTTPD_PORT}", Integer.toString(httpdPort))
+ .replace("%{TOMCAT_PORT}", Integer.toString(tomcatPort))
+ .replace("%{SERVLET_NAME}", SERVLET_NAME)
+ .replace("%{CONF_DIR}", httpdConfDir.getAbsolutePath())
+ .replace("%{HTTPD_SSL_PORT}", Integer.toString(httpdSslPort))
+ .replace("%{SSL_CERT_FILE}", new File(TesterSupport.LOCALHOST_RSA_CERT_PEM).getAbsolutePath())
+ .replace("%{SSL_KEY_FILE}", new File(TesterSupport.LOCALHOST_RSA_KEY_PEM).getAbsolutePath())
+ .replace("%{SSL_CA_CERT_FILE}", new File(TesterSupport.CA_CERT_PEM).getAbsolutePath());
+
+ try (PrintWriter writer = new PrintWriter(new File(httpdConfDir, "httpd.conf"))) {
+ writer.write(httpdConf);
+ }
+
+ }
+
+ public int getHttpdPort() {
+ return httpdPort;
+ }
+
+ public int getHttpdSslPort() {
+ return httpdSslPort;
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestBasicProxy.java b/test/org/apache/tomcat/integration/httpd/TestBasicProxy.java
new file mode 100644
index 000000000000..e27c73b79dc0
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestBasicProxy.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Valve;
+import org.apache.tomcat.util.buf.ByteChunk;
+
+public class TestBasicProxy extends HttpdIntegrationBaseTest {
+
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ LoadModule headers_module modules/mod_headers.so
+ ProxyRequests Off
+ ProxyPreserveHost On
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ RequestHeader set X-Forwarded-For 140.211.11.130
+ RequestHeader set X-Forwarded-Proto "https"
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ return new ArrayList<>();
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ @Test
+ public void testBasicProxying() throws Exception {
+ ByteChunk res = new ByteChunk();
+ int rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint", res, false);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+
+ Assert.assertNotNull(requestDesc.getRequestInfo());
+ Assert.assertEquals("127.0.0.1", requestDesc.getRequestInfo("REQUEST-REMOTE-ADDR"));
+ Assert.assertEquals(getHttpdPort(), Integer.valueOf(requestDesc.getRequestInfo("REQUEST-SERVER-PORT")).intValue());
+ Assert.assertEquals(getPort(), Integer.valueOf(requestDesc.getRequestInfo("REQUEST-LOCAL-PORT")).intValue());
+ // httpd sets X-Forwarded-Proto: https, but without RemoteIpValve Tomcat does not process it.
+ Assert.assertEquals("http", requestDesc.getRequestInfo("REQUEST-SCHEME"));
+ Assert.assertEquals("false", requestDesc.getRequestInfo("REQUEST-IS-SECURE"));
+ Assert.assertNotNull(requestDesc.getHeaders());
+ Assert.assertNotNull(requestDesc.getHeader("X-Forwarded-For"));
+ Assert.assertEquals("https", requestDesc.getHeader("X-Forwarded-Proto"));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestChunkedTransferEncodingWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestChunkedTransferEncodingWithProxy.java
new file mode 100644
index 000000000000..f051a4ad2c0d
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestChunkedTransferEncodingWithProxy.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Valve;
+import org.apache.catalina.startup.BytesStreamer;
+import org.apache.tomcat.util.buf.ByteChunk;
+
+public class TestChunkedTransferEncodingWithProxy extends HttpdIntegrationBaseTest {
+
+ private static final int PAYLOAD_SIZE = 10 * 1024 * 1024 * 100;
+
+ private static final String HTTPD_CONFIG = """
+ LoadModule env_module modules/mod_env.so \s
+ SetEnv proxy-sendchunked 1
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ return new ArrayList<>();
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ /**
+ * Verify that chunked transfer encoding works correctly through the httpd reverse proxy
+ * which sets proxy-sendchunked to minimize resource usage by using chunked encoding.
+ */
+ @Test
+ public void testChunkedTransferEncoding() throws Exception {
+ byte[] payload = new byte[PAYLOAD_SIZE];
+ Arrays.fill(payload, (byte) 'A');
+
+ BytesStreamer streamer = new BytesStreamer() {
+ private boolean sent = false;
+
+ @Override
+ public int getLength() {
+ return -1;
+ }
+
+ @Override
+ public int available() {
+ return sent ? 0 : payload.length;
+ }
+
+ @Override
+ public byte[] next() {
+ sent = true;
+ return payload;
+ }
+ };
+
+ ByteChunk res = new ByteChunk();
+ Map> reqHead = new HashMap<>();
+ reqHead.put("Content-Type", List.of("application/octet-stream"));
+ int rc = postUrl(true, streamer, "http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+ Assert.assertEquals(String.valueOf(PAYLOAD_SIZE), requestDesc.getRequestInfo("REQUEST-BODY-SIZE"));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestErrorHandling.java b/test/org/apache/tomcat/integration/httpd/TestErrorHandling.java
new file mode 100644
index 000000000000..f6601ccc05ee
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestErrorHandling.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.io.Serial;
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.Valve;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.tomcat.util.buf.ByteChunk;
+
+public class TestErrorHandling extends HttpdIntegrationBaseTest {
+
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ ProxyRequests Off
+ ProxyPreserveHost On
+ ProxyPass / http://localhost:%{TOMCAT_PORT}/
+ ProxyPassReverse / http://localhost:%{TOMCAT_PORT}/
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ return new ArrayList<>();
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ @Test
+ public void test404NotFound() throws Exception {
+ int rc = getUrl("http://localhost:" + getHttpdPort() + "/nonexistent", new ByteChunk(), false);
+ Assert.assertEquals(HttpServletResponse.SC_NOT_FOUND, rc);
+ }
+
+ @Test
+ public void test500InternalError() throws Exception {
+ Context ctx = (Context) getTomcatInstance().getHost().findChildren()[0];
+ Tomcat.addServlet(ctx, "error", new HttpServlet() {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException {
+ throw new ServletException("Expected error");
+ }
+ });
+ ctx.addServletMappingDecoded("/error", "error");
+ int rc = getUrl("http://localhost:" + getHttpdPort() + "/error", new ByteChunk(), false);
+ Assert.assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, rc);
+ }
+
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestFullReverseProxy.java b/test/org/apache/tomcat/integration/httpd/TestFullReverseProxy.java
new file mode 100644
index 000000000000..829bd500cce9
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestFullReverseProxy.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Globals;
+import org.apache.catalina.Valve;
+import org.apache.catalina.valves.RemoteIpValve;
+import org.apache.catalina.valves.SSLValve;
+import org.apache.tomcat.util.buf.ByteChunk;
+import org.apache.tomcat.util.net.TesterSupport;
+
+public class TestFullReverseProxy extends HttpdIntegrationBaseTest {
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ LoadModule headers_module modules/mod_headers.so
+ LoadModule ssl_module modules/mod_ssl.so
+ SSLSessionCache none
+ Listen %{HTTPD_SSL_PORT} https
+
+ ServerName localhost:%{HTTPD_SSL_PORT}
+ SSLEngine on
+ SSLCertificateFile "%{SSL_CERT_FILE}"
+ SSLCertificateKeyFile "%{SSL_KEY_FILE}"
+ ProxyRequests Off
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s"
+ RequestHeader set SSL_CIPHER "%{SSL_CIPHER}s"
+ RequestHeader set SSL_SESSION_ID "%{SSL_SESSION_ID}s"
+ RequestHeader set SSL_CIPHER_USEKEYSIZE "%{SSL_CIPHER_USEKEYSIZE}s"
+ SSLVerifyClient optional
+ SSLCACertificateFile "%{SSL_CA_CERT_FILE}"
+ SSLOptions +ExportCertData
+ ProxyAddHeaders Off
+ RequestHeader set X-Forwarded-For 140.211.11.130
+ RequestHeader set X-Forwarded-Proto https
+ RequestHeader set X-Forwarded-Host whoamI.tomcat
+
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ List valves = new ArrayList<>();
+
+ RemoteIpValve remoteIpValve = new RemoteIpValve();
+ remoteIpValve.setHostHeader("X-Forwarded-Host");
+ valves.add(remoteIpValve);
+
+ SSLValve sslValve = new SSLValve();
+ valves.add(sslValve);
+
+ return valves;
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ @Test
+ public void testFullReverseProxying() throws Exception {
+ TesterSupport.configureClientSsl();
+
+ ByteChunk res = new ByteChunk();
+ int rc = getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint", res, false);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+
+ Assert.assertNotNull(requestDesc.getRequestInfo());
+ Assert.assertEquals("140.211.11.130", requestDesc.getRequestInfo("REQUEST-REMOTE-ADDR"));
+ Assert.assertEquals(443, Integer.valueOf(requestDesc.getRequestInfo("REQUEST-SERVER-PORT")).intValue());
+ Assert.assertEquals("https", requestDesc.getRequestInfo("REQUEST-SCHEME"));
+ Assert.assertEquals("true", requestDesc.getRequestInfo("REQUEST-IS-SECURE"));
+ Assert.assertEquals("whoamI.tomcat", requestDesc.getRequestInfo("REQUEST-SERVER-NAME"));
+
+ Assert.assertNotNull(requestDesc.getHeaders());
+ Assert.assertNull(requestDesc.getHeader("X-Forwarded-For"));
+ Assert.assertEquals("https", requestDesc.getHeader("X-Forwarded-Proto"));
+
+
+ Assert.assertNotNull(requestDesc.getAttributes());
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.CIPHER_SUITE_ATTR));
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.SSL_SESSION_ID_ATTR));
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.KEY_SIZE_ATTR));
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.CERTIFICATES_ATTR));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestLargePayloadWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestLargePayloadWithProxy.java
new file mode 100644
index 000000000000..992d0ce6dd2c
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestLargePayloadWithProxy.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Valve;
+import org.apache.catalina.startup.BytesStreamer;
+import org.apache.tomcat.util.buf.ByteChunk;
+
+public class TestLargePayloadWithProxy extends HttpdIntegrationBaseTest {
+
+ private static final int PAYLOAD_SIZE = 10 * 1024 * 1024;
+
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ return new ArrayList<>();
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ /**
+ * Verify that a large POST body passes through the httpd reverse proxy
+ */
+ @Test
+ public void testLargePostBody() throws Exception {
+ byte[] payload = new byte[PAYLOAD_SIZE];
+ Arrays.fill(payload, (byte) 'A');
+
+ ByteChunk res = new ByteChunk();
+ Map> reqHead = new HashMap<>();
+ reqHead.put("Content-Type", List.of("application/octet-stream"));
+ int rc = postUrl(payload, "http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+ Assert.assertEquals(String.valueOf(PAYLOAD_SIZE), requestDesc.getRequestInfo("REQUEST-BODY-SIZE"));
+ }
+
+ /**
+ * Verify that chunked transfer encoding works correctly through the httpd reverse proxy.
+ */
+ @Test
+ public void testChunkedTransferEncoding() throws Exception {
+ byte[] payload = new byte[PAYLOAD_SIZE];
+ Arrays.fill(payload, (byte) 'A');
+
+ BytesStreamer streamer = new BytesStreamer() {
+ private boolean sent = false;
+
+ @Override
+ public int getLength() {
+ return -1;
+ }
+
+ @Override
+ public int available() {
+ return sent ? 0 : payload.length;
+ }
+
+ @Override
+ public byte[] next() {
+ sent = true;
+ return payload;
+ }
+ };
+
+ ByteChunk res = new ByteChunk();
+ Map> reqHead = new HashMap<>();
+ reqHead.put("Content-Type", List.of("application/octet-stream"));
+ int rc = postUrl(true, streamer, "http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+ Assert.assertEquals(String.valueOf(PAYLOAD_SIZE), requestDesc.getRequestInfo("REQUEST-BODY-SIZE"));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestRemoteIpValveWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestRemoteIpValveWithProxy.java
new file mode 100644
index 000000000000..24dfe2fc1c97
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestRemoteIpValveWithProxy.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Valve;
+import org.apache.catalina.valves.RemoteIpValve;
+import org.apache.tomcat.util.buf.ByteChunk;
+
+public class TestRemoteIpValveWithProxy extends HttpdIntegrationBaseTest {
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ LoadModule headers_module modules/mod_headers.so
+ ProxyRequests Off
+ ProxyPreserveHost On
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyAddHeaders Off
+ RequestHeader set X-Forwarded-For 140.211.11.130
+ RequestHeader set X-Forwarded-Proto https
+ RequestHeader set X-Forwarded-Host whoamI.tomcat
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ List valves = new ArrayList<>();
+
+ RemoteIpValve remoteIpValve = new RemoteIpValve();
+ remoteIpValve.setHostHeader("X-Forwarded-Host");
+ valves.add(remoteIpValve);
+
+ return valves;
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ @Test
+ public void testRemoteIpValveProxying() throws Exception {
+ ByteChunk res = new ByteChunk();
+ int rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint", res, false);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+
+ Assert.assertNotNull(requestDesc.getRequestInfo());
+ Assert.assertEquals("140.211.11.130", requestDesc.getRequestInfo("REQUEST-REMOTE-ADDR"));
+ Assert.assertEquals(443, Integer.valueOf(requestDesc.getRequestInfo("REQUEST-SERVER-PORT")).intValue());
+ Assert.assertEquals(getPort(), Integer.valueOf(requestDesc.getRequestInfo("REQUEST-LOCAL-PORT")).intValue());
+ Assert.assertEquals("https", requestDesc.getRequestInfo("REQUEST-SCHEME"));
+ Assert.assertEquals("true", requestDesc.getRequestInfo("REQUEST-IS-SECURE"));
+ Assert.assertEquals("whoamI.tomcat", requestDesc.getRequestInfo("REQUEST-SERVER-NAME"));
+ Assert.assertNotNull(requestDesc.getHeaders());
+ Assert.assertNull(requestDesc.getHeader("X-Forwarded-For"));
+ Assert.assertEquals("https", requestDesc.getHeader("X-Forwarded-Proto"));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy01.java b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy01.java
new file mode 100644
index 000000000000..65e2b883d979
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy01.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Globals;
+import org.apache.catalina.Valve;
+import org.apache.catalina.valves.SSLValve;
+import org.apache.tomcat.util.buf.ByteChunk;
+import org.apache.tomcat.util.net.TesterSupport;
+
+public class TestSSLValveWithProxy01 extends HttpdIntegrationBaseTest {
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ LoadModule headers_module modules/mod_headers.so
+ LoadModule ssl_module modules/mod_ssl.so
+ SSLSessionCache none
+ Listen %{HTTPD_SSL_PORT} https
+
+ ServerName localhost:%{HTTPD_SSL_PORT}
+ SSLEngine on
+ SSLCertificateFile "%{SSL_CERT_FILE}"
+ SSLCertificateKeyFile "%{SSL_KEY_FILE}"
+ ProxyRequests Off
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s"
+ RequestHeader set SSL_CIPHER "%{SSL_CIPHER}s"
+ RequestHeader set SSL_SESSION_ID "%{SSL_SESSION_ID}s"
+ RequestHeader set SSL_CIPHER_USEKEYSIZE "%{SSL_CIPHER_USEKEYSIZE}s"
+
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ List valves = new ArrayList<>();
+
+ SSLValve sslValve = new SSLValve();
+ valves.add(sslValve);
+
+ return valves;
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ @Test
+ public void testSSLValveProxying() throws Exception {
+ TesterSupport.configureClientSsl();
+
+ ByteChunk res = new ByteChunk();
+ int rc = getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint", res, false);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+
+ Assert.assertNotNull(requestDesc.getAttributes());
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.CIPHER_SUITE_ATTR));
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.SSL_SESSION_ID_ATTR));
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.KEY_SIZE_ATTR));
+ // No client certificate in this test, mod_ssl sends null which SSLValve correctly treats as absent.
+ Assert.assertNull(requestDesc.getAttribute(Globals.CERTIFICATES_ATTR));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy02.java b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy02.java
new file mode 100644
index 000000000000..b1ee23803f4f
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestSSLValveWithProxy02.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Globals;
+import org.apache.catalina.Valve;
+import org.apache.catalina.valves.SSLValve;
+import org.apache.tomcat.util.buf.ByteChunk;
+import org.apache.tomcat.util.net.TesterSupport;
+
+public class TestSSLValveWithProxy02 extends HttpdIntegrationBaseTest {
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ LoadModule headers_module modules/mod_headers.so
+ LoadModule ssl_module modules/mod_ssl.so
+ SSLSessionCache none
+ Listen %{HTTPD_SSL_PORT} https
+
+ ServerName localhost:%{HTTPD_SSL_PORT}
+ SSLEngine on
+ SSLCertificateFile "%{SSL_CERT_FILE}"
+ SSLCertificateKeyFile "%{SSL_KEY_FILE}"
+ ProxyRequests Off
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ RequestHeader set SSL_CLIENT_CERT "%{SSL_CLIENT_CERT}s"
+ RequestHeader set SSL_CIPHER "%{SSL_CIPHER}s"
+ RequestHeader set SSL_SESSION_ID "%{SSL_SESSION_ID}s"
+ RequestHeader set SSL_CIPHER_USEKEYSIZE "%{SSL_CIPHER_USEKEYSIZE}s"
+ SSLVerifyClient optional
+ SSLCACertificateFile "%{SSL_CA_CERT_FILE}"
+ SSLOptions +ExportCertData
+
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ List valves = new ArrayList<>();
+
+ SSLValve sslValve = new SSLValve();
+ valves.add(sslValve);
+
+ return valves;
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ @Test
+ public void testSSLValveProxying() throws Exception {
+ TesterSupport.configureClientSsl();
+
+ ByteChunk res = new ByteChunk();
+ int rc = getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint", res, false);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+
+ Assert.assertNotNull(requestDesc.getAttribute(Globals.CERTIFICATES_ATTR));
+ }
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TestSessionWithProxy.java b/test/org/apache/tomcat/integration/httpd/TestSessionWithProxy.java
new file mode 100644
index 000000000000..a021b741a2c9
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TestSessionWithProxy.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Valve;
+import org.apache.catalina.valves.RemoteIpValve;
+import org.apache.tomcat.util.buf.ByteChunk;
+import org.apache.tomcat.util.net.TesterSupport;
+
+public class TestSessionWithProxy extends HttpdIntegrationBaseTest {
+ private static final String HTTPD_CONFIG = """
+ LoadModule proxy_module modules/mod_proxy.so
+ LoadModule proxy_http_module modules/mod_proxy_http.so
+ LoadModule headers_module modules/mod_headers.so
+ LoadModule ssl_module modules/mod_ssl.so
+ SSLSessionCache none
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ Listen %{HTTPD_SSL_PORT} https
+
+ ServerName localhost:%{HTTPD_SSL_PORT}
+ SSLEngine on
+ SSLCertificateFile "%{SSL_CERT_FILE}"
+ SSLCertificateKeyFile "%{SSL_KEY_FILE}"
+ ProxyPass /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ ProxyPassReverse /endpoint http://localhost:%{TOMCAT_PORT}/%{SERVLET_NAME}
+ RequestHeader set X-Forwarded-Proto https
+
+ """;
+
+ @Override
+ protected List getValveConfig() {
+ List valves = new ArrayList<>();
+
+ RemoteIpValve remoteIpValve = new RemoteIpValve();
+ valves.add(remoteIpValve);
+
+ return valves;
+ }
+
+ @Override
+ protected String getHttpdConfig() {
+ return HTTPD_CONFIG;
+ }
+
+ /**
+ * Verify that a session created through httpd can be retrieved
+ * on a subsequent request using the session cookie.
+ */
+ @Test
+ public void testSessionCookieSetAndRetrieved() throws Exception {
+ // Create a session
+ ByteChunk res = new ByteChunk();
+ Map> resHead = new HashMap<>();
+ int rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint?createSession=true", res, null, resHead);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+
+ RequestDescriptor requestDesc = SnoopResult.parse(res.toString());
+ Assert.assertNotNull(requestDesc.getRequestInfo());
+ String sessionId = requestDesc.getRequestInfo("SESSION-ID");
+ Assert.assertNotNull(sessionId);
+ Assert.assertEquals("true", requestDesc.getRequestInfo("SESSION-IS-NEW"));
+
+ String setCookie = resHead.get("Set-Cookie").getFirst();
+ Assert.assertTrue(setCookie.contains("JSESSIONID"));
+
+ // Send the session cookie back
+ Map> reqHead = new HashMap<>();
+ reqHead.put("Cookie", List.of("JSESSIONID=" + sessionId));
+ rc = getUrl("http://localhost:" + getHttpdPort() + "/endpoint", res, reqHead, null);
+ Assert.assertEquals(HttpServletResponse.SC_OK, rc);
+
+ requestDesc = SnoopResult.parse(res.toString());
+ Assert.assertNotNull(requestDesc.getRequestInfo());
+ Assert.assertEquals(sessionId, requestDesc.getRequestInfo("SESSION-ID"));
+ Assert.assertEquals("false", requestDesc.getRequestInfo("SESSION-IS-NEW"));
+ }
+
+ /**
+ * Verify that when SSL is used at httpd, but not Tomcat, and RemoteIpValve
+ * sets the scheme to https, session cookies have the Secure flag.
+ */
+ @Test
+ public void testSecureCookieWithSslTermination() throws Exception {
+ TesterSupport.configureClientSsl();
+ ByteChunk res = new ByteChunk();
+ Map> resHead = new HashMap<>();
+ getUrl("https://localhost:" + getHttpdSslPort() + "/endpoint?createSession=true", res, null, resHead);
+ Assert.assertTrue("Session cookie should have Secure flag", resHead.get("Set-Cookie").getFirst().contains("Secure"));
+ }
+
+}
diff --git a/test/org/apache/tomcat/integration/httpd/TesterHttpd.java b/test/org/apache/tomcat/integration/httpd/TesterHttpd.java
new file mode 100644
index 000000000000..691dd428cd11
--- /dev/null
+++ b/test/org/apache/tomcat/integration/httpd/TesterHttpd.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.tomcat.integration.httpd;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.io.Reader;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Assert;
+
+
+public class TesterHttpd {
+
+ private final File httpdConfDir;
+ private final int httpdPort;
+
+ private static final String HTTPD_PATH = "tomcat.test.httpd.path";
+
+ private Process p;
+
+ public TesterHttpd(File httpdConfDir, int httpdPort) {
+ this.httpdConfDir = httpdConfDir;
+ this.httpdPort = httpdPort;
+ }
+
+ public void start() throws IOException, InterruptedException {
+ start(false);
+ }
+
+ public void start(boolean swallowOutput) throws IOException, InterruptedException {
+ if (p != null) {
+ throw new IllegalStateException("Already started");
+ }
+
+ String httpdPath = System.getProperty(HTTPD_PATH);
+ if (httpdPath == null || httpdPath.isEmpty()) {
+ httpdPath = "httpd";
+ }
+
+ File httpdConfFile = new File(httpdConfDir, "httpd.conf");
+ validateHttpdConfig(httpdPath, httpdConfFile.getAbsolutePath());
+
+ List cmd = new ArrayList<>(4);
+ cmd.add(httpdPath);
+ cmd.add("-f");
+ cmd.add(httpdConfFile.getAbsolutePath());
+ cmd.add("-X");
+
+ ProcessBuilder pb = new ProcessBuilder(cmd.toArray(new String[0]));
+
+ p = pb.start();
+
+ redirect(p.inputReader(), System.out, swallowOutput);
+ redirect(p.errorReader(), System.err, swallowOutput);
+
+ Assert.assertTrue(p.isAlive() && isHttpdReady());
+ }
+
+ public void stop() {
+ if (p == null) {
+ throw new IllegalStateException("Not started");
+ }
+ p.destroy();
+
+ try {
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Failed to stop");
+ }
+ } catch (InterruptedException e) {
+ throw new IllegalStateException("Interrupted while waiting to stop", e);
+ }
+ }
+
+ private void redirect(final Reader r, final PrintStream os, final boolean swallow) {
+ /*
+ * InputStream will close when process ends. Thread will exit once stream closes.
+ */
+ new Thread( () -> {
+ char[] cbuf = new char[1024];
+ try {
+ int read;
+ while ((read = r.read(cbuf)) > 0) {
+ if (!swallow) {
+ os.print(new String(cbuf, 0, read));
+ }
+ }
+ } catch (IOException ignore) {
+ // Ignore
+ }
+
+ }).start();
+ }
+
+ private static void validateHttpdConfig(final String httpdPath, final String httpdConfPath) throws IOException, InterruptedException {
+ List cmd = new ArrayList<>(4);
+
+ cmd.add(httpdPath);
+ cmd.add("-t");
+ cmd.add("-f");
+ cmd.add(httpdConfPath);
+
+ ProcessBuilder pb = new ProcessBuilder(cmd.toArray(new String[0]));
+ pb.redirectErrorStream(true);
+
+ Process p = pb.start();
+
+ String output = new String(p.getInputStream().readAllBytes());
+ int exitCode = p.waitFor();
+
+ if (exitCode != 0) {
+ throw new IllegalStateException("Httpd configuration invalid. Output: " + output);
+ }
+ }
+
+ @SuppressWarnings("BusyWait")
+ private boolean isHttpdReady() throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 1000;
+ while (System.currentTimeMillis() < deadline) {
+ try (Socket ignored = new Socket("localhost", this.httpdPort)) {
+ return true;
+ } catch (IOException e) {
+ Thread.sleep(100);
+ }
+ }
+ throw new IllegalStateException("Httpd has not been started.");
+ }
+
+
+}
diff --git a/test/org/apache/tomcat/integration/httpd/httpd-binary.lock b/test/org/apache/tomcat/integration/httpd/httpd-binary.lock
new file mode 100644
index 000000000000..e69de29bb2d1