diff options
19 files changed, 374 insertions, 44 deletions
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java index d29d21d105a..5c774e2d501 100644 --- a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java +++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java @@ -25,21 +25,21 @@ import java.net.URL; 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 @@ -48,15 +48,15 @@ public final class SamlAuthStatusPageGenerator { .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() { diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java index 20406d36db2..42e0697fe1c 100644 --- a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java +++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java @@ -210,10 +210,10 @@ public class SamlAuthenticator { 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()))); } } } diff --git a/server/sonar-auth-saml/src/main/resources/samlAuthResult.html b/server/sonar-auth-saml/src/main/resources/samlAuthResult.html index 85e987b33ca..a49503aa659 100644 --- a/server/sonar-auth-saml/src/main/resources/samlAuthResult.html +++ b/server/sonar-auth-saml/src/main/resources/samlAuthResult.html @@ -113,9 +113,12 @@ <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"; @@ -173,7 +176,8 @@ 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; @@ -206,6 +210,7 @@ addSection(container, "Attribute mappings", createTable(mappings)); } } + }); </script> </body> </html> diff --git a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java index 643129df0c7..440279c227b 100644 --- a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java +++ b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java @@ -25,6 +25,7 @@ import java.net.URL; 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; @@ -38,14 +39,16 @@ public class SamlAuthStatusPageGeneratorTest { @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); diff --git a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthenticatorTest.java b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthenticatorTest.java index fe2ce6a5aad..1c473879e3d 100644 --- a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthenticatorTest.java +++ b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthenticatorTest.java @@ -25,6 +25,7 @@ import org.junit.Test; import static org.junit.Assert.*; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class SamlAuthenticatorTest { @@ -33,6 +34,7 @@ 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); diff --git a/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html b/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html index de4879d8ce3..b592d671155 100644 --- a/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html +++ b/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html @@ -3,40 +3,40 @@ <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> @@ -113,9 +113,12 @@ <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"; @@ -173,7 +176,8 @@ 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; @@ -206,6 +210,7 @@ addSection(container, "Attribute mappings", createTable(mappings)); } } + }); </script> </body> </html> diff --git a/server/sonar-web/config/indexHtmlTemplate.js b/server/sonar-web/config/indexHtmlTemplate.js index 1080574f5b7..c047316b0f2 100644 --- a/server/sonar-web/config/indexHtmlTemplate.js +++ b/server/sonar-web/config/indexHtmlTemplate.js @@ -44,20 +44,13 @@ module.exports = (cssHash, jsHash) => ` </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> diff --git a/server/sonar-web/src/main/js/app/index.ts b/server/sonar-web/src/main/js/app/index.ts index d81a3bda393..81e528d133a 100644 --- a/server/sonar-web/src/main/js/app/index.ts +++ b/server/sonar-web/src/main/js/app/index.ts @@ -27,11 +27,12 @@ import { getGlobalNavigation } from '../api/navigation'; 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() { diff --git a/server/sonar-web/src/main/js/helpers/__tests__/system-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/system-test.ts new file mode 100644 index 00000000000..f945759eab9 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/__tests__/system-test.ts @@ -0,0 +1,60 @@ +/* + * 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(); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/system.ts b/server/sonar-web/src/main/js/helpers/system.ts index 390c12ddd77..ba7799b2339 100644 --- a/server/sonar-web/src/main/js/helpers/system.ts +++ b/server/sonar-web/src/main/js/helpers/system.ts @@ -17,6 +17,7 @@ * 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() { @@ -38,3 +39,17 @@ export function isOfficial() { 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); +} diff --git a/server/sonar-web/src/main/js/types/browser.ts b/server/sonar-web/src/main/js/types/browser.ts index 051730801fc..a2499f5405f 100644 --- a/server/sonar-web/src/main/js/types/browser.ts +++ b/server/sonar-web/src/main/js/types/browser.ts @@ -32,3 +32,12 @@ export interface EnhancedWindow extends Window { 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; + }; +} diff --git a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationCspHeaders.java b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationCspHeaders.java new file mode 100644 index 00000000000..2735f4b87ec --- /dev/null +++ b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationCspHeaders.java @@ -0,0 +1,65 @@ +/* + * 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)); + } + +} diff --git a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationRedirectionFilter.java b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationRedirectionFilter.java index 28d04120ad5..7298e30e3df 100644 --- a/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationRedirectionFilter.java +++ b/server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationRedirectionFilter.java @@ -37,6 +37,8 @@ import org.sonar.api.platform.Server; 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 { @@ -77,6 +79,7 @@ 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() + "/") @@ -86,10 +89,11 @@ public class SamlValidationRedirectionFilter extends ServletFilter { 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; } @@ -109,4 +113,5 @@ public class SamlValidationRedirectionFilter extends ServletFilter { } return ""; } + } diff --git a/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationCspHeadersTest.java b/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationCspHeadersTest.java new file mode 100644 index 00000000000..9765357cba9 --- /dev/null +++ b/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationCspHeadersTest.java @@ -0,0 +1,94 @@ +/* + * 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> + """; + } +} diff --git a/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationRedirectionFilterTest.java b/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationRedirectionFilterTest.java index 3c41f6eeef0..38e8ea8cefb 100644 --- a/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationRedirectionFilterTest.java +++ b/server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationRedirectionFilterTest.java @@ -52,7 +52,7 @@ public class SamlValidationRedirectionFilterTest { @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)); } @@ -74,16 +74,17 @@ public class SamlValidationRedirectionFilterTest { 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\""); } @@ -104,9 +105,9 @@ public class SamlValidationRedirectionFilterTest { 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>"); @@ -127,11 +128,12 @@ public class SamlValidationRedirectionFilterTest { 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 @@ -157,4 +159,11 @@ public class SamlValidationRedirectionFilterTest { 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'"); + } + } diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/ValidationAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/ValidationAction.java index 0fa254d6596..98238ab0b40 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/ValidationAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/ValidationAction.java @@ -41,6 +41,8 @@ import org.sonar.server.authentication.event.AuthenticationException; 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 { @@ -92,7 +94,10 @@ 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 diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/ValidationActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/ValidationActionTest.java index 58e32004ae2..405b35932b6 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/ValidationActionTest.java +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/ValidationActionTest.java @@ -38,6 +38,7 @@ import org.sonar.server.authentication.event.AuthenticationException; 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; @@ -45,6 +46,7 @@ import static org.mockito.Mockito.mock; 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 { @@ -85,11 +87,14 @@ 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 @@ -143,4 +148,58 @@ public class ValidationActionTest { 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> + """; + } } diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/CspFilter.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/CspFilter.java index 1899143e295..9f577ad2aa8 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/CspFilter.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/web/CspFilter.java @@ -47,7 +47,7 @@ public class CspFilter implements Filter { 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(); diff --git a/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/CspFilterTest.java b/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/CspFilterTest.java index f41e318062d..4e74feaa286 100644 --- a/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/CspFilterTest.java +++ b/server/sonar-webserver/src/test/java/org/sonar/server/platform/web/CspFilterTest.java @@ -41,7 +41,7 @@ public class CspFilterTest { "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); |