--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program 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 3 of the License, or (at your option) any later version.
+ *
+ * This program 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 program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.web;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.util.Map;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import static java.lang.String.format;
+
+/**
+ * This servlet filter sets response headers that enable cache control on some static resources
+ */
+public class CacheControlFilter implements Filter {
+
+ private static final String CACHE_CONTROL_HEADER = "Cache-Control";
+
+ /**
+ * Recommended max value for max-age is 1 year
+ * @see <a href="https://stackoverflow.com/questions/7071763/max-value-for-cache-control-header-in-http">stackoverflow thread</a>
+ */
+ private static final int ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
+
+ private static final int FIVE_MINUTES_IN_SECONDS = 5 * 60 * 60;
+
+ private static final String MAX_AGE_TEMPLATE = "max-age=%d";
+
+ private static final Map<String, Integer> MAX_AGE_BY_PATH = ImmutableMap.of(
+ // These folders contains files that are suffixed with their content hash : the cache should never be invalidated
+ "/js/", ONE_YEAR_IN_SECONDS,
+ "/css/", ONE_YEAR_IN_SECONDS,
+ // This folder contains static resources from plugins : the cache should be set to a small value
+ "/static/", FIVE_MINUTES_IN_SECONDS,
+ "/images/", FIVE_MINUTES_IN_SECONDS);
+
+ @Override
+ public void init(FilterConfig filterConfig) {
+ // nothing
+ }
+
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
+ String path = ((HttpServletRequest) req).getRequestURI().replaceFirst(((HttpServletRequest) req).getContextPath(), "");
+
+ MAX_AGE_BY_PATH.entrySet().stream()
+ .filter(m -> path.startsWith(m.getKey()))
+ .map(Map.Entry::getValue)
+ .findFirst()
+ .ifPresent(maxAge -> ((HttpServletResponse) resp).addHeader(CACHE_CONTROL_HEADER, format(MAX_AGE_TEMPLATE, maxAge)));
+
+ chain.doFilter(req, resp);
+ }
+
+ @Override
+ public void destroy() {
+ // nothing
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program 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 3 of the License, or (at your option) any later version.
+ *
+ * This program 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 program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.platform.web;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+import static java.lang.String.format;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+public class CacheControlFilterTest {
+
+ private HttpServletResponse response = mock(HttpServletResponse.class);
+ private FilterChain chain = mock(FilterChain.class);
+
+ private CacheControlFilter underTest = new CacheControlFilter();
+
+ @Test
+ public void max_age_is_set_to_one_year_on_js() throws Exception {
+ HttpServletRequest request = newRequest("/js/sonar.js");
+
+ underTest.doFilter(request, response, chain);
+
+ verify(response).addHeader("Cache-Control", format("max-age=%s", 31_536_000));
+ }
+
+ @Test
+ public void max_age_is_set_to_one_year_on_css() throws Exception {
+ HttpServletRequest request = newRequest("/css/sonar.css");
+
+ underTest.doFilter(request, response, chain);
+
+ verify(response).addHeader("Cache-Control", format("max-age=%s", 31_536_000));
+ }
+
+ @Test
+ public void max_age_is_set_to_five_minutes_on_images() throws Exception {
+ HttpServletRequest request = newRequest("/images/logo.png");
+
+ underTest.doFilter(request, response, chain);
+
+ verify(response).addHeader("Cache-Control", format("max-age=%s", 18_000));
+ }
+
+ @Test
+ public void max_age_is_set_to_five_minutes_on_static() throws Exception {
+ HttpServletRequest request = newRequest("/static/something");
+
+ underTest.doFilter(request, response, chain);
+
+ verify(response).addHeader("Cache-Control", format("max-age=%s", 18_000));
+ }
+
+ @Test
+ public void max_age_is_set_to_five_minutes_on_css_of_static() throws Exception {
+ HttpServletRequest request = newRequest("/static/css/custom.css");
+
+ underTest.doFilter(request, response, chain);
+
+ verify(response).addHeader("Cache-Control", format("max-age=%s", 18_000));
+ }
+
+ @Test
+ public void does_nothing_on_home() throws Exception {
+ HttpServletRequest request = newRequest("/");
+
+ underTest.doFilter(request, response, chain);
+
+ verifyZeroInteractions(response);
+ }
+
+ @Test
+ public void does_nothing_on_web_service() throws Exception {
+ HttpServletRequest request = newRequest("/api/ping");
+
+ underTest.doFilter(request, response, chain);
+
+ verifyZeroInteractions(response);
+ }
+
+ private static HttpServletRequest newRequest(String path) {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getMethod()).thenReturn("GET");
+ when(req.getRequestURI()).thenReturn(path);
+ when(req.getContextPath()).thenReturn("");
+ return req;
+ }
+
+}
import java.io.IOException;
import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Test
public void set_security_headers() throws Exception {
- underTest.init(mock(FilterConfig.class));
HttpServletRequest request = newRequest("GET", "/");
underTest.doFilter(request, response, chain);
verify(response).addHeader("X-Frame-Options", "SAMEORIGIN");
verify(response).addHeader("X-XSS-Protection", "1; mode=block");
verify(response).addHeader("X-Content-Type-Options", "nosniff");
-
- underTest.destroy();
}
@Test
public void do_not_set_frame_protection_on_integration_resources() throws Exception {
- underTest.init(mock(FilterConfig.class));
HttpServletRequest request = newRequest("GET", "/integration/vsts/index.html");
underTest.doFilter(request, response, chain);
verify(response, never()).addHeader(eq("X-Frame-Options"), anyString());
verify(response).addHeader("X-XSS-Protection", "1; mode=block");
verify(response).addHeader("X-Content-Type-Options", "nosniff");
-
- underTest.destroy();
}
@Test
public void do_not_set_frame_protection_on_integration_resources_with_context() throws Exception {
- underTest.init(mock(FilterConfig.class));
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getMethod()).thenReturn("GET");
when(request.getRequestURI()).thenReturn("/sonarqube/integration/vsts/index.html");
verify(response, never()).addHeader(eq("X-Frame-Options"), anyString());
verify(response).addHeader("X-XSS-Protection", "1; mode=block");
verify(response).addHeader("X-Content-Type-Options", "nosniff");
-
- underTest.destroy();
}
private static HttpServletRequest newRequest(String httpMethod, String path) {
<filter-name>WebPagesFilter</filter-name>
<filter-class>org.sonar.server.platform.web.WebPagesFilter</filter-class>
</filter>
+ <filter>
+ <filter-name>CacheControlFilter</filter-name>
+ <filter-class>org.sonar.server.platform.web.CacheControlFilter</filter-class>
+ </filter>
<!-- order of execution is important -->
<filter-mapping>
<filter-name>SecurityFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
+ <filter-mapping>
+ <filter-name>CacheControlFilter</filter-name>
+ <url-pattern>/*</url-pattern>
+ </filter-mapping>
<filter-mapping>
<filter-name>UserSessionFilter</filter-name>
<url-pattern>/*</url-pattern>
public class HttpHeadersTest {
+ private static final int ONE_YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
+ private static final int FIVE_MINUTES_IN_SECONDS = 5 * 60 * 60;
+
@ClassRule
public static final Orchestrator orchestrator = Category4Suite.ORCHESTRATOR;
// SONAR-6964
assertNoCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(-1);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "application/json");
assertNoCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(-1);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "image/svg+xml");
assertCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "text/css");
assertCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(ONE_YEAR_IN_SECONDS);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "application/javascript");
+ assertCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(ONE_YEAR_IN_SECONDS);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "image/jpeg");
+ assertCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "application/javascript");
+ assertCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
}
@Test
verifySecurityHeaders(response);
verifyContentType(response, "text/html");
+ assertCacheInBrowser(response);
+
+ assertThat(response.cacheControl().maxAgeSeconds()).isEqualTo(FIVE_MINUTES_IN_SECONDS);
}
private static void assertCacheInBrowser(Response httpResponse) {