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); + } +}