import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
import org.json.JSONObject;
public final class SamlAuthStatusPageGenerator {
private static final String WEB_CONTEXT = "%WEB_CONTEXT%";
private static final String SAML_AUTHENTICATION_STATUS = "%SAML_AUTHENTICATION_STATUS%";
-
private static final String HTML_TEMPLATE_NAME = "samlAuthResult.html";
private SamlAuthStatusPageGenerator() {
throw new IllegalStateException("This Utility class cannot be instantiated");
}
- public static String getSamlAuthStatusHtml(SamlAuthenticationStatus samlAuthenticationStatus) {
- Map<String, String> substitutionsMap = getSubstitutionsMap(samlAuthenticationStatus);
+ public static String getSamlAuthStatusHtml(HttpServletRequest request, SamlAuthenticationStatus samlAuthenticationStatus) {
+ Map<String, String> substitutionsMap = getSubstitutionsMap(request, samlAuthenticationStatus);
String htmlTemplate = getPlainTemplate();
return substitutionsMap
.reduce(htmlTemplate, (accumulator, pattern) -> accumulator.replace(pattern, substitutionsMap.get(pattern)));
}
- private static Map<String, String> getSubstitutionsMap(SamlAuthenticationStatus samlAuthenticationStatus) {
+ private static Map<String, String> getSubstitutionsMap(HttpServletRequest request, SamlAuthenticationStatus samlAuthenticationStatus) {
return Map.of(
- WEB_CONTEXT, "",
+ WEB_CONTEXT, request.getContextPath(),
SAML_AUTHENTICATION_STATUS, getBase64EncodedStatus(samlAuthenticationStatus));
}
private static String getBase64EncodedStatus(SamlAuthenticationStatus samlAuthenticationStatus) {
byte[] bytes = new JSONObject(samlAuthenticationStatus).toString().getBytes(StandardCharsets.UTF_8);
- return String.format("'%s'", Base64.getEncoder().encodeToString(bytes));
+ return String.format("%s", Base64.getEncoder().encodeToString(bytes));
}
private static String getPlainTemplate() {
public String getAuthenticationStatusPage(HttpServletRequest request, HttpServletResponse response) {
try {
- Auth auth = this.initSamlAuth(request, response);
- return getSamlAuthStatusHtml(getSamlAuthenticationStatus(auth, samlSettings));
+ Auth auth = initSamlAuth(request, response);
+ return getSamlAuthStatusHtml(request, getSamlAuthenticationStatus(auth, samlSettings));
} catch (IllegalStateException e) {
- return getSamlAuthStatusHtml(getSamlAuthenticationStatus(String.format("%s due to: %s", e.getMessage(), e.getCause().getMessage())));
+ return getSamlAuthStatusHtml(request, getSamlAuthenticationStatus(String.format("%s due to: %s", e.getMessage(), e.getCause().getMessage())));
}
}
}
<div class="box">
<div id="status"></div>
</div>
+ <div id="response" data-response="%SAML_AUTHENTICATION_STATUS%"></div>
</div>
<script>
+ window.addEventListener('DOMContentLoaded', (event) => {
+
function createBox() {
const box = document.createElement("div");
box.className = "box";
container.appendChild(box);
}
- const response = %SAML_AUTHENTICATION_STATUS%;
+ const variables = document.querySelector("#response");
+ const response = variables.dataset.response;
const decodedStatus = JSON.parse(atob(response));
const status = decodedStatus.status;
const attributes = decodedStatus.availableAttributes;
addSection(container, "Attribute mappings", createTable(mappings));
}
}
+ });
</script>
</body>
</html>
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
+import javax.servlet.http.HttpServletRequest;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
@Test
public void test_full_html_generation_with_empty_values() {
SamlAuthenticationStatus samlAuthenticationStatus = mock(SamlAuthenticationStatus.class);
+ HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);
when(samlAuthenticationStatus.getStatus()).thenReturn(null);
when(samlAuthenticationStatus.getErrors()).thenReturn(new ArrayList<>());
when(samlAuthenticationStatus.getWarnings()).thenReturn(new ArrayList<>());
when(samlAuthenticationStatus.getAvailableAttributes()).thenReturn(new HashMap<>());
when(samlAuthenticationStatus.getMappedAttributes()).thenReturn(new HashMap<>());
+ when(httpServletRequest.getContextPath()).thenReturn("context");
- String completeHtmlTemplate = getSamlAuthStatusHtml(samlAuthenticationStatus);
+ String completeHtmlTemplate = getSamlAuthStatusHtml(httpServletRequest, samlAuthenticationStatus);
String expectedTemplate = loadTemplateFromResources(EMPTY_HTML_TEMPLATE_NAME);
assertEquals(expectedTemplate, completeHtmlTemplate);
import static org.junit.Assert.*;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
public class SamlAuthenticatorTest {
SamlAuthenticator samlAuthenticator = new SamlAuthenticator(mock(SamlSettings.class), mock(SamlMessageIdChecker.class));
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
+ when(request.getContextPath()).thenReturn("context");
String authenticationStatus = samlAuthenticator.getAuthenticationStatusPage(request, response);
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
- <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
- <link rel="apple-touch-icon" sizes="57x57" href="/apple-touch-icon-57x57.png" />
- <link rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-60x60.png" />
- <link rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-72x72.png" />
- <link rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-76x76.png" />
+ <link rel="apple-touch-icon" href="context/apple-touch-icon.png" />
+ <link rel="apple-touch-icon" sizes="57x57" href="context/apple-touch-icon-57x57.png" />
+ <link rel="apple-touch-icon" sizes="60x60" href="context/apple-touch-icon-60x60.png" />
+ <link rel="apple-touch-icon" sizes="72x72" href="context/apple-touch-icon-72x72.png" />
+ <link rel="apple-touch-icon" sizes="76x76" href="context/apple-touch-icon-76x76.png" />
<link
rel="apple-touch-icon"
sizes="114x114"
- href="/apple-touch-icon-114x114.png"
+ href="context/apple-touch-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
- href="/apple-touch-icon-120x120.png"
+ href="context/apple-touch-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
- href="/apple-touch-icon-144x144.png"
+ href="context/apple-touch-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
- href="/apple-touch-icon-152x152.png"
+ href="context/apple-touch-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
- href="/apple-touch-icon-180x180.png"
+ href="context/apple-touch-icon-180x180.png"
/>
- <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+ <link rel="icon" type="image/x-icon" href="context/favicon.ico" />
<meta name="application-name" content="SonarQube" />
<meta name="msapplication-TileColor" content="#FFFFFF" />
- <meta name="msapplication-TileImage" content="/mstile-512x512.png" />
+ <meta name="msapplication-TileImage" content="context/mstile-512x512.png" />
<title>SAML Authentication Test</title>
<style>
<div class="box">
<div id="status"></div>
</div>
+ <div id="response" data-response="eyJ3YXJuaW5ncyI6W10sImF2YWlsYWJsZUF0dHJpYnV0ZXMiOnt9LCJlcnJvcnMiOltdLCJtYXBwZWRBdHRyaWJ1dGVzIjp7fX0="></div>
</div>
<script>
+ window.addEventListener('DOMContentLoaded', (event) => {
+
function createBox() {
const box = document.createElement("div");
box.className = "box";
container.appendChild(box);
}
- const response = 'eyJ3YXJuaW5ncyI6W10sImF2YWlsYWJsZUF0dHJpYnV0ZXMiOnt9LCJlcnJvcnMiOltdLCJtYXBwZWRBdHRyaWJ1dGVzIjp7fX0=';
+ const variables = document.querySelector("#response");
+ const response = variables.dataset.response;
const decodedStatus = JSON.parse(atob(response));
const status = decodedStatus.status;
const attributes = decodedStatus.availableAttributes;
addSection(container, "Attribute mappings", createTable(mappings));
}
}
+ });
</script>
</body>
</html>
</head>
<body>
- <div id="content">
+ <div id="content" data-base-url="%WEB_CONTEXT%" data-server-status="%SERVER_STATUS%" data-instance="%INSTANCE%" data-official="%OFFICIAL%">
<div class="global-loading">
<i class="spinner global-loading-spinner"></i>
<span aria-live="polite" class="global-loading-text">Loading...</span>
</div>
</div>
- <script>
- window.baseUrl = '%WEB_CONTEXT%';
- window.serverStatus = '%SERVER_STATUS%';
- window.instance = '%INSTANCE%';
- window.official = %OFFICIAL%;
- </script>
-
<script type="module" src="%WEB_CONTEXT%/js/out${jsHash}.js"></script>
</body>
import { getCurrentUser } from '../api/users';
import { installExtensionsHandler, installWebAnalyticsHandler } from '../helpers/extensionsHandler';
import { loadL10nBundle } from '../helpers/l10nBundle';
-import { getBaseUrl, getSystemStatus } from '../helpers/system';
+import { getBaseUrl, getSystemStatus, initAppVariables } from '../helpers/system';
import './styles/sonar.ts';
installWebAnalyticsHandler();
installExtensionsHandler();
+initAppVariables();
initApplication();
async function initApplication() {
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.
+ */
+import { AppVariablesElement } from '../../types/browser';
+import { InstanceType } from '../../types/system';
+import { initAppVariables } from '../system';
+
+// Faking window so we don't pollute real window set in /config/jest/SetupTestEnvironment
+const fakeWindow = {};
+jest.mock('../browser', () => ({
+ getEnhancedWindow: jest.fn(() => fakeWindow),
+}));
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('initAppVariables', () => {
+ it('should correctly init app variables', () => {
+ const dataset: AppVariablesElement['dataset'] = {
+ baseUrl: 'test/base-url',
+ serverStatus: 'DOWN',
+ instance: InstanceType.SonarQube,
+ official: 'false',
+ };
+
+ const appVariablesElement = document.querySelector('#content') as AppVariablesElement;
+ Object.assign(appVariablesElement.dataset, dataset);
+
+ initAppVariables();
+
+ expect(fakeWindow).toEqual({
+ ...dataset,
+ official: Boolean(dataset.official),
+ });
+ });
+
+ it('should throw error if app variables element is not found', () => {
+ const querySelector = jest.spyOn(document, 'querySelector');
+ querySelector.mockReturnValue(null);
+
+ expect(initAppVariables).toThrow();
+ });
+});
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { AppVariablesElement } from '../types/browser';
import { getEnhancedWindow } from './browser';
export function getBaseUrl() {
export function getReactDomContainerSelector() {
return '#content';
}
+
+export function initAppVariables() {
+ const appVariablesDiv = document.querySelector<AppVariablesElement>(
+ getReactDomContainerSelector()
+ );
+ if (appVariablesDiv === null) {
+ throw new Error('Failed to get app variables');
+ }
+
+ getEnhancedWindow().baseUrl = appVariablesDiv.dataset.baseUrl;
+ getEnhancedWindow().serverStatus = appVariablesDiv.dataset.serverStatus;
+ getEnhancedWindow().instance = appVariablesDiv.dataset.instance;
+ getEnhancedWindow().official = Boolean(appVariablesDiv.dataset.official);
+}
t: (...keys: string[]) => string;
tp: (messageKey: string, ...parameters: Array<string | number>) => string;
}
+
+export interface AppVariablesElement extends HTMLElement {
+ dataset: {
+ baseUrl: string;
+ serverStatus: SysStatus;
+ instance: InstanceType;
+ official: string;
+ };
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.authentication;
+
+import java.util.Base64;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.codec.digest.DigestUtils;
+
+public class SamlValidationCspHeaders {
+
+ private static final Pattern SCRIPT_PATTERN = Pattern.compile("(?<=<script>)(?s).*(?=</script>)");
+
+ private SamlValidationCspHeaders() {
+ throw new IllegalStateException("Utility class, cannot be instantiated");
+ }
+
+ public static void addCspHeadersToResponse(HttpServletResponse httpResponse, String hash) {
+ List<String> cspPolicies = List.of(
+ "default-src 'self'",
+ "base-uri 'none'",
+ "connect-src 'self' http: https:",
+ "img-src * data: blob:",
+ "object-src 'none'",
+ "script-src 'self' '" + hash + "'",
+ "style-src 'self' 'unsafe-inline'",
+ "worker-src 'none'");
+ String policies = String.join("; ", cspPolicies).trim();
+
+ List<String> cspHeaders = List.of("Content-Security-Policy", "X-Content-Security-Policy", "X-WebKit-CSP");
+ cspHeaders.forEach(header -> httpResponse.setHeader(header, policies));
+ }
+
+ public static String getHashForInlineScript(String html) {
+ Matcher matcher = SCRIPT_PATTERN.matcher(html);
+ if (matcher.find()) {
+ return getBase64Sha256(matcher.group(0));
+ }
+ return "";
+ }
+
+ private static String getBase64Sha256(String string) {
+ return "sha256-" + Base64.getEncoder().encodeToString(DigestUtils.sha256(string));
+ }
+
+}
import org.sonar.api.web.ServletFilter;
import static org.sonar.server.authentication.AuthenticationFilter.CALLBACK_PATH;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.addCspHeadersToResponse;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.getHashForInlineScript;
public class SamlValidationRedirectionFilter extends ServletFilter {
String relayState = request.getParameter(RELAY_STATE_PARAMETER);
if (isSamlValidation(relayState)) {
+
HttpServletResponse httpResponse = (HttpServletResponse) response;
URI redirectionEndpointUrl = URI.create(server.getContextPath() + "/")
String csrfToken = getCsrfTokenFromRelayState(relayState);
String template = StringUtils.replaceEachRepeatedly(redirectionPageTemplate,
- new String[]{"%VALIDATION_URL%", "%SAML_RESPONSE%", "%CSRF_TOKEN%"},
- new String[]{redirectionEndpointUrl.toString(), samlResponse, csrfToken});
+ new String[]{"%WEB_CONTEXT%", "%VALIDATION_URL%", "%SAML_RESPONSE%", "%CSRF_TOKEN%"},
+ new String[]{server.getContextPath(), redirectionEndpointUrl.toString(), samlResponse, csrfToken});
httpResponse.setContentType("text/html");
+ addCspHeadersToResponse(httpResponse, getHashForInlineScript(template));
httpResponse.getWriter().print(template);
return;
}
}
return "";
}
+
}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.authentication;
+
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.addCspHeadersToResponse;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.getHashForInlineScript;
+
+public class SamlValidationCspHeadersTest {
+
+ @Test
+ public void CspHeaders_are_correctly_added_to_response() {
+ HttpServletResponse httpServletResponse = mock(HttpServletResponse.class);
+
+ addCspHeadersToResponse(httpServletResponse, "hash");
+ verify(httpServletResponse).setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' 'hash'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ verify(httpServletResponse).setHeader("X-Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' 'hash'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ verify(httpServletResponse).setHeader("X-WebKit-CSP", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' 'hash'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ }
+
+ @Test
+ public void hash_is_properly_calculated_for_an_inline_script() {
+ String hash = getHashForInlineScript(getBasicHtmlWithScript());
+ assertEquals("sha256-jRoPhEx/vXxIUUkuTwJJ2ww4OPlo7B2ZK/wDVC4IXUs=", hash);
+ }
+
+ @Test
+ public void hash_is_empty_when_no_inline_script_available() {
+ String hash = getHashForInlineScript(getBasicHtmlWithoutScript());
+ assertEquals("", hash);
+ }
+
+ private String getBasicHtmlWithScript() {
+ return """
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <title>SAML Authentication Test</title>
+ </head>
+ <body>
+ <script>
+ function createBox() {
+ const box = document.createElement("div");
+ box.className = "box";
+ return box;
+ });
+ </script>
+ </body>
+ </html>
+ """;
+ }
+
+ private String getBasicHtmlWithoutScript() {
+ return """
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <title>SAML Authentication Test</title>
+ </head>
+ <body>
+ <div id="content">
+ <h1>SAML Authentication Test</h1>
+ <div class="box">
+ <div id="status"></div>
+ </div>
+ <div id="response" data-response="%SAML_AUTHENTICATION_STATUS%"></div>
+ </div>
+ </body>
+ </html>
+ """;
+ }
+}
@Before
public void setup() throws ServletException {
Server server = mock(Server.class);
- doReturn("").when(server).getContextPath();
+ doReturn("contextPath").when(server).getContextPath();
underTest = new SamlValidationRedirectionFilter(server);
underTest.init(mock(FilterConfig.class));
}
String validSample = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
when(servletRequest.getParameter(matches("SAMLResponse"))).thenReturn(validSample);
when(servletRequest.getParameter(matches("RelayState"))).thenReturn("validation-query/CSRF_TOKEN");
+ when(servletRequest.getContextPath()).thenReturn("contextPath");
PrintWriter pw = mock(PrintWriter.class);
when(servletResponse.getWriter()).thenReturn(pw);
underTest.doFilter(servletRequest, servletResponse, filterChain);
- verify(servletResponse).setContentType("text/html");
ArgumentCaptor<String> htmlProduced = ArgumentCaptor.forClass(String.class);
verify(pw).print(htmlProduced.capture());
+ verifyResponseContentTypeAndCSPHeaders(servletResponse, "sha256-TClpsoWi64Z74Xuk4Fa3bdt7mY/7K+A2jHOgNpxDy2I=");
assertThat(htmlProduced.getValue()).contains(validSample);
- assertThat(htmlProduced.getValue()).contains("action=\"/saml/validation\"");
+ assertThat(htmlProduced.getValue()).contains("action=\"contextPath/saml/validation\"");
assertThat(htmlProduced.getValue()).contains("value=\"CSRF_TOKEN\"");
}
underTest.doFilter(servletRequest, servletResponse, filterChain);
- verify(servletResponse).setContentType("text/html");
ArgumentCaptor<String> htmlProduced = ArgumentCaptor.forClass(String.class);
verify(pw).print(htmlProduced.capture());
+ verifyResponseContentTypeAndCSPHeaders(servletResponse, "sha256-TClpsoWi64Z74Xuk4Fa3bdt7mY/7K+A2jHOgNpxDy2I=");
assertThat(htmlProduced.getValue()).contains(validSample);
assertThat(htmlProduced.getValue()).doesNotContain("<script>/*Malicious Token*/</script>");
underTest.doFilter(servletRequest, servletResponse, filterChain);
- verify(servletResponse).setContentType("text/html");
ArgumentCaptor<String> htmlProduced = ArgumentCaptor.forClass(String.class);
+
verify(pw).print(htmlProduced.capture());
+ verifyResponseContentTypeAndCSPHeaders(servletResponse, "sha256-TClpsoWi64Z74Xuk4Fa3bdt7mY/7K+A2jHOgNpxDy2I=");
assertThat(htmlProduced.getValue()).doesNotContain("<script>/*hack website*/</script>");
- assertThat(htmlProduced.getValue()).contains("action=\"/saml/validation\"");
+ assertThat(htmlProduced.getValue()).contains("action=\"contextPath/saml/validation\"");
}
@Test
return new Object[]{"random_query", "validation-query", null};
}
+ private static void verifyResponseContentTypeAndCSPHeaders(HttpServletResponse servletResponse, String hash) {
+ verify(servletResponse).setContentType("text/html");
+ verify(servletResponse).setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' '" + hash + "'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ verify(servletResponse).setHeader("X-Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' '" + hash + "'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ verify(servletResponse).setHeader("X-WebKit-CSP", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' '" + hash + "'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ }
+
}
import org.sonar.server.user.ThreadLocalUserSession;
import org.sonar.server.ws.ServletFilterHandler;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.addCspHeadersToResponse;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.getHashForInlineScript;
import static org.sonar.server.saml.ws.SamlValidationWs.SAML_VALIDATION_CONTROLLER;
public class ValidationAction extends ServletFilter implements SamlAction {
};
httpResponse.setContentType("text/html");
- httpResponse.getWriter().print(samlAuthenticator.getAuthenticationStatusPage(httpRequest, httpResponse));
+
+ String htmlResponse = samlAuthenticator.getAuthenticationStatusPage(httpRequest, httpResponse);
+ addCspHeadersToResponse(httpResponse, getHashForInlineScript(htmlResponse));
+ httpResponse.getWriter().print(htmlResponse);
}
@Override
import org.sonar.server.user.ThreadLocalUserSession;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
+import static org.sonar.server.authentication.SamlValidationCspHeaders.getHashForInlineScript;
public class ValidationActionTest {
doReturn(true).when(userSession).hasSession();
doReturn(true).when(userSession).isSystemAdministrator();
+ doReturn(getBasicHtmlWithScript()).when(samlAuthenticator).getAuthenticationStatusPage(any(), any());
underTest.doFilter(servletRequest, servletResponse, filterChain);
verify(samlAuthenticator).getAuthenticationStatusPage(any(), any());
verify(servletResponse).getWriter();
+ verifyResponseTypeAndCSPHeaders(servletResponse, getHashForInlineScript(getBasicHtmlWithScript()));
+ assertEquals(stringWriter.toString(), getBasicHtmlWithScript());
}
@Test
assertThat(validationInitAction.description()).isNotEmpty();
assertThat(validationInitAction.handler()).isNotNull();
}
+
+ private static void verifyResponseTypeAndCSPHeaders(HttpServletResponse servletResponse, String hash) {
+ verify(servletResponse).setContentType("text/html");
+ verify(servletResponse).setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' '" + hash + "'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ verify(servletResponse).setHeader("X-Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' '" + hash + "'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ verify(servletResponse).setHeader("X-WebKit-CSP", "default-src 'self'; base-uri 'none'; connect-src 'self' http: https:; img-src * data: blob:; object-src 'none'; script-src 'self' '" + hash + "'; style-src 'self' 'unsafe-inline'; worker-src 'none'");
+ }
+
+ private String getBasicHtmlWithScript() {
+ return """
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8" charset="UTF-8" />
+ <link rel="icon" type="image/x-icon" href="%WEB_CONTEXT%/favicon.ico" />
+ <meta name="application-name" content="SonarQube" />
+ <meta name="msapplication-TileColor" content="#FFFFFF" />
+ <meta name="msapplication-TileImage" content="%WEB_CONTEXT%/mstile-512x512.png" />
+ <title>SAML Authentication Test</title>
+
+ <style>
+ .error {
+ background-color: #d02f3a;
+ }
+
+ .success {
+ background-color: #008a25;
+ }
+ </style>
+ </head>
+
+ <body>
+ <div id="content">
+ <h1>SAML Authentication Test</h1>
+ <div class="box">
+ <div id="status"></div>
+ </div>
+ <div id="response" data-response="%SAML_AUTHENTICATION_STATUS%"></div>
+ </div>
+
+ <script>
+ window.addEventListener('DOMContentLoaded', (event) => {
+
+ function createBox() {
+ const box = document.createElement("div");
+ box.className = "box";
+ return box;
+ }
+ });
+ </script>
+ </body>
+ </html>
+ """;
+ }
}
cspPolicies.add("connect-src 'self' http: https:");
cspPolicies.add("img-src * data: blob:");
cspPolicies.add("object-src 'none'");
- cspPolicies.add("script-src 'self' 'unsafe-inline' 'unsafe-eval'");
+ cspPolicies.add("script-src 'self'");
cspPolicies.add("style-src 'self' 'unsafe-inline'");
cspPolicies.add("worker-src 'none'");
this.policies = String.join("; ", cspPolicies).trim();
"connect-src 'self' http: https:; " +
"img-src * data: blob:; " +
"object-src 'none'; " +
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
+ "script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"worker-src 'none'";
private final ServletContext servletContext = mock(ServletContext.class, RETURNS_MOCKS);