Mert Özen Further With Every Line
Security Frontend Security — #07

Angular SSR Security: allowedHosts, Host Header Validation, TransferState and Secrets Leaking into Bundles

Mert Özen Nov 8, 2026 16 min 4 views
Angular SSR Security: allowedHosts, Host Header Validation, TransferState and Secrets Leaking into Bundles

In Angular SSR the server resolves relative HttpClient URLs against the incoming request's Host header, so fill allowedHosts with explicit names, trust proxy headers only behind a trusted proxy and do not derive the API address from Host. On a local SSR server we show SSRF, the 2026 advisories, fields leaking into TransferState, a nonce-based CSP under SSR and secrets ending up in bundles and source maps, all with real output.

Version note: The examples in this post were run with Angular 22.2 (@angular/ssr 22.2.6), Express 5.3, Node.js 24 and Chrome 156 (headless).

Guess First

The Bookstore book list is now rendered on the server. The component on the page fetches the books with HttpClient from /api/books, a relative URL (Uniform Resource Locator). Because validating host names for traffic coming through the load balancer felt like a chore, someone put * into allowedHosts. Now someone sends a request to the SSR (Server-Side Rendering) server with a Host: 127.0.0.1:19453 header, and on port 19453 runs an internal service that is not supposed to be public. Where does the server request /api/books from while rendering the page? What does the answer look like on the page?

In this post we try that and three more SSR-specific risks (data that goes into TransferState, CSP (Content Security Policy) under SSR, and secrets leaking into the bundle) on a local Bookstore SSR server. Every request went to my own processes on my own machine; every line shown comes from those runs.

Under SSR the Server Is a Client Too

The lab is an Angular 22 project created with ng new --ssr. The book list lives in this component:

// src/app/book-list.ts
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

interface Book {
  id: number;
  title: string;
  price: number;
}

@Component({
  selector: 'app-book-list',
  template: `
    <h1>Bookstore</h1>
    <ul>
      @for (book of books(); track book.id) {
        <li>{{ book.title }} ({{ book.price }} TRY)</li>
      }
    </ul>
  `,
})
export class BookList {
  private readonly http = inject(HttpClient);
  readonly books = toSignal(this.http.get<Book[]>('/api/books'), { initialValue: [] });
}
// src/app/app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  { path: '**', renderMode: RenderMode.Server },
];

// src/app/app.config.ts
providers: [
  provideBrowserGlobalErrorListeners(),
  provideRouter(routes),
  provideHttpClient(withFetch()),
  provideClientHydration(),
],

In the browser /api/books resolves against the page's origin; no problem there. On the server there is no "page origin". Angular fills that gap with the URL of the incoming request: an interceptor in platform-server makes relative URLs absolute using the protocol, host and port of the request being rendered. And the host is sent by the client. So unless it is validated, the person sending the request chooses the target of the SSR server's HTTP (Hypertext Transfer Protocol) requests. That is the definition of SSRF (Server-Side Request Forgery): the attacker makes your server send a request to an address they cannot reach themselves.

To see it, I put a small "internal service" on port 19453 and started the SSR server with * for allowedHosts:

// internal.mjs: plays an internal service that is not meant to be public
import http from 'node:http';
http.createServer((req, res) => {
  console.log(`[internal] ${req.method} ${req.url} host=${req.headers.host}`);
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify([{ id: 99, title: 'internal-only data', price: 0 }]));
}).listen(19453, '127.0.0.1');
$ NG_ALLOWED_HOSTS="*" PORT=19450 node dist/bookstore-ssr/server/server.mjs
Allowing all hosts via "*" is a security risk. This configuration should only be used when validation for "Host" and "X-Forwarded-Host" headers is performed in another layer, such as a load balancer or reverse proxy. For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
Node Express server listening on http://localhost:19450

$ curl -s -i -H "Host: 127.0.0.1:19453" http://127.0.0.1:19450/
HTTP/1.1 200 OK
...
<ul><li>internal-only data (0 TRY)</li><!----></ul>

$ cat internal.log
[internal] GET /api/books host=127.0.0.1:19453

Angular printed the warning at startup but ran the server. Because the request's Host header was 127.0.0.1:19453, the HttpClient call during SSR went to the internal service and the internal service's answer came back inside the HTML (HyperText Markup Language). In a real environment, port 19453 could be an admin panel, a database management interface or the cloud provider's metadata service (169.254.169.254); if the SSR server runs in a network where it can reach them, the attacker reaches them indirectly too. If an interceptor adds the cookie or Authorization header from the user's request to server-side API (Application Programming Interface) calls, those go to the address the attacker picked as well.

