~/blog/spa-route-enumeration-angularPOSTED
by Shady Nathan Tawfik · May 23, 2026 · 4 min

A Single Page Application Hands You Its Own Route Map

A modern framework application with almost no server-side exposure still revealed its administrative surface, because the route table is compiled into the client bundle.

Interactive[blog-2.0]

TL;DR: A single page application hands out its own route map: the complete route table, permission guard names and environment config ship in the client bundle. I extracted all of it with one script download.

The scenario

I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.

A chamber of industry and commerce portal built on a modern component framework, served as a pre-rendered application through a content delivery network. The engagement focused on what the delivered client assets reveal about routes, environments and administrative features.

I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.

The pattern

The pattern I recognised — and that I keep finding in similar estates:

Pre-rendering helps search engines and does nothing for confidentiality. The router that decides what a user may reach is a client-side module, so the complete route table, its lazy-loaded feature chunks and the environment configuration all ship to every visitor. The server never has to know that an administrative screen exists, and the browser is handed the map anyway.

This inverts the usual economy of reconnaissance. Where a classic target costs an attacker a directory scan, a client-rendered target yields the same information from a single script download. The interesting part is not that the file is large but that the identifiers are readable: path strings, permission guards, role names and API base URLs survive minification intact, because renaming them would break the application.

The practical consequence is that route enumeration becomes a passive activity with a very high signal rate. Everything the application intends to guard is named in the bundle, including the guards themselves, which tells an attacker both where to go and what the check was supposed to be.

TL;DR: A modern framework application with almost no server-side exposure still revealed its administrative surface, because the route table is compiled into the client bundle.

Findings

SeverityFindingEvidence
MEDIUMComplete route table readable in the client bundleAdministrative and internal screens enumerated from assets
MEDIUMPermission guard names exposedThe intended authorisation logic described in the bundle
MEDIUMEnvironment configuration compiled into the bundlePer-environment API base addresses
LOWSource map served publiclyOriginal sources recoverable
LOWLazy chunks enumerated by predictable namingFeature modules downloadable individually
INFOServer-side surface minimalNo server rendering and no database reachable

The attack path

Download one script fileExtract route path stringsEnumerate administrative screensRead permission guard namesLearn the intended authorisation ruleProbe the API behind those routes

Reproduction in my lab

Every command below targets a lab container I control. Nothing here is aimed at a live system.

Read the route map out of your own bundle

~/code/bash bash
# target: your own built assets
$ curl -s https://example.org/ | grep -oE '/assets/[A-Za-z0-9._-]+\.js' | sort -u | head

$ for f in $(curl -s https://example.org/ | grep -oE '/assets/[A-Za-z0-9._-]+\.js' | sort -u); do
    curl -s "https://example.org$f"
  done > /tmp/bundle.txt

# route-like strings survive minification
$ grep -oE '"/[a-z0-9/-]{4,40}"' /tmp/bundle.txt | sort -u | head -40

# guard and role identifiers
$ grep -oiE '(canActivate|hasRole|isAdmin|requiresAuth|permission)[A-Za-z]*' /tmp/bundle.txt | sort -u | head

Detection in your own estate

Check whether source maps and environment files are served

~/code/bash bash
$ curl -so /dev/null -w '%{http_code}\n' https://example.org/assets/main.js.map
$ curl -so /dev/null -w '%{http_code}\n' https://example.org/environments/environment.prod.js
$ curl -so /dev/null -w '%{http_code}\n' https://example.org/assets/config.json

Look for legacy content management routes on the same host

~/code/bash bash
$ for p in /wp-json/ /xmlrpc.php /api/graphql /.well-known/security.txt; do
    printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://example.org$p)"
  done

Remediation

Do not ship source maps to production

~/code/json json
{
  "production": {
    "sourceMap": false,
    "namedChunks": false
  }
}

Keep environment values on the server, not in the bundle

~/code/ts ts
// the bundle must not contain per-environment endpoints
// inject only what the browser genuinely needs, and nothing that maps your estate
export const environment = {
  production: true,
  apiBase: '/api',            // same-origin relative path, never a full host
};

Enforce the authorisation on the server, then treat the guard as UX

~/code/ts ts
// the client guard improves the experience, the server decides
// every administrative route needs the same server-side session check
export const canActivate = () => {
  const session = inject(SessionService);
  return session.hasRole('admin') ? true : inject(Router).parseUrl('/login');
};

Once an attacker gains access, they can perform the following actions:

Complete route table readable in the client bundle
Permission guard names exposed
Environment configuration compiled into the bundle

Takeaways

  • Client-side routing guards are user experience, never authorisation.
  • A route table in the bundle is an attack surface map with no expiry date.
  • Turn off source maps in production and stop describing your estate to visitors.
  • Look for a legacy content management system sharing the same host.

For educational purposes only. Every reproducible command targets my own lab environment (localhost), never a live system. © 2026