TERNAL' | 'TRUSTED';
@Injectable({ providedIn: 'root' })
export class ContentSecurityService {
private sanitizer = inject(DomSanitizer);
sanitizeHtml(payload: string, risk: ContentRiskLevel): SafeHtml | string {
if (risk === 'TRUSTED') {
return this.sanitizer.bypassSecurityTrustHtml(payload);
}
// For UNTRUSTED and INTERNAL, return raw string.
// Angular's template binding will handle sanitization automatically.
return payload;
}
sanitizeStyle(payload: string): SafeStyle | null {
return this.sanitizer.sanitize(SecurityContext.STYLE, payload) as SafeStyle | null;
}
sanitizeUrl(payload: string): SafeUrl | null {
return this.sanitizer.sanitize(SecurityContext.URL, payload) as SafeUrl | null;
}
}
**Architecture Rationale:** The service returns raw strings for untrusted content, deliberately relying on Angularâs template-level sanitization. Bypassing is strictly gated behind the `'TRUSTED'` flag, which should only be set after backend validation. This prevents accidental opt-outs and centralizes security decisions.
### Step 2: Implement a Reactive Rendering Component
Use Angular signals to manage content state and computed properties to derive safe bindings. This ensures reactivity without manual change detection overhead.
```typescript
// secure-viewer.component.ts
import { Component, input, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { ContentSecurityService, ContentRiskLevel } from './content-security.service';
@Component({
selector: 'app-secure-viewer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="content-wrapper">
<div [innerHTML]="safeHtmlOutput()"></div>
<div [style]="safeStyleOutput()"></div>
</article>
`
})
export class SecureViewerComponent {
private security = inject(ContentSecurityService);
rawContent = input.required<string>();
contentStyle = input<string>('');
riskLevel = input<ContentRiskLevel>('UNTRUSTED');
safeHtmlOutput = computed(() =>
this.security.sanitizeHtml(this.rawContent(), this.riskLevel())
);
safeStyleOutput = computed(() =>
this.security.sanitizeStyle(this.contentStyle())
);
}
Architecture Rationale: computed signals automatically track dependencies and recalculate only when inputs change. The template binds directly to the computed output. If riskLevel is 'UNTRUSTED', safeHtmlOutput() returns a plain string, triggering Angularâs automatic HTML sanitization. If 'TRUSTED', it returns a SafeHtml object, skipping sanitization. This explicit branching makes security boundaries visible in the template.
Step 3: Enforce Context-Specific Validation
Different DOM targets require different validation rules. RESOURCE_URL contexts (iframes, script tags) demand the strictest handling because they execute external code. Never sanitize these automatically; always require explicit trust verification.
// embed-handler.directive.ts
import { Directive, input, effect, inject, ElementRef } from '@angular/core';
import { DomSanitizer, SecurityContext } from '@angular/platform-browser';
@Directive({ selector: '[appSecureEmbed]' })
export class SecureEmbedDirective {
private el = inject(ElementRef);
private sanitizer = inject(DomSanitizer);
embedUrl = input.required<string>();
constructor() {
effect(() => {
const url = this.embedUrl();
const trusted = this.sanitizer.sanitize(SecurityContext.RESOURCE_URL, url);
if (trusted) {
this.el.nativeElement.src = trusted;
} else {
console.warn('[Security] Blocked untrusted resource URL:', url);
}
});
}
}
Architecture Rationale: Directives handle DOM property assignment safely. Using effect() ensures the URL is validated whenever the input changes. sanitize() returns null for rejected URLs, preventing execution. This pattern is safer than bypassSecurityTrustResourceUrl() because it fails closed rather than failing open.
Pitfall Guide
-
Database Equals Trust
- Explanation: Developers assume content stored in the application database is safe. Attackers exploit stored XSS by injecting payloads through forms, which are later rendered without validation.
- Fix: Treat all database content as untrusted until explicitly verified by a backend content moderation pipeline. Use
'UNTRUSTED' risk levels by default.
-
Interpolation vs Property Binding Confusion
- Explanation: Using
{{ userValue }} inside a <div> escapes HTML, but binding [innerHTML]="userValue" sanitizes it. Developers sometimes switch to [innerHTML] to preserve formatting, inadvertently enabling script execution if bypassed.
- Fix: Reserve
[innerHTML] for explicitly sanitized or trusted content. Use text interpolation for user-generated strings that donât require markup.
-
Frontend-Only CSRF Mitigation
- Explanation: Relying solely on Angularâs XSRF token interceptor without backend validation leaves applications vulnerable to token theft or replay attacks.
- Fix: Implement double-submit cookie patterns and validate
Origin/Referer headers server-side. Angularâs withXsrfConfiguration() should complement, not replace, backend checks.
-
Ignoring RESOURCE_URL Strictness
- Explanation: Applying standard HTML sanitization rules to iframe or script sources fails to block executable payloads.
- Fix: Always use
SecurityContext.RESOURCE_URL for embeds. Validate against an allowlist of domains before passing to the sanitizer.
-
Treating Safe* Types as Runtime Enforcers
- Explanation:
SafeHtml is a TypeScript interface, not a runtime guard. It compiles away and provides zero execution-time protection.
- Fix: Enforce trust boundaries through code review gates and automated static analysis. Never assume a
Safe* type guarantees safety without verifying the upstream source.
-
Hydration Mismatches in SSR
- Explanation: Sanitized content rendered server-side may differ from client-side hydration if
DomSanitizer behaves differently across environments, causing hydration errors or security gaps.
- Fix: Ensure identical sanitizer configurations in
app.config.server.ts. Avoid environment-specific bypass logic. Test hydration with security-focused E2E suites.
-
CSP Misconfiguration
- Explanation: Overly permissive
script-src 'unsafe-inline' directives negate Angularâs sanitization efforts by allowing injected scripts to execute regardless of DOM filtering.
- Fix: Deploy strict Content Security Policies with nonces or hashes. Use
trusted-types policy enforcement to block DOM XSS vectors at the browser level.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| User-generated comments | Default [innerHTML] + constrained parser | Blocks script injection while preserving safe formatting | Low |
| Internal CMS articles | bypassSecurityTrustHtml() with backend moderation | Trusted source with editorial review justifies opt-out | Medium |
| Third-party iframe embeds | SecurityContext.RESOURCE_URL + domain allowlist | Prevents execution of unverified external code | Low |
| Dynamic component styles | SecurityContext.STYLE sanitization | Blocks legacy CSS injection vectors | Low |
| Legacy jQuery/DOM manipulation | Refactor to Angular bindings or secure directives | Eliminates framework bypass surface | High |
Configuration Template
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi, withXsrfConfiguration } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(
withInterceptorsFromDi(),
withXsrfConfiguration({
cookieName: 'XSRF-TOKEN',
headerName: 'X-XSRF-TOKEN'
})
)
]
};
<!-- index.html (CSP Meta Tag) -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self' 'nonce-<dynamic-nonce>';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
frame-src https://trusted-embeds.example.com;
trusted-types angular #default;
require-trusted-types-for 'script';">
Quick Start Guide
- Install Dependencies: Ensure
@angular/core and @angular/platform-browser are up to date. No additional packages are required for core sanitization.
- Create the Service: Copy the
ContentSecurityService implementation into your shared utilities folder. Export the ContentRiskLevel type for consistent usage.
- Replace Template Bindings: Update components using
[innerHTML] to inject the service and pass content through sanitizeHtml(). Set riskLevel based on content origin.
- Configure HTTP Security: Add
withXsrfConfiguration() to your provideHttpClient() call. Verify your backend sets matching cookie and header names.
- Deploy CSP Headers: Add the meta tag to
index.html or configure your reverse proxy to inject strict CSP headers. Test with browser dev tools to verify policy enforcement.