allowedHosts: Closed by Default in Angular 22

I started the same app without any setting, with the server.ts generated by the Angular CLI (Command-Line Interface):

$ PORT=19450 node dist/bookstore-ssr/server/server.mjs
$ curl -s -i http://localhost:19450/
HTTP/1.1 400 Bad Request
X-Powered-By: Express
content-type: text/plain
...

Header "host" with value "localhost:19450" is not allowed.

# server log
ERROR: Bad Request ("http://localhost:19450/").
Header "host" with value "localhost:19450" is not allowed.

For more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf

In Angular 22, if allowedHosts is empty the SSR server answers no host at all, localhost included, and returns 400. A page that does not open on the first production deploy is annoying, but it is the right default: fail closed. You can supply the list in three ways. At build time from angular.json:

"build": {
  "builder": "@angular/build:application",
  "options": {
    "security": {
      "allowedHosts": ["shop.bookstore.test", "*.bookstore.test", "localhost"]
    }
  }
}

or at run time with the NG_ALLOWED_HOSTS="shop.bookstore.test,localhost" environment variable, or with new AngularNodeAppEngine({ allowedHosts: [...] }). With the setting above I tried six different Host headers:

$ for h in localhost:19450 shop.bookstore.test admin.bookstore.test evil.test bookstore.test.evil.test 127.0.0.1:19453; do
    printf "%-28s " $h; curl -s -o /dev/null -w "%{http_code}\n" -H "Host: $h" http://127.0.0.1:19450/
  done
localhost:19450              200
shop.bookstore.test          200
admin.bookstore.test         200
evil.test                    400
bookstore.test.evil.test     400
127.0.0.1:19453              400

Note two lines. admin.bookstore.test returning 200 is the price of the wildcard: *.bookstore.test accepts every subdomain. If a name like admin.bookstore.test resolves on the internal network, drop the wildcard and list only the names you really use. bookstore.test.evil.test returning 400 shows that the check matches from the end; in the source code the wildcard is implemented as hostname.endsWith('.bookstore.test').

Behind a load balancer the real host often arrives in X-Forwarded-Host. Angular 22 does not trust these headers by default:

$ curl -s -i -H "X-Forwarded-Host: evil.test" http://localhost:19450/ | head -1
HTTP/1.1 200 OK

# server log
Received "x-forwarded-host" header but "trustProxyHeaders" was not set up to allow it.
For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers

The header was removed from the request, the page was rendered with the normal Host and a warning landed in the log. Turn proxy headers on only if you are behind a reverse proxy that overwrites these headers with its own values instead of passing on what the client sent:

// only behind a reverse proxy that overwrites these headers
const angularApp = new AngularNodeAppEngine({
  trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],
});

The current shape of these checks is the result of a series of advisories published during 2026, and you need to know which fixes you have. The CVE (Common Vulnerabilities and Exposures) entry CVE-2026-27739, with the GHSA (GitHub Security Advisory) identifier GHSA-x288-3778-4hhx, published on 23 February 2026, was critical: @angular/ssr built the application's origin from the Host and X-Forwarded-* headers without validating them. The fix shipped in 21.1.5, 20.3.17 and 19.2.21. In May 2026 two more advisories followed for @angular/platform-server: CVE-2026-46417 (hostname hijacking through an absolute-form request URL; fixed in 21.2.13, 20.3.21, 19.2.22) and CVE-2026-50168. The latter is a URL parser differential that bypasses the allowlist check: the WHATWG (Web Hypertext Application Technology Working Group) URL parser treated a URL like http://evil.com:80:80/path as invalid and skipped the check, while Domino, which builds the server-side DOM (Document Object Model), accepted the same URL and resolved its origin to evil.com. The patched versions are 22.0.0-rc.2, 21.2.15, 20.3.22 and 19.2.23; there is no official patch for 18.x. With Angular 22.0.0, falling back to CSR (Client-Side Rendering) when host validation fails was also removed; a 400 is returned instead. Compare the output of npm ls @angular/ssr @angular/platform-server in your own project with these versions.

A Second Layer: Do Not Derive the API Address from Host

Even with allowedHosts set correctly, relative URLs cause one more problem. I sent a valid request with Host shop.bookstore.test:

