Skip to content

Commit 4b51ba7

Browse files
authored
security: check if context path is safe (#2449)
This pull-request adds a `isSafeContextPath` method to sanitise the `contextPath`. > When the application exposes a Swagger UI page, say at “https://localhost:8081/swagger-ui?contextPath=“, it appears that the contextPath parameter is vulnerable. To run potentially malicious Javascript code, an attacker could set the contextPath parameter to the location of a malicious script. For example, here is a test payload used for evaluating whether a website is vulnerable to reflected XSS attacks (it simply triggers a Javascript alert()): > https://localhost:8081/swagger-ui?contextPath=https://x55.is/brutelogic/brute.svg > When this URL is evaluated by my browser, the value of the contextPath parameter is not fully sanitised. So the alert(1) script present at https://x55.is/brutelogic/brute.svg is evaluated and the example payload is executed. > An attacker could exploit this vulnerability by finding domains on the internet that are serving Swagger UI pages using micronaut-openapi. They could then craft a link that looks safe, but is actually vulnerable, by packing a malicious script into the contextPath parameter and sending it to an unsuspecting user. Something like “https://mysafewebsite.com/swagger-ui?contextPath=bad_and_unsafe_script.js”
1 parent 281abeb commit 4b51ba7

File tree

5 files changed

+175
-5
lines changed

5 files changed

+175
-5
lines changed

openapi/src/main/resources/templates/openapi-explorer/index.html

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@
9696
return decodeURIComponent(v.replace(/(?:^|.*;\s*)contextPath\s*=\s*([^;]*).*$|^.*$/, "$1"));
9797
}
9898
const cookie = extract(document.cookie)
99-
const contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie
99+
contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
100+
if (!isSafeContextPath(contextPath)) {
101+
contextPath = '';
102+
}
100103
const openApiExplorer = document.getElementById('openapi-explorer');
101104
const head = document.getElementsByTagName('head')[0]
102105

