From d8e84cfbfd8dac8d76ff9ef6d3d9165efcd82de7 Mon Sep 17 00:00:00 2001 From: Nayden Naydenov <31909318+nnaydenow@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:03:25 +0300 Subject: [PATCH 1/3] fix(framework): improve themeRoot validation (#13354) Fix themeRoot validation to require explicit origin allowlist via meta tag and separate configuration storage from validated URL usage. Problem: - themeRoot URLs were not properly validated for security - getThemeRoot() mixed raw configuration with validated URLs - Missing validation for relative paths and URL formats Solution: 1. Require meta tag for all themeRoot usage: - Comma-separated list of allowed origins - Wildcard "*" to allow any origin - Legacy "sap-allowedThemeOrigins" (camelCase) supported - Same-origin URLs allowed when meta tag present 2. Separate configuration from validation: - getThemeRoot() returns raw configured value (unchanged) - validateThemeRoot() performs security checks and normalization - DOM link creation uses validated URL: {validatedRoot}/UI5/Base/baseLib/{theme}/css_variables.css 3. Enhanced validation: - Absolute URLs: check origin against allowlist - Relative paths (./path, ../path): resolve to current origin - Absolute paths (/path): resolve to current origin - Add trailing slash if missing - Append /UI5/ to create proper theme asset path - Return undefined for invalid/unauthorized URLs 4. Proper error handling: - Log warning when validation fails - No DOM link created for invalid themeRoot - Graceful fallback to default theme behavior --- packages/base/src/InitialConfiguration.ts | 3 +- packages/base/src/config/ThemeRoot.ts | 24 +++++++---- packages/base/src/validateThemeRoot.ts | 49 ++++++++++++++++------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/base/src/InitialConfiguration.ts b/packages/base/src/InitialConfiguration.ts index 8720d194157a..f88d0dcae0a0 100644 --- a/packages/base/src/InitialConfiguration.ts +++ b/packages/base/src/InitialConfiguration.ts @@ -1,7 +1,6 @@ import merge from "./thirdparty/merge.js"; import { getFeature } from "./FeaturesRegistry.js"; import { DEFAULT_THEME } from "./generated/AssetParameters.js"; -import validateThemeRoot from "./validateThemeRoot.js"; import type OpenUI5Support from "./features/OpenUI5Support.js"; import type { FormatSettings } from "./config/FormatSettings.js"; import AnimationMode from "./types/AnimationMode.js"; @@ -156,7 +155,7 @@ const parseURLParameters = () => { const normalizeThemeRootParamValue = (value: string) => { const themeRoot = value.split("@")[1]; - return validateThemeRoot(themeRoot); + return themeRoot; }; const normalizeThemeParamValue = (param: string, value: string) => { diff --git a/packages/base/src/config/ThemeRoot.ts b/packages/base/src/config/ThemeRoot.ts index e11ad44af03e..9b121efd3832 100644 --- a/packages/base/src/config/ThemeRoot.ts +++ b/packages/base/src/config/ThemeRoot.ts @@ -43,16 +43,11 @@ const setThemeRoot = (themeRoot: string): Promise | undefined => { currThemeRoot = themeRoot; - if (!validateThemeRoot(themeRoot)) { - console.warn(`The ${themeRoot} is not valid. Check the allowed origins as suggested in the "setThemeRoot" description.`); // eslint-disable-line - return; - } - return attachCustomThemeStylesToHead(getTheme()); }; -const formatThemeLink = (theme: string) => { - return `${getThemeRoot()!}Base/baseLib/${theme}/css_variables.css`; // theme root is always set at this point. +const formatThemeLink = (theme: string, validatedThemeRoot: string) => { + return `${validatedThemeRoot}Base/baseLib/${theme}/css_variables.css`; }; const attachCustomThemeStylesToHead = async (theme: string): Promise => { @@ -62,7 +57,20 @@ const attachCustomThemeStylesToHead = async (theme: string): Promise => { document.head.removeChild(link); } - await createLinkInHead(formatThemeLink(theme), { "sap-ui-webcomponents-theme": theme }); + const themeRoot = getThemeRoot(); + + if (!themeRoot) { + return; + } + + const validatedThemeRoot = validateThemeRoot(themeRoot); + + if (!validatedThemeRoot) { + console.warn(`The ${themeRoot} is not valid. Check the allowed origins as suggested in the "setThemeRoot" description.`); // eslint-disable-line + return; + } + + await createLinkInHead(formatThemeLink(theme, validatedThemeRoot), { "sap-ui-webcomponents-theme": theme }); }; export { diff --git a/packages/base/src/validateThemeRoot.ts b/packages/base/src/validateThemeRoot.ts index 6e8a7592e16c..6f1f1a588c6d 100644 --- a/packages/base/src/validateThemeRoot.ts +++ b/packages/base/src/validateThemeRoot.ts @@ -1,3 +1,13 @@ +const isSSR = typeof document === "undefined"; + +const getLocationHref = () => { + if (isSSR) { + return ""; + } + + return window.location.href; +}; + const getMetaTagValue = (metaTagName: string) => { const metaTag = document.querySelector(`META[name="${metaTagName}"]`), metaTagContent = metaTag && metaTag.getAttribute("content"); @@ -5,22 +15,28 @@ const getMetaTagValue = (metaTagName: string) => { return metaTagContent; }; -const validateThemeOrigin = (origin: string) => { - const allowedOrigins = getMetaTagValue("sap-allowedThemeOrigins"); +const validateThemeOrigin = (origin: string, isSameOrigin: boolean = false) => { + const allowedOrigins = getMetaTagValue("sap-allowed-theme-origins") ?? getMetaTagValue("sap-allowedThemeOrigins"); // Prioritize the new meta tag name - return allowedOrigins && allowedOrigins.split(",").some(allowedOrigin => { - return allowedOrigin === "*" || origin === allowedOrigin.trim(); - }); -}; + // If no allowed origins are specified, block. + if (!allowedOrigins) { + return false; + } -const buildCorrectUrl = (oldUrl: string, newOrigin: string) => { - const oldUrlPath = new URL(oldUrl).pathname; + // If it's same-origin (relative URL resolved to current page), allow it when there's any meta tag present + // The presence of the meta tag indicates the user wants to use theme roots + if (isSameOrigin) { + return true; + } - return new URL(oldUrlPath, newOrigin).toString(); + return allowedOrigins.split(",").some(allowedOrigin => { + return allowedOrigin === "*" || origin === allowedOrigin.trim(); + }); }; const validateThemeRoot = (themeRoot: string) => { let resultUrl; + let isSameOrigin = false; try { if (themeRoot.startsWith(".") || themeRoot.startsWith("/")) { @@ -28,18 +44,22 @@ const validateThemeRoot = (themeRoot: string) => { // new URL("/newExmPath", "http://example.com/exmPath") => http://example.com/newExmPath // new URL("./newExmPath", "http://example.com/exmPath") => http://example.com/exmPath/newExmPath // new URL("../newExmPath", "http://example.com/exmPath") => http://example.com/newExmPath - resultUrl = new URL(themeRoot, window.location.href).toString(); + resultUrl = new URL(themeRoot, getLocationHref()).toString(); + isSameOrigin = true; } else { const themeRootURL = new URL(themeRoot); const origin = themeRootURL.origin; + const currentOrigin = new URL(getLocationHref()).origin; + + // Check if the absolute URL is same-origin + isSameOrigin = origin === currentOrigin; - if (origin && validateThemeOrigin(origin)) { + if (origin && validateThemeOrigin(origin, isSameOrigin)) { // If origin is allowed, use it resultUrl = themeRootURL.toString(); } else { - // If origin is not allow and the URL is not relative, we have to replace the origin - // with current location - resultUrl = buildCorrectUrl(themeRootURL.toString(), window.location.href); + // If origin is not allowed, return undefined to indicate validation failed + return undefined; } } @@ -50,6 +70,7 @@ const validateThemeRoot = (themeRoot: string) => { return `${resultUrl}UI5/`; } catch (e) { // Catch if URL is not correct + return undefined; } }; From 92b40614bcee555fdd0134368b4b9f0d1095eeaa Mon Sep 17 00:00:00 2001 From: Nayden Naydenov Date: Thu, 16 Apr 2026 17:32:18 +0300 Subject: [PATCH 2/3] chore: align test --- .../base/test/specs/ConfigurationURL.spec.js | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/base/test/specs/ConfigurationURL.spec.js b/packages/base/test/specs/ConfigurationURL.spec.js index 367048adaa60..baca5bbc5c46 100644 --- a/packages/base/test/specs/ConfigurationURL.spec.js +++ b/packages/base/test/specs/ConfigurationURL.spec.js @@ -35,7 +35,17 @@ describe("Some settings can be set via SAP UI URL params", () => { const config = window['sap-ui-webcomponents-bundle'].configuration; done(config.getThemeRoot()); }); - assert.strictEqual(res, 'https://example.com/UI5/', "Theme root is https://example.com/UI5"); + assert.strictEqual(res, 'https://example.com', "Theme root is https://example.com"); + + // The origin https://example.com is allowed via sap-allowedThemeOrigins meta tag, + // so the link should be added to the DOM. + let linkHref = await browser.executeAsync(done => { + const link = document.querySelector(`head > link[sap-ui-webcomponents-theme="sap_belize_hcb"]`); + done(link ? link.href : null); + }); + assert.ok(linkHref, "A theme link is added to the DOM for an allowed origin"); + assert.include(linkHref, "https://example.com", "Theme link href contains the allowed theme root"); + assert.include(linkHref, "sap_belize_hcb/css_variables.css", "Theme link href points to the theme CSS variables file"); await browser.url("test/pages/Configuration.html?sap-ui-theme=sap_belize_hcb@https://another-example.com"); @@ -47,7 +57,15 @@ describe("Some settings can be set via SAP UI URL params", () => { done(window.location); }); - assert.strictEqual(res, `${location.origin}/UI5/`, `Theme root is ${location.origin}/UI5/`); + assert.strictEqual(res, `${location.origin}`, `Theme root is ${location.origin}`); + + // The origin https://another-example.com is not in allowed origins and is cross-origin, + // so validation fails and no link should be added to the DOM. + linkHref = await browser.executeAsync(done => { + const link = document.querySelector(`head > link[sap-ui-webcomponents-theme="sap_belize_hcb"]`); + done(link ? link.href : null); + }); + assert.isNull(linkHref, "No theme link is added to the DOM for a disallowed cross-origin theme root"); await browser.url("test/pages/Configuration.html?sap-ui-theme=sap_belize_hcb@./test"); @@ -56,7 +74,15 @@ describe("Some settings can be set via SAP UI URL params", () => { done(config.getThemeRoot()); }); - assert.ok(res.endsWith("/test/UI5/"), `Theme root is set correctly with relative url`); + assert.strictEqual(res, "./test", `Theme root is ./test`); + + // A relative URL is treated as same-origin, so the link should be added to the DOM. + linkHref = await browser.executeAsync(done => { + const link = document.querySelector(`head > link[sap-ui-webcomponents-theme="sap_belize_hcb"]`); + done(link ? link.href : null); + }); + assert.ok(linkHref, "A theme link is added to the DOM for a relative theme root"); + assert.include(linkHref, "sap_belize_hcb/css_variables.css", "Theme link href points to the theme CSS variables file"); }); it("Tests that animationMode is applied", async () => { From 2b763ddab19d9685d45344b1f74b8bddbae2b1d7 Mon Sep 17 00:00:00 2001 From: Nayden Naydenov Date: Thu, 16 Apr 2026 17:41:54 +0300 Subject: [PATCH 3/3] chore: align test --- packages/base/test/specs/ConfigurationURL.spec.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/base/test/specs/ConfigurationURL.spec.js b/packages/base/test/specs/ConfigurationURL.spec.js index baca5bbc5c46..c6a1a12dba27 100644 --- a/packages/base/test/specs/ConfigurationURL.spec.js +++ b/packages/base/test/specs/ConfigurationURL.spec.js @@ -53,11 +53,8 @@ describe("Some settings can be set via SAP UI URL params", () => { const config = window['sap-ui-webcomponents-bundle'].configuration; done(config.getThemeRoot()); }); - location = await browser.executeAsync(done => { - done(window.location); - }); - assert.strictEqual(res, `${location.origin}`, `Theme root is ${location.origin}`); + assert.strictEqual(res, `https://another-example.com`, `Theme root is https://another-example.com`); // The origin https://another-example.com is not in allowed origins and is cross-origin, // so validation fails and no link should be added to the DOM.