]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-18809 fix SSF-358
authorMatteo Mara <matteo.mara@sonarsource.com>
Thu, 16 Mar 2023 09:44:39 +0000 (10:44 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 23 Mar 2023 20:02:57 +0000 (20:02 +0000)
Co-authored-by: Ambroise C <ambroise.christea@sonarsource.com>
19 files changed:
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java
server/sonar-auth-saml/src/main/resources/samlAuthResult.html
server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java
server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthenticatorTest.java
server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html
server/sonar-web/config/indexHtmlTemplate.js
server/sonar-web/src/main/js/app/index.ts
server/sonar-web/src/main/js/helpers/__tests__/system-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/system.ts
server/sonar-web/src/main/js/types/browser.ts
server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationCspHeaders.java [new file with mode: 0644]
server/sonar-webserver-auth/src/main/java/org/sonar/server/authentication/SamlValidationRedirectionFilter.java
server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationCspHeadersTest.java [new file with mode: 0644]
server/sonar-webserver-auth/src/test/java/org/sonar/server/authentication/SamlValidationRedirectionFilterTest.java
server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/ValidationAction.java
server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/ValidationActionTest.java
server/sonar-webserver/src/main/java/org/sonar/server/platform/web/CspFilter.java
server/sonar-webserver/src/test/java/org/sonar/server/platform/web/CspFilterTest.java

index d29d21d105ace753e51ebb832fc25ff0229c9b59..5c774e2d501dc639ef4eead3ca3ce75d335f8aad 100644 (file)
@@ -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() {
index 20406d36db2ddae105ee3797ba812a85707dbc34..42e0697fe1c865fc8ac6447cea0d9f9470e8a822 100644 (file)
@@ -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())));
     }
   }
 }
index 85e987b33caac6a9347603c485e52f037359bb97..a49503aa659f35ce3e4aa2d6a10b259b27cce1a7 100644 (file)
       <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>
index 643129df0c7ffaec98d8d8818ac144ded18f7590..440279c227b1a91b5db988120272db1a2377bdef 100644 (file)
@@ -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);
index fe2ce6a5aad22261b3da1f7bb57e5f714721e88b..1c473879e3d10c5f2bfb7d7341313a8597fcc492 100644 (file)
@@ -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);
 
index de4879d8ce34f2a2a2c949255c6f3f826320b5b5..b592d6711553d56ef98b16fdb87620d38807f2e1 100644 (file)
@@ -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>
       <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>
index 1080574f5b7f38100812fc24ccd2568e05402467..c047316b0f2019ba7139f7be0c87752984a0a2f2 100644 (file)
@@ -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>
 
index d81a3bda393854cf68a4f2ac8cfbd9478f59eba6..81e528d133ab2852a23e9b41dceda23b38d79bd9 100644 (file)
@@ -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 (file)
index 0000000..f945759
--- /dev/null
@@ -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();
+  });
+});
index 390c12ddd77d1ba7df247a2bb58e07c452570f86..ba7799b2339d6eca7e4d2a9030909749852bb7f0 100644 (file)
@@ -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);
+}
index 051730801fc42cd3e357fbac022ff73ad1b71ead..a2499f5405f0680bbad06dec1192f6f38477c5e7 100644 (file)
@@ -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 (file)
index 0000000..2735f4b
--- /dev/null
@@ -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));
+  }
+
+}
index 28d04120ad58cc0b194da0572041a4995be2327d..7298e30e3df550f3ff283cc44840873ddb2334b2 100644 (file)
@@ -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 (file)
index 0000000..9765357
--- /dev/null
@@ -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>
+      """;
+  }
+}
index 3c41f6eeef0df7ceb059c2167e2954f4a5994fed..38e8ea8cefb0379bf7ee1ab50c2370a08de4300a 100644 (file)
@@ -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'");
+  }
+
 }
index 0fa254d6596889e2f0b6d63e1b6c988395bc7b09..98238ab0b40a48f454587d408d64e391b9d3b2e8 100644 (file)
@@ -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
index 58e32004ae2311e2d78d31c4bf0782de318d5216..405b35932b6096ab7af6cbdd55a623bea02ceaca 100644 (file)
@@ -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>
+      """;
+  }
 }
index 1899143e29567e9b27a35c006240d9a0e632fa91..9f577ad2aa8f9531d2a4446364d2d80831b894c3 100644 (file)
@@ -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();
index f41e318062ddeb1d62dbebb89ece620bc8eb4f23..4e74feaa286a0ef6ffd4155aad10eb30e281e7d9 100644 (file)
@@ -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);