This is an automated email from the ASF dual-hosted git repository. markt pushed a commit to branch 9.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/9.0.x by this push: new 71ed0ddd38 Fix BZ 69439 - Improve handling of multiple Cache-Control headers 71ed0ddd38 is described below commit 71ed0ddd384cff61a5029bc2fbe96d06636ddbb6 Author: Mark Thomas <ma...@apache.org> AuthorDate: Wed Nov 20 19:17:10 2024 +0000 Fix BZ 69439 - Improve handling of multiple Cache-Control headers Based on #777 by Chenjp --- .../org/apache/catalina/filters/ExpiresFilter.java | 20 ++-- .../apache/catalina/filters/TestExpiresFilter.java | 127 +++++++++++++++++---- webapps/docs/changelog.xml | 5 + 3 files changed, 122 insertions(+), 30 deletions(-) diff --git a/java/org/apache/catalina/filters/ExpiresFilter.java b/java/org/apache/catalina/filters/ExpiresFilter.java index cd7b084575..ef54a3ad73 100644 --- a/java/org/apache/catalina/filters/ExpiresFilter.java +++ b/java/org/apache/catalina/filters/ExpiresFilter.java @@ -165,10 +165,10 @@ import org.apache.tomcat.util.buf.StringUtils; * <h4> * {@code ExpiresExcludedResponseStatusCodes}</h4> * <p> - * This directive defines the http response status codes for which the + * This directive defines the HTTP response status codes for which the * {@code ExpiresFilter} will not generate expiration headers. By default, the * {@code 304} status code ("{@code Not modified}") is skipped. The - * value is a comma separated list of http status codes. + * value is a comma separated list of HTTP status codes. * </p> * <p> * This directive is useful to ease usage of {@code ExpiresDefault} directive. @@ -332,7 +332,7 @@ import org.apache.tomcat.util.buf.StringUtils; * </p> * <p> * To trap the 'before write response body' event, the - * {@code ExpiresFilter} wraps the http servlet response's writer and + * {@code ExpiresFilter} wraps the HTTP servlet response's writer and * outputStream to intercept calls to the methods {@code write()}, * {@code print()}, {@code close()} and {@code flush()}. For empty response * body (e.g. empty files), the {@code write()}, {@code print()}, @@ -517,7 +517,7 @@ public class ExpiresFilter extends FilterBase { /** * <p> - * Wrapping extension of the {@link HttpServletResponse} to yrap the "Start Write Response Body" event. + * Wrapping extension of the {@link HttpServletResponse} to wrap the "Start Write Response Body" event. * </p> * <p> * For performance optimization : this extended response holds the {@link #lastModifiedHeader} and @@ -528,12 +528,12 @@ public class ExpiresFilter extends FilterBase { public class XHttpServletResponse extends HttpServletResponseWrapper { /** - * Value of the {@code Cache-Control} http response header if it has been set. + * Value of the {@code Cache-Control} HTTP response header if it has been set. */ private String cacheControlHeader; /** - * Value of the {@code Last-Modified} http response header if it has been set. + * Value of the {@code Last-Modified} HTTP response header if it has been set. */ private long lastModifiedHeader; @@ -568,8 +568,12 @@ public class ExpiresFilter extends FilterBase { @Override public void addHeader(String name, String value) { super.addHeader(name, value); - if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name) && cacheControlHeader == null) { - cacheControlHeader = value; + if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name)) { + if (cacheControlHeader == null) { + cacheControlHeader = value; + } else { + cacheControlHeader = StringUtils.join(cacheControlHeader, value); + } } } diff --git a/test/org/apache/catalina/filters/TestExpiresFilter.java b/test/org/apache/catalina/filters/TestExpiresFilter.java index a3778af9b2..1d5fa60811 100644 --- a/test/org/apache/catalina/filters/TestExpiresFilter.java +++ b/test/org/apache/catalina/filters/TestExpiresFilter.java @@ -42,6 +42,7 @@ import org.apache.catalina.filters.ExpiresFilter.StartingPoint; import org.apache.catalina.startup.Tomcat; import org.apache.catalina.startup.TomcatBaseTest; import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.buf.StringUtils; import org.apache.tomcat.util.collections.CaseInsensitiveKeyMap; import org.apache.tomcat.util.descriptor.web.FilterDef; import org.apache.tomcat.util.descriptor.web.FilterMap; @@ -92,8 +93,8 @@ public class TestExpiresFilter extends TomcatBaseTest { Assert.assertEquals(1, expiresConfigurationDefault.getDurations().get(0).getAmount()); // VERIFY TEXT/HTML - ExpiresConfiguration expiresConfigurationTextHtml = expiresFilter.getExpiresConfigurationByContentType() - .get("text/html"); + ExpiresConfiguration expiresConfigurationTextHtml = + expiresFilter.getExpiresConfigurationByContentType().get("text/html"); Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfigurationTextHtml.getStartingPoint()); Assert.assertEquals(3, expiresConfigurationTextHtml.getDurations().size()); @@ -111,8 +112,8 @@ public class TestExpiresFilter extends TomcatBaseTest { Assert.assertEquals(2, twoHours.getAmount()); // VERIFY IMAGE/GIF - ExpiresConfiguration expiresConfigurationImageGif = expiresFilter.getExpiresConfigurationByContentType() - .get("image/gif"); + ExpiresConfiguration expiresConfigurationImageGif = + expiresFilter.getExpiresConfigurationByContentType().get("image/gif"); Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfigurationImageGif.getStartingPoint()); Assert.assertEquals(2, expiresConfigurationImageGif.getDurations().size()); @@ -126,8 +127,8 @@ public class TestExpiresFilter extends TomcatBaseTest { Assert.assertEquals(3, threeMinutes.getAmount()); // VERIFY IMAGE/JPG - ExpiresConfiguration expiresConfigurationImageJpg = expiresFilter.getExpiresConfigurationByContentType() - .get("image/jpg"); + ExpiresConfiguration expiresConfigurationImageJpg = + expiresFilter.getExpiresConfigurationByContentType().get("image/jpg"); Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfigurationImageJpg.getStartingPoint()); Assert.assertEquals(1, expiresConfigurationImageJpg.getDurations().size()); @@ -137,8 +138,8 @@ public class TestExpiresFilter extends TomcatBaseTest { Assert.assertEquals(10000, tenThousandSeconds.getAmount()); // VERIFY VIDEO/MPEG - ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType() - .get("video/mpeg"); + ExpiresConfiguration expiresConfiguration = + expiresFilter.getExpiresConfigurationByContentType().get("video/mpeg"); Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint()); Assert.assertEquals(1, expiresConfiguration.getDurations().size()); @@ -173,8 +174,8 @@ public class TestExpiresFilter extends TomcatBaseTest { @Test public void testParseExpiresConfigurationCombinedDuration() { ExpiresFilter expiresFilter = new ExpiresFilter(); - ExpiresConfiguration actualConfiguration = expiresFilter - .parseExpiresConfiguration("access plus 1 month 15 days 2 hours"); + ExpiresConfiguration actualConfiguration = + expiresFilter.parseExpiresConfiguration("access plus 1 month 15 days 2 hours"); Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint()); @@ -226,7 +227,7 @@ public class TestExpiresFilter extends TomcatBaseTest { } }; - validate(servlet, null, HttpServletResponse.SC_NOT_MODIFIED); + validate(servlet, null, null, HttpServletResponse.SC_NOT_MODIFIED); } @Test @@ -362,11 +363,11 @@ public class TestExpiresFilter extends TomcatBaseTest { } protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds) throws Exception { - validate(servlet, expectedMaxAgeInSeconds, HttpServletResponse.SC_OK); + validate(servlet, expectedMaxAgeInSeconds, null, HttpServletResponse.SC_OK); } - protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds, int expectedResponseStatusCode) - throws Exception { + protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds, Boolean expectedExpires, + int expectedResponseStatusCode) throws Exception { // SETUP @@ -401,14 +402,14 @@ public class TestExpiresFilter extends TomcatBaseTest { // TEST ByteChunk bc = new ByteChunk(); - Map<String, List<String>> responseHeaders = new HashMap<>(); + Map<String,List<String>> responseHeaders = new HashMap<>(); int rc = getUrl("http://localhost:" + getPort() + "/test", bc, responseHeaders); // VALIDATE Assert.assertEquals(expectedResponseStatusCode, rc); StringBuilder msg = new StringBuilder(); - for (Entry<String, List<String>> field : responseHeaders.entrySet()) { + for (Entry<String,List<String>> field : responseHeaders.entrySet()) { for (String value : field.getValue()) { msg.append((field.getKey() == null ? "" : field.getKey() + ": ") + value + "\n"); } @@ -417,7 +418,7 @@ public class TestExpiresFilter extends TomcatBaseTest { Integer actualMaxAgeInSeconds; - String cacheControlHeader = getSingleHeader("Cache-Control", responseHeaders); + String cacheControlHeader = StringUtils.join(responseHeaders.get("Cache-Control")); if (cacheControlHeader == null) { actualMaxAgeInSeconds = null; @@ -438,13 +439,30 @@ public class TestExpiresFilter extends TomcatBaseTest { } if (expectedMaxAgeInSeconds == null) { - Assert.assertNull("actualMaxAgeInSeconds '" + actualMaxAgeInSeconds + "' should be null", - actualMaxAgeInSeconds); + Assert.assertTrue( + "actualMaxAgeInSeconds [" + actualMaxAgeInSeconds + + "] should be null or 'no-store' should be set", + actualMaxAgeInSeconds == null || + (cacheControlHeader != null && cacheControlHeader.contains("no-store"))); return; } Assert.assertNotNull(actualMaxAgeInSeconds); + if (expectedExpires != null) { + if (expectedExpires.booleanValue()) { + // Expires is expected and should be greater than date + Assert.assertNotNull(responseHeaders.get("Expires")); + String expiresString = responseHeaders.get("Expires").get(0); + long expiresDate = FastHttpDateFormat.parseDate(expiresString); + Assert.assertTrue( + "Expires header value [" + expiresString + "] does not specify a date in the future", + expiresDate > System.currentTimeMillis()); + } else { + Assert.assertNull(responseHeaders.get("Expires")); + } + } + String contentType = getSingleHeader("Content-Type", responseHeaders); int deltaInSeconds = Math.abs(actualMaxAgeInSeconds.intValue() - expectedMaxAgeInSeconds.intValue()); @@ -495,11 +513,11 @@ public class TestExpiresFilter extends TomcatBaseTest { tomcat.start(); ByteChunk bc = new ByteChunk(); - Map<String, List<String>> requestHeaders = new CaseInsensitiveKeyMap<>(); + Map<String,List<String>> requestHeaders = new CaseInsensitiveKeyMap<>(); List<String> ifModifiedSinceValues = new ArrayList<>(); ifModifiedSinceValues.add(FastHttpDateFormat.getCurrentDate()); requestHeaders.put("If-Modified-Since", ifModifiedSinceValues); - Map<String, List<String>> responseHeaders = new CaseInsensitiveKeyMap<>(); + Map<String,List<String>> responseHeaders = new CaseInsensitiveKeyMap<>(); int rc = getUrl("http://localhost:" + getPort() + "/test/bug6nnnn/bug69303.txt", bc, requestHeaders, responseHeaders); @@ -507,7 +525,7 @@ public class TestExpiresFilter extends TomcatBaseTest { Assert.assertEquals(HttpServletResponse.SC_NOT_MODIFIED, rc); StringBuilder msg = new StringBuilder(); - for (Entry<String, List<String>> field : responseHeaders.entrySet()) { + for (Entry<String,List<String>> field : responseHeaders.entrySet()) { for (String value : field.getValue()) { msg.append((field.getKey() == null ? "" : field.getKey() + ": ") + value + "\n"); } @@ -539,4 +557,69 @@ public class TestExpiresFilter extends TomcatBaseTest { Assert.assertNotNull(actualMaxAgeInSeconds); Assert.assertTrue(Math.abs(actualMaxAgeInSeconds.intValue() - 420) < 3); } + + + @Test + public void testBug69439CacheControlDirectiveConflict() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.setContentType("image/jpeg"); + response.addHeader("Cache-Control", "public"); + response.addHeader("Cache-Control", "no-store"); + response.addHeader("Cache-Control", "max-age=300"); + response.getWriter().print("Hello world"); + } + }; + // rfc9111 - 4.2.1 - If directives conflict (e.g., both max-age and no-cache are present), the most restrictive + // directive should be honored. + // final effective Cache-Control: no-store + // skip ExpiresFilter + validate(servlet, null, Boolean.FALSE, HttpServletResponse.SC_OK); + } + + + @Test + public void testBug69439CacheControlMaxAgePredefined() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.setContentType("image/jpeg"); + response.addHeader("Cache-Control", "public"); + response.addHeader("Cache-Control", "max-age=600"); + response.getWriter().print("Hello world"); + } + }; + // rfc9111 - 4.2.1 - If directives conflict (e.g., both max-age and no-cache are present), the most restrictive + // directive should be honored. + // final effective Cache-Control: public, max-age=600 + // skip ExpiresFilter + validate(servlet, Integer.valueOf(600), Boolean.FALSE, HttpServletResponse.SC_OK); + } + + + @Test + public void testBug69439CacheControlUndefined() throws Exception { + HttpServlet servlet = new HttpServlet() { + private static final long serialVersionUID = 1L; + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.setContentType("image/jpeg"); + response.getWriter().print("Hello world"); + } + }; + // rfc9111 - 4.2.1 - If directives conflict (e.g., both max-age and no-cache are present), the most restrictive + // directive should be honored. + // final effective Cache-Control: public, max-age=600 + // apply ExpiresFilter + validate(servlet, Integer.valueOf(60), Boolean.TRUE, HttpServletResponse.SC_OK); + } } diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 407315bce8..34a5894ca4 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -150,6 +150,11 @@ <fix> Use client locale for directory listings. (remm) </fix> + <fix> + <bug>69439</bug>: Improve the handling of multiple + <code>Cache-Control</code> headers in the <code>ExpiresFilter</code>. + Based on pull request <pr>777</pr> by Chenjp. (markt) + </fix> </changelog> </subsection> <subsection name="Coyote"> --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org