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.
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.
- Authenticate, then send any authenticated request including the header
True-Client-IP: <iframe src="javascript:alert(document.cookie)">(thesaveLoginIpmiddleware persists the header value to your own user row). - Log out and log in again — the new JWT contains the malicious string in
payload.data.lastLoginIp. - Visit
/#/privacy-security/last-login-ip. The Angular component callsbypassSecurityTrustHtmlon 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.
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.
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.
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.
- CWE-79
- CWE-116
- OWASP A03:2021 — Injection