From 8706ccc7f704e8232c3c0f90dbe131b4959bc2a6 Mon Sep 17 00:00:00 2001 From: Prasad Pawar Date: Wed, 26 Aug 2026 18:01:04 +0530 Subject: [PATCH] =?UTF-8?q?ATLAS-5389:=20Atlas=20React=20Dashboard=20?= =?UTF-8?q?=E2=80=94=20Align=20UI=20with=20strict=20browser=20resource=20p?= =?UTF-8?q?olicy=20(nonce-based=20script/style=20handling)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/index.html | 2 + dashboard/src/Main.tsx | 23 ++-- dashboard/src/__tests__/Main.test.tsx | 58 +++++++++ .../src/utils/__tests__/cspNonce.test.ts | 115 ++++++++++++++++++ .../src/utils/__tests__/emotionCache.test.ts | 61 ++++++++++ dashboard/src/utils/cspNonce.ts | 50 ++++++++ dashboard/src/utils/emotionCache.ts | 27 ++++ 7 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 dashboard/src/utils/__tests__/cspNonce.test.ts create mode 100644 dashboard/src/utils/__tests__/emotionCache.test.ts create mode 100644 dashboard/src/utils/cspNonce.ts create mode 100644 dashboard/src/utils/emotionCache.ts diff --git a/dashboard/index.html b/dashboard/index.html index 58a084be1cf..b02457627a0 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -21,6 +21,8 @@ + + Atlas diff --git a/dashboard/src/Main.tsx b/dashboard/src/Main.tsx index b01422a1716..9ff3fcef1ea 100644 --- a/dashboard/src/Main.tsx +++ b/dashboard/src/Main.tsx @@ -14,7 +14,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ThemeProvider, createTheme } from "@mui/material/styles"; +import { CacheProvider } from "@emotion/react"; +import { StyledEngineProvider, ThemeProvider, createTheme } from "@mui/material/styles"; import * as React from "react"; import { createRoot } from "react-dom/client"; import App from "./App.tsx"; @@ -30,8 +31,12 @@ import "react-quill-new/dist/quill.core.css"; import "../src/styles/table.scss"; import { Provider } from "react-redux"; import store from "./redux/store/store.ts"; +import { createEmotionCache } from "./utils/emotionCache.ts"; +import { getCspNonce } from "./utils/cspNonce.ts"; // import ErrorBoundary from "ErrorBoundary.ts"; +const emotionCache = createEmotionCache(getCspNonce()); + const theme = createTheme({ typography: { allVariants: { @@ -44,11 +49,15 @@ const theme = createTheme({ createRoot(document.getElementById("root")!).render( - - - - - - + + + + + + + + + + ); diff --git a/dashboard/src/__tests__/Main.test.tsx b/dashboard/src/__tests__/Main.test.tsx index ff17c368b05..386a98e9430 100644 --- a/dashboard/src/__tests__/Main.test.tsx +++ b/dashboard/src/__tests__/Main.test.tsx @@ -40,6 +40,31 @@ jest.mock('../App', () => ({ default: () =>
App Component
})) +jest.mock('@emotion/cache', () => ({ + __esModule: true, + default: jest.fn(() => ({ key: 'css' })) +})) + +jest.mock('@emotion/react', () => { + const actual = jest.requireActual('@emotion/react') + + return { + ...actual, + CacheProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) + } +}) + +const mockGetCspNonce = jest.fn() +const mockCreateEmotionCache = jest.fn(() => ({ key: 'css' })) +jest.mock('../utils/cspNonce.ts', () => ({ + getCspNonce: () => mockGetCspNonce() +})) +jest.mock('../utils/emotionCache.ts', () => ({ + createEmotionCache: (...args: unknown[]) => mockCreateEmotionCache(...args) +})) + // Mock all CSS imports jest.mock('../index.scss', () => {}) jest.mock('react-toastify/dist/ReactToastify.css', () => {}) @@ -92,6 +117,8 @@ describe('Main.tsx', () => { beforeEach(() => { jest.clearAllMocks() jest.resetModules() + mockGetCspNonce.mockReturnValue(undefined) + mockCreateEmotionCache.mockReturnValue({ key: 'css' }) mockStore = createMockStore() // Mock document.getElementById to return root element @@ -115,6 +142,37 @@ describe('Main.tsx', () => { jest.resetModules() }) + describe('CSP Nonce Wiring', () => { + it('should read csp nonce and create emotion cache without nonce when unavailable', async () => { + mockGetCspNonce.mockReturnValue(undefined) + + await import('../Main.tsx') + + expect(mockGetCspNonce).toHaveBeenCalled() + expect(mockCreateEmotionCache).toHaveBeenCalledWith(undefined) + }) + + it('should pass csp nonce to createEmotionCache when available', async () => { + mockGetCspNonce.mockReturnValue('server-generated-nonce') + + await import('../Main.tsx') + + expect(mockCreateEmotionCache).toHaveBeenCalledWith('server-generated-nonce') + }) + + it('should invoke createRoot.render after configuring emotion cache', async () => { + mockGetCspNonce.mockReturnValue('server-generated-nonce') + + await import('../Main.tsx') + + const ReactDOM = require('react-dom/client') + const mockRoot = ReactDOM.createRoot.mock.results[0].value + + expect(mockCreateEmotionCache).toHaveBeenCalled() + expect(mockRoot.render).toHaveBeenCalled() + }) + }) + describe('Module Execution', () => { it('should execute Main.tsx and call createRoot with root element', async () => { // Import Main.tsx to execute it diff --git a/dashboard/src/utils/__tests__/cspNonce.test.ts b/dashboard/src/utils/__tests__/cspNonce.test.ts new file mode 100644 index 00000000000..59a3b445a5e --- /dev/null +++ b/dashboard/src/utils/__tests__/cspNonce.test.ts @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CSP_NONCE_META_NAME, getCspNonce } from "../cspNonce"; + +describe("getCspNonce", () => { + const originalWindowNonce = window.__CSP_NONCE__; + + beforeEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + delete window.__CSP_NONCE__; + }); + + afterEach(() => { + if (originalWindowNonce === undefined) { + delete window.__CSP_NONCE__; + } else { + window.__CSP_NONCE__ = originalWindowNonce; + } + }); + + it("returns nonce from meta tag when present", () => { + const meta = document.createElement("meta"); + meta.setAttribute("name", CSP_NONCE_META_NAME); + meta.setAttribute("content", "meta-nonce-value"); + document.head.appendChild(meta); + + expect(getCspNonce()).toBe("meta-nonce-value"); + }); + + it("trims whitespace from meta tag nonce", () => { + const meta = document.createElement("meta"); + meta.setAttribute("name", CSP_NONCE_META_NAME); + meta.setAttribute("content", " trimmed-meta-nonce "); + document.head.appendChild(meta); + + expect(getCspNonce()).toBe("trimmed-meta-nonce"); + }); + + it("prefers meta tag nonce over script nonce", () => { + const meta = document.createElement("meta"); + meta.setAttribute("name", CSP_NONCE_META_NAME); + meta.setAttribute("content", "meta-nonce-value"); + document.head.appendChild(meta); + + const script = document.createElement("script"); + script.setAttribute("nonce", "script-nonce-value"); + document.body.appendChild(script); + + expect(getCspNonce()).toBe("meta-nonce-value"); + }); + + it("returns nonce from script tag when meta tag is missing", () => { + const script = document.createElement("script"); + script.setAttribute("nonce", "script-nonce-value"); + document.body.appendChild(script); + + expect(getCspNonce()).toBe("script-nonce-value"); + }); + + it("returns nonce from window global when dom sources are missing", () => { + window.__CSP_NONCE__ = "window-nonce-value"; + + expect(getCspNonce()).toBe("window-nonce-value"); + }); + + it("returns undefined when no nonce source is available", () => { + expect(getCspNonce()).toBeUndefined(); + }); + + it("returns undefined when meta content is blank", () => { + const meta = document.createElement("meta"); + meta.setAttribute("name", CSP_NONCE_META_NAME); + meta.setAttribute("content", " "); + document.head.appendChild(meta); + + expect(getCspNonce()).toBeUndefined(); + }); + + it("returns undefined when script nonce is blank and no other source exists", () => { + const script = document.createElement("script"); + script.setAttribute("nonce", " "); + document.body.appendChild(script); + + expect(getCspNonce()).toBeUndefined(); + }); + + it("falls back to script nonce when meta content is blank", () => { + const meta = document.createElement("meta"); + meta.setAttribute("name", CSP_NONCE_META_NAME); + meta.setAttribute("content", " "); + document.head.appendChild(meta); + + const script = document.createElement("script"); + script.setAttribute("nonce", "script-nonce-value"); + document.body.appendChild(script); + + expect(getCspNonce()).toBe("script-nonce-value"); + }); +}); diff --git a/dashboard/src/utils/__tests__/emotionCache.test.ts b/dashboard/src/utils/__tests__/emotionCache.test.ts new file mode 100644 index 00000000000..06e1df7660f --- /dev/null +++ b/dashboard/src/utils/__tests__/emotionCache.test.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock("@emotion/cache", () => ({ + __esModule: true, + default: jest.fn(() => ({ key: "css" })) +})); + +import createCache from "@emotion/cache"; +import { createEmotionCache } from "../emotionCache"; + +describe("createEmotionCache", () => { + const mockCreateCache = createCache as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateCache.mockReturnValue({ key: "css" }); + }); + + it("creates emotion cache without nonce when nonce is undefined", () => { + createEmotionCache(undefined); + + expect(mockCreateCache).toHaveBeenCalledWith({ key: "css" }); + }); + + it("creates emotion cache without nonce when nonce is blank", () => { + createEmotionCache(" "); + + expect(mockCreateCache).toHaveBeenCalledWith({ key: "css" }); + }); + + it("creates emotion cache with nonce when nonce is provided", () => { + createEmotionCache("server-generated-nonce"); + + expect(mockCreateCache).toHaveBeenCalledWith({ + key: "css", + nonce: "server-generated-nonce" + }); + }); + + it("returns the cache instance from @emotion/cache", () => { + const cacheInstance = { key: "css", nonce: "server-generated-nonce" }; + mockCreateCache.mockReturnValue(cacheInstance); + + expect(createEmotionCache("server-generated-nonce")).toBe(cacheInstance); + }); +}); diff --git a/dashboard/src/utils/cspNonce.ts b/dashboard/src/utils/cspNonce.ts new file mode 100644 index 00000000000..5a6e390c5ec --- /dev/null +++ b/dashboard/src/utils/cspNonce.ts @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const CSP_NONCE_META_NAME = "csp-nonce"; + +declare global { + interface Window { + __CSP_NONCE__?: string; + } +} + +const getTrimmedAttribute = (value: string | null | undefined): string | undefined => { + const trimmedValue = value?.trim(); + + return trimmedValue ? trimmedValue : undefined; +}; + +export const getCspNonce = (): string | undefined => { + const metaNonce = getTrimmedAttribute( + document.querySelector(`meta[name="${CSP_NONCE_META_NAME}"]`)?.getAttribute("content") + ); + + if (metaNonce) { + return metaNonce; + } + + const scriptNonce = getTrimmedAttribute( + document.querySelector("script[nonce]")?.getAttribute("nonce") + ); + + if (scriptNonce) { + return scriptNonce; + } + + return getTrimmedAttribute(window.__CSP_NONCE__); +}; diff --git a/dashboard/src/utils/emotionCache.ts b/dashboard/src/utils/emotionCache.ts new file mode 100644 index 00000000000..27d94b953bf --- /dev/null +++ b/dashboard/src/utils/emotionCache.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createCache, { type EmotionCache } from "@emotion/cache"; + +export const createEmotionCache = (cspNonce?: string): EmotionCache => { + const trimmedNonce = cspNonce?.trim(); + + return createCache({ + key: "css", + ...(trimmedNonce ? { nonce: trimmedNonce } : {}) + }); +};