@@ -162,6 +165,37 @@
162165
head.appendChild(el);
163166
return el;
164167
}
168+
function isSafeContextPath(cp) {
169+
if (typeof cp !== 'string') return false;
170+
cp = cp.trim();
171+
if (cp === '') return true; // allow empty = root
172+
173+
try {
174+
// Build against current origin to normalize
175+
const u = new URL(cp, window.location.origin);
176+
177+
// Must be same origin and http(s)
178+
if (u.origin !== window.location.origin) return false;
179+
if (!(u.protocol === 'http:' || u.protocol === 'https:')) return false;
180+
181+
// Path only: no query or fragment
182+
if (u.search !== '' || u.hash !== '') return false;
183+
184+
const p = u.pathname;
185+
186+
// Must start with a single slash and contain only safe path chars
187+
if (!p.startsWith('/')) return false;
188+
if (!/^\/[A-Za-z0-9._~\-\/]*$/.test(p)) return false;
189+
190+
// Disallow protocol relative indicators traversal and HTML breaking chars
191+
if (p.includes('//') || p.includes('..')) return false;
192+
if (/[<>"'`\\]/.test(p)) return false;
193+
194+
return true;
195+
} catch {
196+
return false;
197+
}
198+
}
165199
</script>
166200

167201
</body>

openapi/src/main/resources/templates/rapidoc/index.html

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
return decodeURIComponent(v.replace(/(?:^|.*;\s*)contextPath\s*=\s*([^;]*).*$|^.*$/, "$1"));
2323
}
2424
const cookie = extract(document.cookie)
25-
const contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie
25+
contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
26+
if (!isSafeContextPath(contextPath)) {
27+
contextPath = '';
28+
}
2629
const head = document.getElementsByTagName('head')[0];
2730
{{rapipdf.script}}
2831
const rapidocJs = script(contextPath + "{{rapidoc.js.url.prefix}}rapidoc-min.js", head, true)
@@ -49,6 +52,37 @@
4952
head.appendChild(el);
5053
return el;
5154
}
55+
function isSafeContextPath(cp) {
56+
if (typeof cp !== 'string') return false;
57+
cp = cp.trim();
58+
if (cp === '') return true; // allow empty = root
59+
60+
try {
61+
// Build against current origin to normalize
62+
const u = new URL(cp, window.location.origin);
63+
64+
// Must be same origin and http(s)
65+
if (u.origin !== window.location.origin) return false;
66+
if (!(u.protocol === 'http:' || u.protocol === 'https:')) return false;
67+
68+
// Path only: no query or fragment
69+
if (u.search !== '' || u.hash !== '') return false;
70+
71+
const p = u.pathname;
72+
73+
// Must start with a single slash and contain only safe path chars
74+
if (!p.startsWith('/')) return false;
75+
if (!/^\/[A-Za-z0-9._~\-\/]*$/.test(p)) return false;
76+
77+
// Disallow protocol relative indicators traversal and HTML breaking chars
78+
if (p.includes('//') || p.includes('..')) return false;
79+
if (/[<>"'`\\]/.test(p)) return false;
80+
81+
return true;
82+
} catch {
83+
return false;
84+
}
85+
}
5286
</script>
5387
</body>
5488
</html>

openapi/src/main/resources/templates/redoc/index.html

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
return decodeURIComponent(v.replace(/(?:^|.*;\s*)contextPath\s*=\s*([^;]*).*$|^.*$/, "$1"));
1515
}
1616
const cookie = extract(document.cookie);
17-
const contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
17+
contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
18+
if (!isSafeContextPath(contextPath)) {
19+
contextPath = '';
20+
}
1821
const head = document.getElementsByTagName('head')[0];
1922
{{rapipdf.script}}
2023
const redocJs = script(contextPath + "{{redoc.js.url.prefix}}redoc.standalone.js", head, true);
@@ -32,6 +35,37 @@
3235
head.appendChild(el);
3336
return el;
3437
}
38+
function isSafeContextPath(cp) {
39+
if (typeof cp !== 'string') return false;
40+
cp = cp.trim();
41+
if (cp === '') return true; // allow empty = root
42+
43+
try {
44+
// Build against current origin to normalize
45+
const u = new URL(cp, window.location.origin);
46+
47+
// Must be same origin and http(s)
48+
if (u.origin !== window.location.origin) return false;
49+
if (!(u.protocol === 'http:' || u.protocol === 'https:')) return false;
50+
51+
// Path only: no query or fragment
52+
if (u.search !== '' || u.hash !== '') return false;
53+
54+
const p = u.pathname;
55+
56+
// Must start with a single slash and contain only safe path chars
57+
if (!p.startsWith('/')) return false;
58+
if (!/^\/[A-Za-z0-9._~\-\/]*$/.test(p)) return false;
59+
60+
// Disallow protocol relative indicators traversal and HTML breaking chars
61+
if (p.includes('//') || p.includes('..')) return false;
62+
if (/[<>"'`\\]/.test(p)) return false;
63+
64+
return true;
65+
} catch {
66+
return false;
67+
}
68+
}
3569
</script>
3670
</body>
3771
</html>

openapi/src/main/resources/templates/scalar/index.html

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
return decodeURIComponent(v.replace(/(?:^|.*;\s*)contextPath\s*=\s*([^;]*).*$|^.*$/, "$1"));
1616
};
1717
const cookie = extract(document.cookie);
18-
const contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
18+
contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
19+
if (!isSafeContextPath(contextPath)) {
20+
contextPath = '';
21+
}
1922
const head = document.getElementsByTagName("head")[0];
2023

2124
window.onload = function() {
@@ -36,6 +39,37 @@
3639
head.appendChild(el);
3740
return el;
3841
}
42+
function isSafeContextPath(cp) {
43+
if (typeof cp !== 'string') return false;
44+
cp = cp.trim();
45+
if (cp === '') return true; // allow empty = root
46+
47+
try {
48+
// Build against current origin to normalize
49+
const u = new URL(cp, window.location.origin);
50+
51+
// Must be same origin and http(s)
52+
if (u.origin !== window.location.origin) return false;
53+
if (!(u.protocol === 'http:' || u.protocol === 'https:')) return false;
54+
55+
// Path only: no query or fragment
56+
if (u.search !== '' || u.hash !== '') return false;
57+
58+
const p = u.pathname;
59+
60+
// Must start with a single slash and contain only safe path chars
61+
if (!p.startsWith('/')) return false;
62+
if (!/^\/[A-Za-z0-9._~\-\/]*$/.test(p)) return false;
63+
64+
// Disallow protocol relative indicators traversal and HTML breaking chars
65+
if (p.includes('//') || p.includes('..')) return false;
66+
if (/[<>"'`\\]/.test(p)) return false;
67+
68+
return true;
69+
} catch {
70+
return false;
71+
}
72+
}
3973
</script>
4074
</body>
4175
</html>

openapi/src/main/resources/templates/swagger-ui/index.html

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
return decodeURIComponent(v.replace(/(?:^|.*;\s*)contextPath\s*=\s*([^;]*).*$|^.*$/, "$1"));
1616
};
1717
const cookie = extract(document.cookie);
18-
const contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
18+
contextPath = cookie === '' ? extract(window.location.search.substring(1)) : cookie;
19+
if (!isSafeContextPath(contextPath)) {
20+
contextPath = '';
21+
}
1922
const head = document.getElementsByTagName('head')[0]
2023

2124
link(contextPath + "{{swagger-ui.js.url.prefix}}swagger-ui.css", head, "text/css", "stylesheet")
@@ -95,6 +98,37 @@
9598
head.appendChild(el);
9699
return el;
97100
}
101+
function isSafeContextPath(cp) {
102+
if (typeof cp !== 'string') return false;
103+
cp = cp.trim();
104+
if (cp === '') return true; // allow empty = root
105+
106+
try {
107+
// Build against current origin to normalize
108+
const u = new URL(cp, window.location.origin);
109+
110+
// Must be same origin and http(s)
111+
if (u.origin !== window.location.origin) return false;
112+
if (!(u.protocol === 'http:' || u.protocol === 'https:')) return false;
113+
114+
// Path only: no query or fragment
115+
if (u.search !== '' || u.hash !== '') return false;
116+
117+
const p = u.pathname;
118+
119+
// Must start with a single slash and contain only safe path chars
120+
if (!p.startsWith('/')) return false;
121+
if (!/^\/[A-Za-z0-9._~\-\/]*$/.test(p)) return false;
122+
123+
// Disallow protocol relative indicators traversal and HTML breaking chars
124+
if (p.includes('//') || p.includes('..')) return false;
125+
if (/[<>"'`\\]/.test(p)) return false;
126+
127+
return true;
128+
} catch {
129+
return false;
130+
}
131+
}
98132
</script>
99133
</body>
100134

0 commit comments

Comments
 (0)