diff --git a/exist-core/src/main/java/org/exist/http/servlets/HeaderPassthroughResponse.java b/exist-core/src/main/java/org/exist/http/servlets/HeaderPassthroughResponse.java
new file mode 100644
index 00000000000..e8adf709644
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/http/servlets/HeaderPassthroughResponse.java
@@ -0,0 +1,54 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.servlets;
+
+/**
+ * Capability of a response wrapper that buffers its output (and so cannot let a header take
+ * effect on the real, underlying response immediately) to still accept a header that must
+ * bypass that buffering and reach the real response right away, regardless of whether this
+ * particular wrapper instance ever gets flushed.
+ *
+ * {@code org.exist.http.urlrewrite.XQueryURLRewrite$CachingResponseWrapper} is the sole
+ * implementer: it buffers everything a dispatched pipeline step writes -- including headers a
+ * step sets itself, whether via the underlying servlet's own resource handling or the XQuery
+ * {@code response:set-header()} function -- because that step's output may be discarded in favor
+ * of a later {@code } step's, in which case its buffered writes are correctly never
+ * replayed. A {@code controller.xql} {@code } directive is different: it is
+ * pipeline-level configuration, not step output, and must survive regardless of which step's
+ * output eventually wins -- so it needs a way to skip buffering entirely, tunnelling through
+ * however many wrapper layers are stacked (a forward step may itself trigger a nested dispatch,
+ * wrapping one {@code CachingResponseWrapper} around another) until it reaches a response that
+ * isn't one of these wrappers at all, and applying itself there, immediately.
+ *
+ * @see org.exist.http.urlrewrite.URLRewrite#setHeaders
+ */
+public interface HeaderPassthroughResponse {
+
+ /**
+ * Set a header on the real, underlying response immediately, bypassing any output buffering
+ * this response wrapper (or any wrapper it in turn wraps) would otherwise apply.
+ *
+ * @param name the header name.
+ * @param value the header value.
+ */
+ void setPassthroughHeader(String name, String value);
+}
diff --git a/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java b/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java
index 99442875b77..32b797f2fdf 100644
--- a/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java
+++ b/exist-core/src/main/java/org/exist/http/servlets/HttpResponseWrapper.java
@@ -180,6 +180,29 @@ public void setHeader(final String name, final String value) {
response.setHeader(name, encode(value));
}
+ /**
+ * Set a header the same way {@link #setHeader(String, String)} does, except that if the
+ * wrapped response buffers its output (see {@link HeaderPassthroughResponse}), the header
+ * bypasses that buffering and applies to the real response immediately. Used exclusively by
+ * {@code URLRewrite#setHeaders} to apply a controller.xql {@code }
+ * directive -- pipeline configuration, not step output -- so it survives regardless of which
+ * pipeline step's buffered output eventually gets flushed. Deliberately not used by
+ * {@link #setHeader(String, String)} itself, which backs the XQuery
+ * {@code response:set-header()} function: a step's own programmatic header call is step
+ * output and must stay subject to the same buffering as everything else that step writes.
+ *
+ * @param name the name of the header.
+ * @param value the header value.
+ */
+ public void setPassthroughHeader(final String name, final String value) {
+ final String encoded = encode(value);
+ if (response instanceof HeaderPassthroughResponse passthroughResponse) {
+ passthroughResponse.setPassthroughHeader(name, encoded);
+ } else {
+ response.setHeader(name, encoded);
+ }
+ }
+
@Override
public void setIntHeader(final String name, final int value) {
response.setIntHeader(name, value);
diff --git a/exist-core/src/main/java/org/exist/http/urlrewrite/URLRewrite.java b/exist-core/src/main/java/org/exist/http/urlrewrite/URLRewrite.java
index 124fadf322c..e9bd260d81c 100644
--- a/exist-core/src/main/java/org/exist/http/urlrewrite/URLRewrite.java
+++ b/exist-core/src/main/java/org/exist/http/urlrewrite/URLRewrite.java
@@ -251,7 +251,12 @@ public void prepareRequest(final XQueryURLRewrite.RequestWrapper request) {
protected void setHeaders(final HttpResponseWrapper response) {
if (headers != null) {
for (final Map.Entry entry : headers.entrySet()) {
- response.setHeader(entry.getKey(), entry.getValue());
+ // setPassthroughHeader(), not setHeader(): an directive is
+ // pipeline configuration, not a step's own output, and must survive regardless of
+ // which step's buffered output eventually gets flushed. See
+ // HeaderPassthroughResponse for why this can't just be setHeader()'s default
+ // behavior.
+ response.setPassthroughHeader(entry.getKey(), entry.getValue());
}
}
}
diff --git a/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java b/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java
index 4eb9a152317..7be0636ed14 100644
--- a/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java
+++ b/exist-core/src/main/java/org/exist/http/urlrewrite/XQueryURLRewrite.java
@@ -35,6 +35,7 @@
import org.exist.http.Descriptor;
import org.exist.http.servlets.Authenticator;
import org.exist.http.servlets.BasicAuthenticator;
+import org.exist.http.servlets.HeaderPassthroughResponse;
import org.exist.http.servlets.HttpRequestWrapper;
import org.exist.http.servlets.HttpResponseWrapper;
import org.exist.security.AuthenticationException;
@@ -1329,7 +1330,8 @@ public long getDateHeader(final String s) {
}
}
- private static class CachingResponseWrapper extends HttpServletResponseWrapper {
+ private static class CachingResponseWrapper extends HttpServletResponseWrapper
+ implements HeaderPassthroughResponse {
/**
* Tracks which of {@link #getWriter()} / {@link #getOutputStream()} this response has
@@ -1418,38 +1420,64 @@ public String getContentType() {
}
/**
- * Headers that describe an intermediate resource -- not the final output -- and so must not
- * leak onto the real response while buffering. A static resource forward step (e.g. Jetty's
- * default/ResourceServlet serving a plain file) sets these from the FILE's own properties
- * (its length, its modification time, its identity, whether it supports byte ranges), through
- * any of setHeader/addHeader/setIntHeader/addIntHeader/setDateHeader/addDateHeader --
- * HttpServletResponseWrapper's defaults for all of these delegate straight to the real
- * underlying response with no cache awareness. Without this guard they leak onto the real
- * response before a later step's own, different output is flushed to it --
- * Content-Length doing exactly this is what broke https://github.com/eXist-db/exist/issues/6669;
- * Last-Modified/ETag/Accept-Ranges are the same shape of bug, just without (yet) a hard
- * failure mode of their own to force the issue.
+ * A header write this step made while buffering, recorded so it can be replayed -- in the
+ * exact original order, through the exact original typed method -- onto the real response
+ * from {@link #flush()}, but only if this wrapper is the one that actually gets flushed.
+ * If a later {@code } step replaces this step's output, this wrapper (and
+ * everything buffered in it) is simply discarded without ever being replayed: correct,
+ * because the header described a step whose output turned out not to be the final one.
*
- * Deliberately narrower than "buffer every header while caching": headers set explicitly via
- * controller.xql's {@code } ({@link URLRewrite#setHeaders}, called before
- * {@code doRewrite()}'s {@code dispatcher.forward()}) must keep going straight through
- * immediately, since {@code applyViews()} discards each step's wrapper for a fresh one around
- * the real response on the next view step and only ever flushes the last one -- buffering
- * those too would silently drop them.
+ * Replaying the original call verbatim (rather than pre-computing a final name-to-value
+ * map here) means the real response's own semantics decide what "wins" when the same
+ * header is set multiple times (setHeader replaces, addHeader appends) -- exactly as they
+ * would have if these calls had never been buffered at all.
*/
- private static final Set BUFFERED_RESOURCE_METADATA_HEADERS =
- Set.of("content-length", "last-modified", "etag", "accept-ranges");
+ private sealed interface BufferedHeader {
+ record Set(String name, String value) implements BufferedHeader {
+ }
+
+ record Add(String name, String value) implements BufferedHeader {
+ }
+
+ record SetInt(String name, int value) implements BufferedHeader {
+ }
+
+ record AddInt(String name, int value) implements BufferedHeader {
+ }
+
+ record SetDate(String name, long value) implements BufferedHeader {
+ }
- private boolean isBufferedResourceMetadataHeader(final String name) {
- return cache && BUFFERED_RESOURCE_METADATA_HEADERS.contains(name.toLowerCase(Locale.ROOT));
+ record AddDate(String name, long value) implements BufferedHeader {
+ }
+ }
+
+ private final List bufferedHeaders = new ArrayList<>();
+
+ /**
+ * Set a header on the real response immediately, bypassing buffering, tunnelling through
+ * however many {@code CachingResponseWrapper} layers are stacked (a forward step may
+ * itself trigger a nested dispatch) until reaching a response that isn't one of these
+ * wrappers. See {@link HeaderPassthroughResponse}: this exists solely for
+ * {@link URLRewrite#setHeaders}'s controller.xql {@code } directives,
+ * which are pipeline configuration, not step output, and so must survive regardless of
+ * which step's buffered output eventually wins.
+ */
+ @Override
+ public void setPassthroughHeader(final String name, final String value) {
+ if (getResponse() instanceof HeaderPassthroughResponse passthroughResponse) {
+ passthroughResponse.setPassthroughHeader(name, value);
+ } else {
+ super.setHeader(name, value);
+ }
}
@Override
public void setHeader(final String name, final String value) {
if ("Content-Type".equals(name)) {
setContentType(value);
- } else if (isBufferedResourceMetadataHeader(name)) {
- return;
+ } else if (cache) {
+ bufferedHeaders.add(new BufferedHeader.Set(name, value));
} else {
super.setHeader(name, value);
}
@@ -1460,11 +1488,12 @@ public void addHeader(final String name, final String value) {
if ("Content-Type".equals(name)) {
// Route through setContentType() like setHeader() does -- otherwise a step that sets
// Content-Type via addHeader() (e.g. Jetty's default/ResourceServlet serving a static
- // file) bypasses the contentType tracking entirely and leaks straight onto the real
- // response, so a later step's actual declared Content-Type never sticks.
+ // file) bypasses the contentType tracking entirely, which would leak it straight onto
+ // the real response even though a later step's own Content-Type should
+ // win instead.
setContentType(value);
- } else if (isBufferedResourceMetadataHeader(name)) {
- return;
+ } else if (cache) {
+ bufferedHeaders.add(new BufferedHeader.Add(name, value));
} else {
super.addHeader(name, value);
}
@@ -1472,34 +1501,38 @@ public void addHeader(final String name, final String value) {
@Override
public void setIntHeader(final String name, final int value) {
- if (isBufferedResourceMetadataHeader(name)) {
- return;
+ if (cache) {
+ bufferedHeaders.add(new BufferedHeader.SetInt(name, value));
+ } else {
+ super.setIntHeader(name, value);
}
- super.setIntHeader(name, value);
}
@Override
public void addIntHeader(final String name, final int value) {
- if (isBufferedResourceMetadataHeader(name)) {
- return;
+ if (cache) {
+ bufferedHeaders.add(new BufferedHeader.AddInt(name, value));
+ } else {
+ super.addIntHeader(name, value);
}
- super.addIntHeader(name, value);
}
@Override
public void setDateHeader(final String name, final long date) {
- if (isBufferedResourceMetadataHeader(name)) {
- return;
+ if (cache) {
+ bufferedHeaders.add(new BufferedHeader.SetDate(name, date));
+ } else {
+ super.setDateHeader(name, date);
}
- super.setDateHeader(name, date);
}
@Override
public void addDateHeader(final String name, final long date) {
- if (isBufferedResourceMetadataHeader(name)) {
- return;
+ if (cache) {
+ bufferedHeaders.add(new BufferedHeader.AddDate(name, date));
+ } else {
+ super.addDateHeader(name, date);
}
- super.addDateHeader(name, date);
}
@Override
@@ -1557,13 +1590,18 @@ public void flush() throws IOException {
if (cache && contentType != null) {
super.setContentType(contentType);
}
+ if (cache) {
+ replayBufferedHeaders();
+ }
if (sos != null) {
final byte[] data = sos.getData();
// Set the real Content-Length explicitly rather than leaving it to the container to
- // infer (e.g. via chunked transfer encoding). The buffered step's own Content-Length
- // headers are deliberately suppressed above (see isBufferedContentLength()) precisely
- // because they'd be wrong for this, the final, output -- this is where the correct
- // value actually gets set, from the bytes that are really about to be written.
+ // infer (e.g. via chunked transfer encoding). Deliberately not part of the generic
+ // replay above -- any buffered Content-Length (from this step's own setHeader/
+ // setIntHeader/etc. call) is skipped there and superseded by this, because it's the
+ // one header whose correctness is independently checkable against the bytes actually
+ // being written, and trusting a self-reported value over that is exactly how #6669
+ // happened.
if (cache) {
super.setContentLengthLong(data.length);
}
@@ -1572,6 +1610,32 @@ public void flush() throws IOException {
out.flush();
}
}
+
+ private void replayBufferedHeaders() {
+ for (final BufferedHeader header : bufferedHeaders) {
+ final String name = switch (header) {
+ case BufferedHeader.Set(final String n, final String ignored) -> n;
+ case BufferedHeader.Add(final String n, final String ignored) -> n;
+ case BufferedHeader.SetInt(final String n, final int ignored) -> n;
+ case BufferedHeader.AddInt(final String n, final int ignored) -> n;
+ case BufferedHeader.SetDate(final String n, final long ignored) -> n;
+ case BufferedHeader.AddDate(final String n, final long ignored) -> n;
+ };
+ if ("Content-Length".equalsIgnoreCase(name)) {
+ // See flush(): the real Content-Length is derived from the actual buffered byte
+ // count, never trusted from a step's own self-reported value.
+ continue;
+ }
+ switch (header) {
+ case BufferedHeader.Set(final String n, final String value) -> super.setHeader(n, value);
+ case BufferedHeader.Add(final String n, final String value) -> super.addHeader(n, value);
+ case BufferedHeader.SetInt(final String n, final int value) -> super.setIntHeader(n, value);
+ case BufferedHeader.AddInt(final String n, final int value) -> super.addIntHeader(n, value);
+ case BufferedHeader.SetDate(final String n, final long value) -> super.setDateHeader(n, value);
+ case BufferedHeader.AddDate(final String n, final long value) -> super.addDateHeader(n, value);
+ }
+ }
+ }
}
private static class CachingServletOutputStream extends ServletOutputStream {
diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteFinalStepResponseSetHeaderSurvivesTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteFinalStepResponseSetHeaderSurvivesTest.java
new file mode 100644
index 00000000000..ce558b1b2cb
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteFinalStepResponseSetHeaderSurvivesTest.java
@@ -0,0 +1,144 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.urlrewrite;
+
+import org.exist.http.AbstractHttpTest;
+import org.exist.test.ExistWebServer;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+
+import static java.net.HttpURLConnection.HTTP_OK;
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Characterization test, not a red/green regression test -- like
+ * {@link URLRewriteSetHeaderSurvivesViewPipelineTest}, it passes both before and after the
+ * {@code CachingResponseWrapper} header-buffering redesign it accompanies. It pins the case that
+ * broke a first attempt at that redesign (an allowlist-based "suppress anything not in
+ * controller.xql's static config" approach): a header the final, actually-flushed pipeline
+ * step sets programmatically via XQuery's {@code response:set-header()} -- with no static config
+ * representation at all -- must still reach the client. A naive "buffer while cache=true, drop
+ * anything not explicitly configured" implementation drops this along with the genuinely
+ * intermediate-step headers {@link URLRewriteResponseSetHeaderViewPipelineTest} covers, because it
+ * cannot distinguish "this step's wrapper is the one that gets flushed" from "this step's wrapper
+ * gets discarded" using config alone -- {@code cache=true} means "a view or error-handler exists
+ * somewhere in the pipeline," not "this step is provisional."
+ */
+public class URLRewriteFinalStepResponseSetHeaderSurvivesTest {
+
+ @ClassRule
+ public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false);
+
+ private static final String TEST_COLLECTION = "/db/apps/test-final-step-set-header";
+
+ private static final String CONTROLLER_XQ = """
+ xquery version "3.1";
+ declare namespace exist = "http://exist.sourceforge.net/NS/exist";
+
+
+
+
+
+
+
+ """;
+
+ private static final String STEP1_XQL = """
+ xquery version "3.1";
+
+ Hello""";
+
+ private static final String VIEW_XQL = """
+ xquery version "3.1";
+ declare option exist:serialize "method=xhtml media-type=text/html indent=yes";
+
+ let $data := request:get-data()
+ return (
+ (: The final step -- the one whose output actually reaches the client -- sets a
+ header programmatically, not via config. :)
+ response:set-header("X-Custom-Header", "custom-value"),
+ View saw: { $data//step1/text() }
+ )""";
+
+ @BeforeClass
+ public static void setup() throws Exception {
+ final String restUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest" + TEST_COLLECTION;
+
+ storeViaRest(restUrl + "/controller.xql", CONTROLLER_XQ, "application/xquery");
+ storeViaRest(restUrl + "/step1.xql", STEP1_XQL, "application/xquery");
+ storeViaRest(restUrl + "/view.xql", VIEW_XQL, "application/xquery");
+
+ final String chmod = "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/controller.xql'), 'rwxr-xr-x')," +
+ "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/step1.xql'), 'rwxr-xr-x')," +
+ "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/view.xql'), 'rwxr-xr-x')";
+ final String chmodUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest/db?_query=" +
+ URLEncoder.encode(chmod, StandardCharsets.UTF_8) + "&_wrap=no";
+ final HttpRequest chmodRequest = AbstractHttpTest.authenticatedRequest(URI.create(chmodUrl), "admin", "")
+ .GET()
+ .build();
+ AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), chmodRequest);
+ }
+
+ @AfterClass
+ public static void teardown() throws Exception {
+ final String deleteUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest" + TEST_COLLECTION;
+ final HttpRequest deleteRequest = AbstractHttpTest.authenticatedRequest(URI.create(deleteUrl), "admin", "")
+ .DELETE()
+ .build();
+ AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), deleteRequest);
+ }
+
+ @Test
+ public void finalStepResponseSetHeaderSurvivesToClient() throws IOException, InterruptedException {
+ final String url = "http://localhost:" + existWebServer.getPort()
+ + "/exist/apps/test-final-step-set-header/test";
+
+ final HttpClient client = AbstractHttpTest.newHttpClient();
+ final HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
+ final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+
+ assertEquals("Expected 200 OK but got " + response.statusCode() + ": "
+ + response.body().substring(0, Math.min(300, response.body().length())),
+ HTTP_OK, response.statusCode());
+ assertEquals("The final step's own response:set-header() call must reach the client",
+ "custom-value", response.headers().firstValue("X-Custom-Header").orElse(null));
+ }
+
+ private static void storeViaRest(final String url, final String content, final String contentType)
+ throws IOException {
+ final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(url), "admin", "")
+ .header("Content-Type", contentType + "; charset=UTF-8")
+ .PUT(HttpRequest.BodyPublishers.ofString(content, StandardCharsets.UTF_8))
+ .build();
+ AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), request);
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteResponseSetHeaderViewPipelineTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteResponseSetHeaderViewPipelineTest.java
new file mode 100644
index 00000000000..74568fe88d4
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteResponseSetHeaderViewPipelineTest.java
@@ -0,0 +1,150 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.urlrewrite;
+
+import org.exist.http.AbstractHttpTest;
+import org.exist.test.ExistWebServer;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+
+import static java.net.HttpURLConnection.HTTP_OK;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Covers a header-leak shape the {@code CachingResponseWrapper} fixes in
+ * https://github.com/eXist-db/exist/pull/6691 could only ever close one enumerated header name at
+ * a time (Content-Length, then Content-Type, then Last-Modified/ETag/Accept-Ranges): a header an
+ * intermediate pipeline step sets programmatically via XQuery's {@code response:set-header()}
+ * -- not through {@code controller.xql}'s static {@code } config -- has no name a
+ * fixed allowlist could ever contain, yet describes that step's own (discarded, once a view runs)
+ * output just as much as its Content-Length does.
+ *
+ * Paired with {@link URLRewriteFinalStepResponseSetHeaderSurvivesTest}, which pins the opposite,
+ * equally-necessary case: a header the final, actually-flushed step sets the same way must
+ * still reach the client.
+ */
+public class URLRewriteResponseSetHeaderViewPipelineTest {
+
+ @ClassRule
+ public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false);
+
+ private static final String TEST_COLLECTION = "/db/apps/test-intermediate-set-header";
+
+ private static final String CONTROLLER_XQ = """
+ xquery version "3.1";
+ declare namespace exist = "http://exist.sourceforge.net/NS/exist";
+
+
+
+
+
+
+
+ """;
+
+ private static final String STEP1_XQL = """
+ xquery version "3.1";
+
+ (: Set programmatically, not via config -- this step's own header,
+ which must not survive once the view step below replaces its output. :)
+ response:set-header("X-Step1-Debug", "step1-was-here"),
+ Hello""";
+
+ private static final String VIEW_XQL = """
+ xquery version "3.1";
+ declare option exist:serialize "method=xhtml media-type=text/html indent=yes";
+
+ let $data := request:get-data()
+ return
+ View saw: { $data//step1/text() }
""";
+
+ @BeforeClass
+ public static void setup() throws Exception {
+ final String restUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest" + TEST_COLLECTION;
+
+ storeViaRest(restUrl + "/controller.xql", CONTROLLER_XQ, "application/xquery");
+ storeViaRest(restUrl + "/step1.xql", STEP1_XQL, "application/xquery");
+ storeViaRest(restUrl + "/view.xql", VIEW_XQL, "application/xquery");
+
+ final String chmod = "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/controller.xql'), 'rwxr-xr-x')," +
+ "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/step1.xql'), 'rwxr-xr-x')," +
+ "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/view.xql'), 'rwxr-xr-x')";
+ final String chmodUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest/db?_query=" +
+ URLEncoder.encode(chmod, StandardCharsets.UTF_8) + "&_wrap=no";
+ final HttpRequest chmodRequest = AbstractHttpTest.authenticatedRequest(URI.create(chmodUrl), "admin", "")
+ .GET()
+ .build();
+ AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), chmodRequest);
+ }
+
+ @AfterClass
+ public static void teardown() throws Exception {
+ final String deleteUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest" + TEST_COLLECTION;
+ final HttpRequest deleteRequest = AbstractHttpTest.authenticatedRequest(URI.create(deleteUrl), "admin", "")
+ .DELETE()
+ .build();
+ AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), deleteRequest);
+ }
+
+ @Test
+ public void intermediateStepHeaderDoesNotSurviveToFinalViewResponse() throws IOException, InterruptedException {
+ final String url = "http://localhost:" + existWebServer.getPort()
+ + "/exist/apps/test-intermediate-set-header/test";
+
+ final HttpClient client = AbstractHttpTest.newHttpClient();
+ final HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
+ final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+
+ assertEquals("Expected 200 OK but got " + response.statusCode() + ": "
+ + response.body().substring(0, Math.min(300, response.body().length())),
+ HTTP_OK, response.statusCode());
+
+ // Proves the request actually went through the full forward-then-view pipeline.
+ assertTrue("Response should contain the view's output",
+ response.body().contains("View saw: Hello"));
+
+ // The actual behavior under test: step1's own header must not leak past the view that
+ // replaced its output -- step1's response was never the one actually sent to the client.
+ assertTrue("X-Step1-Debug from the discarded intermediate step must not reach the client",
+ response.headers().firstValue("X-Step1-Debug").isEmpty());
+ }
+
+ private static void storeViaRest(final String url, final String content, final String contentType)
+ throws IOException {
+ final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(url), "admin", "")
+ .header("Content-Type", contentType + "; charset=UTF-8")
+ .PUT(HttpRequest.BodyPublishers.ofString(content, StandardCharsets.UTF_8))
+ .build();
+ AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), request);
+ }
+}