XSS via lastLoginIp HTTP header rendered with bypassSecurityTrustHtml in [innerHTML]
`lastLoginIp`, derived from the attacker-controllable `true-client-ip` header and then wrapped with `bypassSecurityTrustHtml`, is bound to `[innerHTML]`, allowing stored XSS in the Last Login IP screen.
In last-login-ip.component.html line 10:
<dd [innerHTML]="lastLoginIp"></dd>
lastLoginIp is set in last-login-ip.component.ts lines 32–42:
payload = jwtDecode(token)
if (payload.data.lastLoginIp) {
this.lastLoginIp = this.sanitizer.bypassSecurityTrustHtml(`<small>${payload.data.lastLoginIp}</small>`)
}
The lastLoginIp value travels through the JWT but originates from the user-controllable true-client-ip header handled in routes/saveLoginIp.ts:
let lastLoginIp = req.headers['true-client-ip']
...
challengeUtils.solveIf(challenges.httpHeaderXssChallenge, () => { return lastLoginIp === '<iframe src="javascript:alert(`xss`)">' })
...
lastLoginIp = security.sanitizeSecure(lastLoginIp ?? '')
...
await user?.update({ lastLoginIp: lastLoginIp?.toString() })
The server stores attacker-supplied content (the challenge logic explicitly recognises <iframe src="javascript:alert(\xss\)"> as a successful exploit, indicating the bypassable sanitizer permits this payload). The stored value is later returned to the user, embedded in their JWT, and rendered through bypassSecurityTrustHtml+[innerHTML], producing stored XSS.
- Authenticate as a user and submit a request whose
True-Client-IPheader is<iframe src="javascript:alert(xss)">. - The server stores the value in
Users.lastLoginIpand re-issues the JWT. - Navigating to
/#/privacy-security/last-login-ipdecodes the JWT and renders<small><iframe src="javascript:alert(\xss\)"></small>via[innerHTML], executing the script.
Stored XSS in the user's own session (and any administrator who views the affected user's record). An attacker can run arbitrary JavaScript, exfiltrate the JWT from localStorage, hijack the account, or pivot to higher-privileged accounts.
The template line <dd [innerHTML]="lastLoginIp"></dd> binds to a value the component explicitly wraps with sanitizer.bypassSecurityTrustHtml(...), defeating Angular's default sanitization. The data flows from the attacker-controlled true-client-ip request header through saveLoginIp.ts, whose sanitizeSecure is known-bypassable (the challenge condition itself recognizes <iframe src="javascript:alert(\xss\)"> as a winning payload), is persisted, and then re-emitted in the JWT and rendered as raw HTML. The exploit chain is reachable and the unsafe sink (bypassSecurityTrustHtml + [innerHTML]) is unambiguous.
The sink is reached over HTTP (AV:N) by setting the true-client-ip header on a request to routes/saveLoginIp.ts, which persists the value to Users.lastLoginIp — this requires being logged in so the row can be associated with a user (PR:L), and the challenge confirms security.sanitizeSecure lets <iframe src="javascript:alert(...)"> through, so no special conditions are needed (AC:L). The payload only fires when a victim (the attacker's own account or, more impactfully, an admin viewing the user) navigates to /#/privacy-security/last-login-ip where bypassSecurityTrustHtml + [innerHTML] execute the script (UI:R), and because the script runs in the victim's browser under their origin/authority — distinct from the attacker's server-side identity — Scope is Changed. Once executing, the script can read the JWT from localStorage and act as the victim, giving full confidentiality and integrity impact on that account (C:H, I:H) and potentially disrupting the UI (A:L).
- CWE-79
- CWE-80
- OWASP A03:2021 Injection
- https://pwning.owasp-juice.shop/companion-guide/latest/part2/xss.html#_perform_a_persisted_xss_attack_via_an_http_header