agentggagentgg
Back to all findings
MEDIUMconfirmedxssxsseb0826708fef

Stored XSS via bypassSecurityTrustHtml on attacker-controlled True-Client-IP header

`lastLoginIp` (sourced from the user's `True-Client-IP` request header on the server) is wrapped in `DomSanitizer.bypassSecurityTrustHtml` and rendered via `[innerHTML]`, allowing arbitrary HTML/JS execution in the victim's session.

Filefrontend/src/app/last-login-ip/last-login-ip.component.ts
Lines3741
Confidence
90%
File statusvalidated
Details

The component reads the JWT from localStorage, decodes it, and pulls payload.data.lastLoginIp — a value the server stores from the True-Client-IP HTTP header — and then explicitly disables Angular's sanitizer for it before binding to [innerHTML]:

// frontend/src/app/last-login-ip/last-login-ip.component.ts (37-41)
if (payload.data.lastLoginIp) {
  this.lastLoginIp = this.sanitizer.bypassSecurityTrustHtml(
    `<small>${payload.data.lastLoginIp}</small>`
  )
}
<!-- frontend/src/app/last-login-ip/last-login-ip.component.html (10) -->
<dd [innerHTML]="lastLoginIp"></dd>

Tracing the data origin in routes/saveLoginIp.ts confirms the value crosses an attacker trust boundary without HTML escaping when the challenge is active:

// routes/saveLoginIp.ts (18-32)
let lastLoginIp = req.headers['true-client-ip']
...
if (utils.isChallengeEnabled(challenges.httpHeaderXssChallenge)) {
  challengeUtils.solveIf(...)
} else {
  lastLoginIp = security.sanitizeSecure(lastLoginIp ?? '')
}
...
await user?.update({ lastLoginIp: lastLoginIp?.toString() })

When the httpHeaderXssChallenge flag is enabled (default in the standard Juice Shop config), sanitizeSecure is bypassed, so an attacker can store raw markup (e.g. <iframe src="javascript:alert(xss)">) in their own lastLoginIp field. On the next login, the value is embedded in the JWT and rendered as trusted HTML via bypassSecurityTrustHtml + [innerHTML], executing in the user's browser context. Even when the challenge is disabled, relying on bypassSecurityTrustHtml for a value that originated from an HTTP header is fragile — the sink is unconditional in the front end.

Proof of concept
  1. Authenticate, then send any authenticated request including the header True-Client-IP: <iframe src="javascript:alert(document.cookie)"> (the saveLoginIp middleware persists the header value to your own user row).
  2. Log out and log in again — the new JWT contains the malicious string in payload.data.lastLoginIp.
  3. Visit /#/privacy-security/last-login-ip. The Angular component calls bypassSecurityTrustHtml on the header-supplied value and binds it via [innerHTML], executing the injected script in your own browser. In a multi-user/admin-impersonation scenario the same primitive can be weaponized against another account.
Impact

Authenticated stored XSS in the victim's own session. An attacker who can influence the True-Client-IP header for a target session (header injection on intermediate proxies, social-engineered reflected request, or self-XSS escalation) can execute arbitrary JavaScript in the application origin — token theft from localStorage, account hijack, CSRF-bypassing actions against the API.

Validation
confirmed

The component calls sanitizer.bypassSecurityTrustHtml on payload.data.lastLoginIp (line 41) and binds the result via [innerHTML] in the template, with the underlying value originating from the attacker-controllable True-Client-IP header persisted by routes/saveLoginIp.ts. The frontend sink is unconditional, and when httpHeaderXssChallenge is enabled the server stores the raw header without sanitizeSecure, completing a stored XSS chain. Scope explicitly forbids dismissing the bug because Juice Shop is a training app, so this is a real defect.

CVSS 3.1
CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:L
Base score: 6.7 · MEDIUM

The sink at lines 37–41 unconditionally calls bypassSecurityTrustHtml on payload.data.lastLoginIp and pipes it to [innerHTML], so any HTML stored in that field executes JS in the application origin (token theft from localStorage → full account takeover, hence C:H/I:H; A:L for page disruption). Exploitation is remote (AV:N) but requires an authenticated user row to persist True-Client-IP via saveLoginIp.ts (PR:L), and the victim must navigate to /#/privacy-security/last-login-ip for the sink to fire (UI:R). AC is High because the routes/saveLoginIp.ts path only skips sanitizeSecure when httpHeaderXssChallenge is enabled, and weaponizing against another account requires injecting True-Client-IP into the victim's login request (proxy/header-injection or social-engineered request) since each browser reads only its own JWT. Scope is Unchanged because the XSS executes within the same Angular app origin.

References