$ NG_ALLOWED_HOSTS="shop.bookstore.test,localhost" PORT=19450 node dist/bookstore-ssr/server/server.mjs
$ curl -s -i -H "Host: shop.bookstore.test" http://127.0.0.1:19450/ | grep -o '<ul.*</ul>'
<ul><!----></ul>

# server log
ERROR $n {
  ...
  status: 0,
  statusText: 'Unknown Error',
  url: 'http://shop.bookstore.test/api/books',
  ...
  error: [TypeError: fetch failed] {
    [cause]: Error: getaddrinfo ENOTFOUND shop.bookstore.test
  ...

The SSR server tried to call the API through its own public name. In the lab that name does not resolve on the machine running the server, so we got a DNS (Domain Name System) error; in a real environment the request would go out to the internet and come back through the CDN (Content Delivery Network) and the load balancer. Either way, the content of the request decides where the server sends its own request. The sturdier way is to take the API address from configuration on the server and remove the Host header from that decision entirely:

// src/app/api-origin.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { InjectionToken, inject } from '@angular/core';

export const API_INTERNAL_ORIGIN = new InjectionToken<string>('API_INTERNAL_ORIGIN');

// Server-only: relative /api calls go to a fixed internal origin, never to the Host header
export const apiOriginInterceptor: HttpInterceptorFn = (req, next) => {
  if (!req.url.startsWith('/api/')) {
    return next(req);
  }
  const origin = inject(API_INTERNAL_ORIGIN);
  return next(req.clone({ url: `${origin}${req.url}` }));
};
// src/app/app.config.server.ts
import { ApplicationConfig, CSP_NONCE, REQUEST_CONTEXT, inject, mergeApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
import { API_INTERNAL_ORIGIN, apiOriginInterceptor } from './api-origin.interceptor';

const internalOrigin = process.env['BOOKSTORE_API_ORIGIN'] ?? 'http://127.0.0.1:19450';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    provideHttpClient(withFetch(), withInterceptors([apiOriginInterceptor])),
    { provide: API_INTERNAL_ORIGIN, useValue: internalOrigin },
    {
      provide: CSP_NONCE,
      useFactory: () => (inject(REQUEST_CONTEXT) as { cspNonce?: string } | null)?.cspNonce ?? null,
    },
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

The interceptor is registered only in app.config.server.ts and does not enter the browser bundle. Now I repeated the same attack with the worst setting, *:

$ NG_ALLOWED_HOSTS="*" PORT=19450 node dist/bookstore-ssr/server/server.mjs
$ curl -s -i -H "Host: 127.0.0.1:19453" http://127.0.0.1:19450/
HTTP/1.1 200 OK
...
<ul><li>Domain-Driven Design (1250 TRY)</li><li>Refactoring (980 TRY)</li><!----></ul>

# server log: the call went to the fixed origin, internal.log got no new line
[api] GET /api/books host=127.0.0.1:19450

Although the Host header pointed to the internal service, the call went to the fixed origin and the page shows the real books. This does not replace an allowlist; it is only a second layer. I measured its cost too: the transfer cache key is now the absolute internal address, so it no longer matches the relative request in the browser, and the request is sent once more after hydration:

# headless Chrome, requests to /api/ after the SSR page loads
# without the interceptor
[status] 200
[eval] 2

# with the interceptor
[request] GET http://localhost:19450/api/books
[status] 200
[eval] 2

Angular has the HTTP_TRANSFER_CACHE_ORIGIN_MAP token to fix this; it maps the internal origin to the public origin the browser uses. But the token only maps one origin to another origin; when I mapped to an empty string nothing changed because of the line if (!mappedOrigin) return url; in the source. If the browser side also uses an absolute API address (for example a separate api.bookstore.test), this token is designed exactly for that.

TransferState: The JSON in the HTML Is Public

The next SSR trap sits inside a performance feature. provideClientHydration() embeds the responses of HttpClient requests made on the server into the HTML, so the browser does not repeat them. The first version of the API returned the books together with supplier data, while the page only showed title and price:

// src/server.ts
app.get('/api/books', (req, res) => {
  console.log(`[api] GET /api/books host=${req.headers.host}`);
  res.json([
    { id: 1, title: 'Domain-Driven Design', price: 1250, supplierCost: 610, supplierEmail: 'orders@supplier.example' },
    { id: 2, title: 'Refactoring', price: 980, supplierCost: 455, supplierEmail: 'orders@supplier.example' },
  ]);
});
$ curl -s http://localhost:19450/ | grep -o '<script id="ng-state"[^<]*</script>'
<script id="ng-state" type="application/json">{"6297af8e584ff750aab1c19d4ce46985a8cb6ba11d003af9c1a69d6f269b32b2":{"b":[{"id":1,"title":"Domain-Driven Design","price":1250,"supplierCost":610,"supplierEmail":"orders@supplier.example"},{"id":2,"title":"Refactoring","price":980,"supplierCost":455,"supplierEmail":"orders@supplier.example"}],"h":{},"s":200,"st":"OK","u":"/api/books","rt":"json"},"__nghData__":[{"t":{"4":"t0"},"c":{"4":[{"i":"t0","r":1,"x":2}]}},{"c":{"0":[{"i":"c3528196404","r":1}]}}]}</script>

supplierCost and supplierEmail, which never appear on the page, sit in the page source as JSON (JavaScript Object Notation); for anyone who hits "view source", including search engine bots and every CDN that caches the page. A component not showing a field does not mean the field never reaches the client. This was true before SSR as well (the browser's HttpClient response is visible in the Network tab), but since SSR writes the data into the HTML itself, the leak now also persists in a page cache. The fix is on the API side: a separate response model that returns only the fields the page needs.

const books = [
  { id: 1, title: 'Domain-Driven Design', price: 1250, supplierCost: 610, supplierEmail: 'orders@supplier.example' },
  { id: 2, title: 'Refactoring', price: 980, supplierCost: 455, supplierEmail: 'orders@supplier.example' },
];

app.get('/api/books', (req, res) => {
  console.log(`[api] GET /api/books host=${req.headers.host}`);
  // only the fields the page shows leave the server
  res.json(books.map(({ id, title, price }) => ({ id, title, price })));
});
<script id="ng-state" type="application/json">{"57acc35259ffb2819ebbc98112190d5d60975a8876cb9932005ae688a373ea71":{"b":[{"id":1,"title":"Domain-Driven Design","price":1250},{"id":2,"title":"Refactoring","price":980}],"h":{},"s":200,"st":"OK","u":"http://127.0.0.1:19450/api/books","rt":"json"},"__nghData__":[{"t":{"4":"t0"},"c":{"4":[{"i":"t0","r":1,"x":2}]}},{"c":{"0":[{"i":"c3528196404","r":1}]}}]}</script>

The supplier fields are gone. There is one more thing in this output: the "u" field contains the internal address used by the interceptor from the previous section, http://127.0.0.1:19450/api/books. An internal address is not a secret, but you do not want to describe your internal network topology to the outside either; one more reason to use the origin map. By default Angular's transfer cache does not cache requests that carry an Authorization, Proxy-Authorization or Cookie header, or requests sent with withCredentials. Before you write includeRequestsWithAuthHeaders: true, think about whether a user-specific response could end up in a shared page cache.

CSP under SSR: No autoCsp, Use a Nonce

autoCsp, which you saw in the CSP post of this series, computes the hashes of inline scripts at build time; angular.dev states plainly that it cannot be used with SSR. Under SSR the right tool is a nonce generated fresh for every request. In my first attempt I only added the CSP_NONCE provider and sent the header. Headless Chrome reported two blocks:

[console.error] Loading the script 'http://localhost:19450/main-C66JJH6J.js' violates the following Content Security Policy directive: "script-src 'nonce-Tl6AcMsj7sSK4sd1ERQzcg==' 'strict-dynamic'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback. The action has been blocked.
[console.error] Executing inline script violates the following Content Security Policy directive 'script-src 'nonce-Tl6AcMsj7sSK4sd1ERQzcg==' 'strict-dynamic''. Either the 'unsafe-inline' keyword, a hash ('sha256-LMY6wYoFV9I4wWzxaq1N/dTpl4iurQktw706UCHK3vM='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.

CSP_NONCE gives a nonce to the inline scripts and styles Angular adds at runtime; but the <script src="main-...js"> tag coming from index.html and the small inline script that loads critical CSS (Cascading Style Sheets) know nothing about it. Angular's build step offers a way for these: if the root element has an ngCspNonce attribute, its value is copied to every script, style and link tag. I wrote a placeholder as the value:

<!-- src/index.html -->
<app-root ngCspNonce="NG_CSP_NONCE"></app-root>
$ grep -o '<[a-z-]* [^>]*NG_CSP_NONCE[^>]*>' dist/bookstore-ssr/server/index.server.html
<link rel="stylesheet" href="styles-5INURTSO.css" nonce="NG_CSP_NONCE">
<script type="text/javascript" id="ng-event-dispatch-contract" nonce="NG_CSP_NONCE">
<app-root ngcspnonce="NG_CSP_NONCE">
<script src="main-C66JJH6J.js" type="module" nonce="NG_CSP_NONCE">

The server generates a new nonce for every request, hands it to Angular through REQUEST_CONTEXT (the CSP_NONCE factory in app.config.server.ts above) and replaces the placeholder in the HTML:

app.use(async (req, res, next) => {
  try {
    const cspNonce = randomBytes(16).toString('base64');
    const response = await angularApp.handle(req, { cspNonce });
    if (!response) {
      return next();
    }
    // replace the placeholder only inside a start tag; text nodes never contain a raw '<'
    const html = (await response.text()).replace(
      /(<[a-z][a-z0-9-]*\s[^<>]*?(?:nonce|ngcspnonce)=")NG_CSP_NONCE"/gi,
      (_, start: string) => `${start}${cspNonce}"`,
    );
    res.setHeader(
      'Content-Security-Policy',
      `script-src 'nonce-${cspNonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`,
    );
    await writeResponseToNodeResponse(new Response(html, response), res);
  } catch (error) {
    next(error);
  }
});
$ curl -s -D - http://localhost:19450/ -o n1.html | grep -i content-security; grep -o '<script[^>]*>' n1.html
Content-Security-Policy: script-src 'nonce-2HWW+u8zTw90z4dje5ej5Q==' 'strict-dynamic'; object-src 'none'; base-uri 'self'
<script src="main-C66JJH6J.js" type="module" nonce="2HWW+u8zTw90z4dje5ej5Q==">
<script id="ng-state" type="application/json">
<script nonce="2HWW+u8zTw90z4dje5ej5Q==">

$ curl -s -D - http://localhost:19450/ -o n2.html | grep -i content-security
Content-Security-Policy: script-src 'nonce-LVV18KmX0AHBfhZUYH/Sng==' 'strict-dynamic'; object-src 'none'; base-uri 'self'

Every request has a different nonce and the script tags carry the right value; in headless Chrome the page hydrated without a single CSP violation. I learned why the replace has to be this narrow from a mistake. The first version only looked for the attribute pattern:

// first version
const html = (await response.text()).replace(
  /(nonce|ngcspnonce)="NG_CSP_NONCE"/gi,
  (_, attr: string) => `${attr}="${cspNonce}"`,
);

I thought this was safe, because I assumed user data could not produce an attribute with a " character. To check, I changed a book title to Refactoring nonce="NG_CSP_NONCE":

# book title: 'Refactoring nonce="NG_CSP_NONCE"'
# first version
<li>Refactoring nonce="ChuPh4uQJSXr8ruJjcrh0Q==" (980 TRY)</li>
# start-tag-anchored version
<li>Refactoring nonce="NG_CSP_NONCE" (980 TRY)</li>

HTML serialization escapes &, < and > in text nodes but leaves " as it is. That is why the first version wrote the request's nonce into the text on the page. On its own this runs no script, but a secret nonce is the assumption CSP rests on; combined with another markup injection it becomes useful. The final version looks for the placeholder only inside a start tag; a text node cannot contain a raw <, so user data cannot match the pattern. One more thing: do not store nonced HTML in a CDN or in the SSR server's own cache; a cached page gives everyone the same nonce.

Everything in the Bundle Is Public

The last trap is not specific to SSR, but it shows up more often in SSR projects because the team thinks "this code runs on the server too". I put a key for a maps service into the environment file and used it in BookList as readonly mapsKey = environment.mapsApiKey;:

// src/environments/environment.ts
export const environment = {
  apiBaseUrl: '/api',
  // TODO: move to the BFF before go-live
  mapsApiKey: 'dev-only-maps-key-7f3a',
};
$ npx ng build
$ grep -o '.\{60\}dev-only-maps-key-7f3a.\{10\}' dist/bookstore-ssr/browser/*.js
1&&e(t.value,n.value)}var _d={apiBaseUrl:"/api",mapsApiKey:"dev-only-maps-key-7f3a"};var c_=
$ grep -c "TODO" dist/bookstore-ssr/browser/*.js
0

The production build wrote the key into the browser bundle as is, while the comment line disappeared with minification. If a value is imported from a file that goes to the browser, it is public, even if the file is called environment. SSR does not change this rule: component code goes into both the server and the browser bundle. A real secret must be read from process.env only in code that runs solely on the server, such as server.ts; if the browser needs to call a third-party API, the call should go through the BFF (Backend for Frontend) and the key should stay there.

Source maps go one step further:

$ npx ng build --source-map
$ tail -c 100 dist/bookstore-ssr/browser/main-C66JJH6J.js
.catch(e=>console.error(e));
//# debugId=68dcb6eb-35b7-51fb-a268-ce6860907188
//# sourceMappingURL=main-C66JJH6J.js.map

$ node -e "const m=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));const i=m.sources.findIndex(s=>s.includes('environment'));console.log(m.sources[i]);console.log(m.sourcesContent[i])" dist/bookstore-ssr/browser/main-C66JJH6J.js.map
src/environments/environment.ts
export const environment = {
  apiBaseUrl: '/api',
  // TODO: move to the BFF before go-live
  mapsApiKey: 'dev-only-maps-key-7f3a',
};

$ curl -s -o /dev/null -w "%{http_code} %{size_download} %{content_type}\n" http://localhost:19450/main-C66JJH6J.js.map
200 2181522 application/json; charset=utf-8

The map file is over 2 MB and contains the TypeScript source itself: the comment that minification removed, the TODO note, the file structure. And the SSR server served the file to everyone through express.static. If you need source maps for your error tracker, produce hidden source maps and do not serve the files:

// angular.json, configurations.production
"sourceMap": { "scripts": true, "styles": false, "hidden": true }

// src/server.ts, before express.static
// hidden source maps stay in dist for the error tracker, but are never served
app.use((req, res, next) => (req.path.endsWith('.map') ? res.sendStatus(404) : next()));
$ ls dist/bookstore-ssr/browser
favicon.ico
index.csr.html
main-C66JJH6J.js
main-C66JJH6J.js.map
styles-5INURTSO.css
$ grep -c sourceMappingURL dist/bookstore-ssr/browser/main-*.js
0
$ curl -s -o /dev/null -w "map: %{http_code}\n" http://localhost:19450/main-C66JJH6J.js.map
map: 404

hidden only removes the sourceMappingURL comment at the end of the bundle; the file is still in dist and its name is the bundle's name plus .map, so it is easy to guess. That is why you either upload the map files to the error tracker and delete them in the deploy step, or block them on the server as above. The server bundle (dist/.../server), on the other hand, is not under the express.static root, so it could not be requested from outside; my request for /server.mjs returned 404.

In Production

Put the SSR server in its own network segment and restrict its egress: it should reach the API and the services it really needs, not the cloud metadata address or management interfaces. allowedHosts and the interceptor are checks inside the application; the network rule is the layer that still works when there is a bug in the code.

Watch the 400 responses. The line Header "host" with value ... is not allowed is the first sign both of a misconfiguration after a deploy and of someone playing with the Host header. Set up an alert for these lines and limit the length of header values that go into the log.

Track the Angular version for security patches. All three SSRF advisories of 2026 were closed with patch releases on the supported major versions; run ng update not only for major versions but also when an advisory is published. npm audit running in CI (Continuous Integration) reports these entries from the GitHub Advisory Database, but it only checks the version in the lock file; do not forget to rebuild the container image.

Common Pitfalls

First, writing * into allowedHosts to silence the 400s. The first experiment in the lab was the result, and Angular's own warning says exactly this: * is acceptable only if another layer validates Host and X-Forwarded-Host.

Second, turning on trustProxyHeaders: true behind a proxy that does not overwrite the headers. Then the X-Forwarded-Host sent by the client becomes trusted directly.

Third, running the SSR server in an environment with broad access to the internal network (for example in the same subnet as the database). What determines the impact of SSRF is what the server can reach on the network.

Fourth, not narrowing the API response because "we don't show that field"; TransferState writes it into the page. Fifth, putting secrets into environment files and serving source maps in production.

A Small Experiment

★☆☆ Use the NG_ALLOWED_HOSTS environment variable and the list in angular.json together; try with curl whether the union of the two lists applies or only one of them.

★★☆ Change the Bookstore so the browser uses an absolute API address such as http://api.bookstore.test:19454, and on the server map the internal origin to that address with HTTP_TRANSFER_CACHE_ORIGIN_MAP. Check that the second request after hydration disappears and what the "u" value in ng-state becomes.

★★★ Add an Express middleware to server.ts that runs before rendering and compares req.hostname with your own allowlist. Without turning off Angular's own check, verify with a test script that both layers reject the same requests.

In the next post we move from the application itself to its dependencies: we will talk about hardening the npm supply chain by restricting lifecycle scripts, putting a waiting period on new releases, and using provenance and trusted publishing.