Skip to content

Commit c765630

Browse files
authored
fix: validate correctly protocol relative urls (#13447)
1 parent bf7153c commit c765630

6 files changed

Lines changed: 565 additions & 2 deletions

File tree

packages/base/bundle.esm.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import applyDirection from "./dist/locale/applyDirection.js";
3131
import { getCurrentRuntimeIndex } from "./dist/Runtimes.js";
3232
import { startMultipleDrag } from "./dist/DragAndDrop.js";
3333
import LegacyDateFormats from "./dist/features/LegacyDateFormats.js";
34+
import { boot } from "./dist/Boot.js";
3435

3536
window["sap-ui-webcomponents-bundle"] = {
3637
configuration : {
@@ -54,6 +55,7 @@ window["sap-ui-webcomponents-bundle"] = {
5455
parseProperties,
5556
registerI18nLoader,
5657
getI18nBundle,
58+
boot,
5759
renderFinished,
5860
applyDirection,
5961
EventProvider,
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { internals } from "../../src/Location.js";
2+
import TestGeneric from "../../test/test-elements/Generic.js";
3+
import { resetConfiguration } from "../../src/InitialConfiguration.js";
4+
import { getTheme } from "../../src/config/Theme.js";
5+
import { getThemeRoot } from "../../src/config/ThemeRoot.js";
6+
import applyTheme from "../../src/theming/applyTheme.js";
7+
8+
const THEME = "sap_horizon";
9+
10+
const addMetaTag = (content: string) => {
11+
cy.window().then($win => {
12+
const metaTag = $win.document.createElement("meta");
13+
metaTag.name = "sap-allowed-theme-origins";
14+
metaTag.content = content;
15+
$win.document.head.append(metaTag);
16+
});
17+
};
18+
19+
const removeMetaTag = () => {
20+
cy.window().then($win => {
21+
$win.document.head.querySelector("[name='sap-allowed-theme-origins']")?.remove();
22+
});
23+
};
24+
25+
const removeThemeLink = () => {
26+
cy.window().then($win => {
27+
$win.document.head.querySelector("link[sap-ui-webcomponents-theme]")?.remove();
28+
});
29+
};
30+
31+
const applyAndCheckLink = (theme: string) => {
32+
cy.wrap({ applyTheme, getTheme })
33+
.invoke("getTheme")
34+
.then(() => {
35+
return cy.wrap({ applyTheme }).invoke("applyTheme", theme);
36+
});
37+
};
38+
39+
// ─── Without meta tag ────────────────────────────────────────────────────────
40+
41+
describe("ThemeRoot via URL param — without meta tag", () => {
42+
afterEach(() => {
43+
removeThemeLink();
44+
});
45+
46+
[
47+
{ label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/", blocked: true },
48+
{ label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/", blocked: true },
49+
{ label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/", blocked: true },
50+
{ label: "absolute URL (same host, no meta tag)", themeRoot: "http://example.com/themes/", blocked: true },
51+
{ label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/", blocked: true },
52+
{ label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/", blocked: true },
53+
{ label: "protocol-relative (same host, no meta tag)", themeRoot: "//example.com/themes/", blocked: true },
54+
{ label: "root-relative", themeRoot: "/themes/", blocked: false },
55+
{ label: "relative (current dir)", themeRoot: "./themes/", blocked: false },
56+
{ label: "relative (parent dir)", themeRoot: "../themes/", blocked: false },
57+
].forEach(({ label, themeRoot, blocked }) => {
58+
describe(`${label}: ${themeRoot}`, () => {
59+
before(() => {
60+
cy.stub(internals, "search").callsFake(() => {
61+
return `sap-ui-theme=${THEME}@${themeRoot}`;
62+
});
63+
64+
cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
65+
cy.mount(<TestGeneric />);
66+
});
67+
68+
it(blocked ? "should not create a link element" : "should create a link element (same-origin, no meta tag needed)", () => {
69+
applyAndCheckLink(THEME);
70+
if (blocked) {
71+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
72+
} else {
73+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
74+
.should("exist")
75+
.and("have.attr", "href")
76+
.and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
77+
}
78+
});
79+
});
80+
});
81+
});
82+
83+
// ─── With meta tag ────────────────────────────────────────────────────────────
84+
85+
describe("ThemeRoot via URL param — with meta tag (allowed: http://example.com)", () => {
86+
afterEach(() => {
87+
removeThemeLink();
88+
});
89+
90+
after(() => {
91+
removeMetaTag();
92+
});
93+
94+
before(() => {
95+
addMetaTag("http://example.com");
96+
});
97+
98+
const blocked = [
99+
{ label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/" },
100+
{ label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/" },
101+
{ label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/" },
102+
{ label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/" },
103+
{ label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/" },
104+
];
105+
106+
const allowed = [
107+
{
108+
label: "absolute URL (matches allowed origin)",
109+
themeRoot: "http://example.com/themes/",
110+
expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
111+
},
112+
{
113+
label: "protocol-relative (resolves to allowed origin)",
114+
themeRoot: "//example.com/themes/",
115+
expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
116+
},
117+
];
118+
119+
const sameOriginAllowed = [
120+
{ label: "root-relative", themeRoot: "/themes/" },
121+
{ label: "relative (current dir)", themeRoot: "./themes/" },
122+
{ label: "relative (parent dir)", themeRoot: "../themes/" },
123+
];
124+
125+
blocked.forEach(({ label, themeRoot }) => {
126+
describe(`blocked — ${label}: ${themeRoot}`, () => {
127+
before(() => {
128+
cy.stub(internals, "search").callsFake(() => {
129+
return `sap-ui-theme=${THEME}@${themeRoot}`;
130+
});
131+
cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
132+
cy.mount(<TestGeneric />);
133+
});
134+
135+
it("should not create a link element", () => {
136+
applyAndCheckLink(THEME);
137+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
138+
});
139+
});
140+
});
141+
142+
allowed.forEach(({ label, themeRoot, expectedHref }) => {
143+
describe(`allowed — ${label}: ${themeRoot}`, () => {
144+
before(() => {
145+
cy.stub(internals, "search").callsFake(() => {
146+
return `sap-ui-theme=${THEME}@${themeRoot}`;
147+
});
148+
cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
149+
cy.mount(<TestGeneric />);
150+
});
151+
152+
it("should create a link element with correct href", () => {
153+
applyAndCheckLink(THEME);
154+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
155+
.should("exist")
156+
.and("have.attr", "href", expectedHref);
157+
});
158+
});
159+
});
160+
161+
sameOriginAllowed.forEach(({ label, themeRoot }) => {
162+
describe(`allowed (same-origin) — ${label}: ${themeRoot}`, () => {
163+
before(() => {
164+
cy.stub(internals, "search").callsFake(() => {
165+
return `sap-ui-theme=${THEME}@${themeRoot}`;
166+
});
167+
cy.wrap({ resetConfiguration }).invoke("resetConfiguration", true);
168+
cy.mount(<TestGeneric />);
169+
});
170+
171+
it("should create a link element", () => {
172+
applyAndCheckLink(THEME);
173+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
174+
.should("exist")
175+
.and("have.attr", "href")
176+
.and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
177+
});
178+
});
179+
});
180+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import TestGeneric from "../../test/test-elements/Generic.js";
2+
import { setThemeRoot } from "../../src/config/ThemeRoot.js";
3+
import { getTheme } from "../../src/config/Theme.js";
4+
import applyTheme from "../../src/theming/applyTheme.js";
5+
6+
const THEME = "sap_horizon";
7+
8+
const addMetaTag = (content: string) => {
9+
cy.window().then($win => {
10+
const metaTag = $win.document.createElement("meta");
11+
metaTag.name = "sap-allowed-theme-origins";
12+
metaTag.content = content;
13+
$win.document.head.append(metaTag);
14+
});
15+
};
16+
17+
const removeMetaTag = () => {
18+
cy.window().then($win => {
19+
$win.document.head.querySelector("[name='sap-allowed-theme-origins']")?.remove();
20+
});
21+
};
22+
23+
const removeThemeLink = () => {
24+
cy.window().then($win => {
25+
$win.document.head.querySelector("link[sap-ui-webcomponents-theme]")?.remove();
26+
});
27+
};
28+
29+
const applyAndCheckLink = (theme: string) => {
30+
cy.wrap({ applyTheme, getTheme })
31+
.invoke("getTheme")
32+
.then(() => {
33+
return cy.wrap({ applyTheme }).invoke("applyTheme", theme);
34+
});
35+
};
36+
37+
// ─── Without meta tag ────────────────────────────────────────────────────────
38+
39+
describe("ThemeRoot via setThemeRoot API — without meta tag", () => {
40+
before(() => {
41+
cy.mount(<TestGeneric />);
42+
});
43+
44+
afterEach(() => {
45+
removeThemeLink();
46+
cy.wrap({ setThemeRoot }).invoke("setThemeRoot", undefined);
47+
});
48+
49+
[
50+
{ label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/", blocked: true },
51+
{ label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/", blocked: true },
52+
{ label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/", blocked: true },
53+
{ label: "absolute URL (same host, no meta tag)", themeRoot: "http://example.com/themes/", blocked: true },
54+
{ label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/", blocked: true },
55+
{ label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/", blocked: true },
56+
{ label: "protocol-relative (same host, no meta tag)", themeRoot: "//example.com/themes/", blocked: true },
57+
{ label: "root-relative", themeRoot: "/themes/", blocked: false },
58+
{ label: "relative (current dir)", themeRoot: "./themes/", blocked: false },
59+
{ label: "relative (parent dir)", themeRoot: "../themes/", blocked: false },
60+
].forEach(({ label, themeRoot, blocked }) => {
61+
it(`${blocked ? "should not create" : "should create"} a link element — ${label}: ${themeRoot}`, () => {
62+
cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
63+
applyAndCheckLink(THEME);
64+
if (blocked) {
65+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
66+
} else {
67+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
68+
.should("exist")
69+
.and("have.attr", "href")
70+
.and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
71+
}
72+
});
73+
});
74+
});
75+
76+
// ─── With meta tag ────────────────────────────────────────────────────────────
77+
78+
describe("ThemeRoot via setThemeRoot API — with meta tag (allowed: http://example.com)", () => {
79+
before(() => {
80+
addMetaTag("http://example.com");
81+
cy.mount(<TestGeneric />);
82+
});
83+
84+
afterEach(() => {
85+
removeThemeLink();
86+
cy.wrap({ setThemeRoot }).invoke("setThemeRoot", undefined);
87+
});
88+
89+
after(() => {
90+
removeMetaTag();
91+
});
92+
93+
const blocked = [
94+
{ label: "absolute URL (different origin)", themeRoot: "http://example2.com/themes/" },
95+
{ label: "absolute URL (different protocol)", themeRoot: "https://example.com/themes/" },
96+
{ label: "absolute URL (different port)", themeRoot: "http://example:9090.com/themes/" },
97+
{ label: "protocol-relative (different origin)", themeRoot: "//example2.com/themes/" },
98+
{ label: "protocol-relative (different port)", themeRoot: "//example:9090.com/themes/" },
99+
];
100+
101+
const allowed = [
102+
{
103+
label: "absolute URL (matches allowed origin)",
104+
themeRoot: "http://example.com/themes/",
105+
expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
106+
},
107+
{
108+
label: "protocol-relative (resolves to allowed origin)",
109+
themeRoot: "//example.com/themes/",
110+
expectedHref: "http://example.com/themes/UI5/Base/baseLib/sap_horizon/css_variables.css",
111+
},
112+
];
113+
114+
const sameOriginAllowed = [
115+
{ label: "root-relative", themeRoot: "/themes/" },
116+
{ label: "relative (current dir)", themeRoot: "./themes/" },
117+
{ label: "relative (parent dir)", themeRoot: "../themes/" },
118+
];
119+
120+
blocked.forEach(({ label, themeRoot }) => {
121+
it(`should not create a link element — blocked: ${label}: ${themeRoot}`, () => {
122+
cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
123+
applyAndCheckLink(THEME);
124+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`).should("not.exist");
125+
});
126+
});
127+
128+
allowed.forEach(({ label, themeRoot, expectedHref }) => {
129+
it(`should create a link element with correct href — allowed: ${label}: ${themeRoot}`, () => {
130+
cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
131+
applyAndCheckLink(THEME);
132+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
133+
.should("exist")
134+
.and("have.attr", "href", expectedHref);
135+
});
136+
});
137+
138+
sameOriginAllowed.forEach(({ label, themeRoot }) => {
139+
it(`should create a link element — same-origin: ${label}: ${themeRoot}`, () => {
140+
cy.wrap({ setThemeRoot }).invoke("setThemeRoot", themeRoot);
141+
applyAndCheckLink(THEME);
142+
cy.get(`link[sap-ui-webcomponents-theme='${THEME}']`)
143+
.should("exist")
144+
.and("have.attr", "href")
145+
.and("include", `UI5/Base/baseLib/${THEME}/css_variables.css`);
146+
});
147+
});
148+
});

packages/base/src/validateThemeRoot.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,16 @@ const validateThemeRoot = (themeRoot: string) => {
3131
let isSameOrigin = false;
3232

3333
try {
34-
if (themeRoot.startsWith(".") || themeRoot.startsWith("/")) {
34+
if (themeRoot.startsWith(".") || (themeRoot.startsWith("/") && !themeRoot.startsWith("//"))) {
3535
// Handle relative url
3636
// new URL("/newExmPath", "http://example.com/exmPath") => http://example.com/newExmPath
3737
// new URL("./newExmPath", "http://example.com/exmPath") => http://example.com/exmPath/newExmPath
3838
// new URL("../newExmPath", "http://example.com/exmPath") => http://example.com/newExmPath
3939
resultUrl = new URL(themeRoot, getLocationHref()).toString();
4040
isSameOrigin = true;
4141
} else {
42-
const themeRootURL = new URL(themeRoot);
42+
// Protocol-relative URLs (//host/path) need a base to resolve the protocol
43+
const themeRootURL = themeRoot.startsWith("//") ? new URL(themeRoot, getLocationHref()) : new URL(themeRoot);
4344
const origin = themeRootURL.origin;
4445
const currentOrigin = new URL(getLocationHref()).origin;
4546

0 commit comments

Comments
 (0)