Angular SSR Güvenliği: allowedHosts, Host Header Doğrulama, TransferState ve Bundle'a Sızan Secret'lar
Angular SSR'da sunucu, göreli HttpClient URL'lerini gelen isteğin Host header'ına göre çözer; bu yüzden allowedHosts'u açık adlarla doldur, proxy header'larına yalnız güvenilir bir proxy arkasında güven ve API adresini Host'tan türetme. Yerel bir SSR sunucusunda SSRF'yi, 2026 advisory'lerini, TransferState'e sızan alanları, SSR'da nonce'lu CSP'yi ve bundle ile source map'e giren secret'ları gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (@angular/ssr 22.2.6), Express 5.3, Node.js 24 ve Chrome 156 (headless) ile denenmiştir.
Önce Tahmin Et
Bookstore'un kitap listesi artık sunucuda render ediliyor. Sayfadaki component kitapları HttpClient ile /api/books adresinden, yani göreli bir URL'den (Uniform Resource Locator) çekiyor. Load balancer'dan geçen trafikte host adını doğrulamak can sıkıcı geldiği için biri allowedHosts'a * yazmış. Şimdi biri SSR (Server-Side Rendering) sunucusuna Host: 127.0.0.1:19453 header'ıyla bir istek atıyor; 19453 portunda da dışarıya açık olmaması gereken bir iç servis çalışıyor. Sunucu sayfayı render ederken /api/books'u nereden ister? Cevap sayfada ne olarak görünür?
Bu yazıda bunu ve SSR'a özgü üç riski daha (TransferState'e giren veri, SSR'da CSP (Content Security Policy) ve bundle'a sızan secret'lar) yerel bir Bookstore SSR sunucusunda deniyoruz. Bütün istekler kendi makinemdeki kendi süreçlerime gitti; gösterilen her satır bu koşulardan.
SSR'da Sunucu da Bir İstemci
Laboratuvar, ng new --ssr ile oluşturulmuş bir Angular 22 projesi. Kitap listesi şu component'te:
// 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(),
],
Tarayıcıda /api/books sayfanın origin'ine göre çözülür; burada sorun yok. Sunucuda ise bir "sayfanın origin'i" yok. Angular bu boşluğu gelen isteğin URL'iyle doldurur: platform-server'daki bir interceptor göreli URL'leri, render edilen isteğin protokol, host ve port'una göre mutlak hâle getirir. Host'u da istemci gönderir. Yani SSR sunucusunun HTTP (Hypertext Transfer Protocol) isteklerinin hedefini, doğrulanmadığı sürece isteği atan kişi seçer. Bu, SSRF'nin (Server-Side Request Forgery) tanımı: saldırgan sunucuna, kendi erişemediği bir adrese istek attırır.
Bunu görmek için 19453 portuna küçük bir "iç servis" koydum ve SSR sunucusunu allowedHosts için * ile başlattım:
// internal.mjs: dışarıya açık olmaması gereken bir iç servis rolünde
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 uyarıyı açılışta yazdı ama sunucuyu çalıştırdı. İstekteki Host header'ı 127.0.0.1:19453 olduğu için SSR'daki HttpClient çağrısı iç servise gitti ve iç servisin cevabı HTML'in (HyperText Markup Language) içinde geri döndü. Gerçek bir ortamda 19453'ün yerinde bir admin paneli, bir veritabanı yönetim arayüzü ya da bulut sağlayıcının metadata servisi (169.254.169.254) olabilir; SSR sunucusu bunlara erişebildiği ağda çalışıyorsa saldırgan da dolaylı olarak erişir. Kullanıcının isteğindeki cookie ya da Authorization header'ını sunucu tarafında API (Application Programming Interface) çağrısına ekleyen bir interceptor varsa, bu bilgiler de saldırganın seçtiği adrese gider.
allowedHosts: Angular 22'de Varsayılan Kapalı
Aynı uygulamayı hiçbir ayar yapmadan, Angular CLI'ın (Command-Line Interface) ürettiği server.ts ile başlattım:
$ 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.
# sunucu log'u
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
Angular 22'de allowedHosts boşsa SSR sunucusu localhost dahil hiçbir host'a cevap vermiyor ve 400 dönüyor. Production'a ilk deploy'da sayfanın açılmaması can sıkıcı ama doğru varsayılan: fail closed. Listeyi üç yoldan verebilirsin. Build sırasında angular.json'dan:
"build": {
"builder": "@angular/build:application",
"options": {
"security": {
"allowedHosts": ["shop.bookstore.test", "*.bookstore.test", "localhost"]
}
}
}
ya da çalışma zamanında NG_ALLOWED_HOSTS="shop.bookstore.test,localhost" ortam değişkeniyle veya new AngularNodeAppEngine({ allowedHosts: [...] }) ile. Yukarıdaki ayarla altı farklı Host header'ı denedim:
$ 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
İki satıra dikkat et. admin.bookstore.test'in 200 dönmesi, wildcard'ın bedeli: *.bookstore.test bütün subdomain'leri kabul eder. İç ağda admin.bookstore.test gibi bir ad çözülebiliyorsa, wildcard'ı kaldırıp yalnız gerçekten kullandığın adları yaz. bookstore.test.evil.test'in 400 dönmesi ise kontrolün sondan eşleştirdiğini gösteriyor; kaynak kodda wildcard, hostname.endsWith('.bookstore.test') olarak uygulanıyor.
Load balancer arkasında gerçek host çoğu zaman X-Forwarded-Host'ta gelir. Angular 22 bu header'lara varsayılan olarak güvenmiyor:
$ curl -s -i -H "X-Forwarded-Host: evil.test" http://localhost:19450/ | head -1
HTTP/1.1 200 OK
# sunucu log'u
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
Header istekten silindi, sayfa normal Host'la render edildi ve log'a bir uyarı düştü. Proxy header'larını yalnız bu header'ları istemciden gelen değerle değil kendi değeriyle ezen bir reverse proxy'nin arkasındaysan aç:
// yalnız bu header'ları ezip yeniden yazan bir reverse proxy'nin arkasında
const angularApp = new AngularNodeAppEngine({
trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],
});
Bu kontrollerin bugünkü hâli 2026 boyunca yayınlanan bir dizi advisory'nin sonucu ve hangi yamaların sende olduğunu bilmen gerekiyor. 23 Şubat 2026'da yayınlanan CVE (Common Vulnerabilities and Exposures) kaydı CVE-2026-27739, GitHub'daki GHSA (GitHub Security Advisory) kimliğiyle GHSA-x288-3778-4hhx, kritik seviyedeydi: @angular/ssr, Host ve X-Forwarded-* header'larını doğrulamadan uygulamanın origin'ini kuruyordu. Düzeltme 21.1.5, 20.3.17 ve 19.2.21'de geldi. Mayıs 2026'da @angular/platform-server için iki advisory daha çıktı: CVE-2026-46417 (absolute-form bir istek URL'iyle hostname'in ele geçirilmesi; 21.2.13, 20.3.21, 19.2.22'de düzeltildi) ve CVE-2026-50168. Sonuncusu, allowlist kontrolünü atlatan bir URL parser farkı: WHATWG (Web Hypertext Application Technology Working Group) URL parser'ı http://evil.com:80:80/path gibi bir URL'i geçersiz sayıp kontrolü atlıyor, sunucu tarafındaki DOM'u (Document Object Model) kuran Domino ise aynı URL'i kabul edip origin'i evil.com olarak çözüyordu. Yamalı sürümler 22.0.0-rc.2, 21.2.15, 20.3.22 ve 19.2.23; 18.x için resmi yama yok. Angular 22.0.0 ile de host doğrulaması başarısız olduğunda CSR'a (Client-Side Rendering) geri düşme davranışı kaldırıldı ve 400 dönülmeye başlandı. Kendi projende npm ls @angular/ssr @angular/platform-server çıktısını bu sürümlerle karşılaştır.
İkinci Kat: API Adresini Host'tan Türetme
allowedHosts'u doğru kurduktan sonra bile göreli URL'ler bir sorun daha çıkarıyor. Host'u shop.bookstore.test olan geçerli bir istek attım:
$ 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>
# sunucu log'u
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
...
SSR sunucusu, API'yi kendi public adı üzerinden çağırmaya çalıştı. Lab'da bu ad sunucunun bulunduğu makinede çözülmediği için DNS (Domain Name System) hatası aldık; gerçek bir ortamda istek internete çıkıp CDN (Content Delivery Network) ve load balancer üzerinden geri dönerdi. İki durumda da sunucunun hangi adrese istek atacağını isteğin içeriği belirliyor. Daha sağlam yol, sunucu tarafında API adresini konfigürasyondan almak ve Host header'ını bu karardan tamamen çıkarmak:
// 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');
// Yalnız sunucuda: göreli /api çağrıları Host header'ına değil, sabit bir iç origin'e gider
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);
Interceptor yalnız app.config.server.ts'te kayıtlı, tarayıcı bundle'ına girmiyor. Şimdi en kötü ayarla, * ile, aynı saldırıyı tekrar denedim:
$ 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>
# sunucu log'u: çağrı sabit origin'e gitti, internal.log'a yeni satır düşmedi
[api] GET /api/books host=127.0.0.1:19450
Host header'ı iç servisi gösterse de çağrı sabit origin'e gitti ve sayfada gerçek kitaplar var. Bu bir allowlist'in yerini tutmaz, yalnız ikinci bir kat. Bedelini de ölçtüm: transfer cache'in key'i artık mutlak iç adres olduğu için tarayıcıdaki göreli istekle eşleşmiyor ve hydration'dan sonra istek bir kez daha atılıyor:
# headless Chrome, SSR sayfası yüklendikten sonra /api/ istekleri
# interceptor yokken
[status] 200
[eval] 2
# interceptor varken
[request] GET http://localhost:19450/api/books
[status] 200
[eval] 2
Bunu düzeltmek için Angular'ın HTTP_TRANSFER_CACHE_ORIGIN_MAP token'ı var; iç origin'i tarayıcının kullandığı public origin'e eşler. Ancak bu token yalnız bir origin'i başka bir origin'e eşliyor, boş string'e eşleme yaptığımda kaynak kodda if (!mappedOrigin) return url; satırı yüzünden hiçbir şey değişmedi. Tarayıcı tarafında da mutlak bir API adresi kullanıyorsan (ör. ayrı bir api.bookstore.test) bu token tam bu iş için tasarlanmış.
TransferState: HTML'deki JSON Herkese Açık
SSR'ın bir sonraki tuzağı performans özelliğinin içinde. provideClientHydration(), sunucuda yapılan HttpClient isteklerinin cevaplarını HTML'e gömer, böylece tarayıcı aynı isteği tekrar atmaz. API'nin ilk hâli kitapları tedarik bilgileriyle birlikte dönüyordu, sayfa ise yalnız başlık ve fiyat gösteriyordu:
// 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>
Sayfada görünmeyen supplierCost ve supplierEmail, sayfanın kaynağında JSON (JavaScript Object Notation) olarak duruyor; "kaynağı görüntüle" diyen herkes, arama motorlarının botları ve sayfayı cache'leyen her CDN dahil. Component'in bir alanı göstermemesi, o alanın istemciye gitmediği anlamına gelmez. Bu SSR'dan önce de doğruydu (tarayıcıdaki HttpClient cevabı da Network sekmesinde görünür), ama SSR veriyi HTML'in kendisine yazdığı için sızıntı artık bir sayfa cache'inde de kalıcı. Düzeltme API tarafında: sayfanın ihtiyacı olan alanları dönen ayrı bir cevap modeli.
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}`);
// sunucudan yalnız sayfanın gösterdiği alanlar çıkar
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>
Tedarik alanları gitti. Bu çıktıda bir şey daha var: "u" alanında bir önceki bölümdeki interceptor'ın kullandığı iç adres, http://127.0.0.1:19450/api/books, sayfaya yazılmış. İç adres gizli bir bilgi değildir, ama iç ağın topolojisini dışarıya anlatmak da istemezsin; origin map'i kullanmak için bir sebep daha. Angular'ın transfer cache'i varsayılan olarak Authorization, Proxy-Authorization ya da Cookie header'ı taşıyan istekleri ve withCredentials ile atılanları cache'lemiyor. includeRequestsWithAuthHeaders: true yazmadan önce, kullanıcıya özel bir cevabın paylaşılan bir sayfa cache'ine girip girmeyeceğini düşün.
SSR'da CSP: autoCsp Yok, Nonce Var
Serinin CSP yazısında gördüğün autoCsp, inline script'lerin hash'ini build sırasında hesaplıyor; angular.dev SSR ile kullanılamayacağını açıkça yazıyor. SSR'da doğru araç her istekte yeni üretilen bir nonce. İlk denemede yalnız CSP_NONCE provider'ını ekledim ve header'ı gönderdim. Headless Chrome iki engelleme yazdı:
[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, Angular'ın runtime'da eklediği inline script ve style'lara nonce veriyor; ama index.html'den gelen <script src="main-...js"> etiketi ve kritik CSS'i (Cascading Style Sheets) yükleyen küçük inline script bundan habersiz. Bunlar için Angular'ın build adımı bir yol sunuyor: root element'te ngCspNonce attribute'u varsa değerini bütün script, style ve link etiketlerine kopyalıyor. Değer olarak bir placeholder yazdım:
<!-- 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">
Sunucu her istekte yeni bir nonce üretiyor, Angular'a REQUEST_CONTEXT üzerinden veriyor (yukarıdaki app.config.server.ts'teki CSP_NONCE factory'si) ve HTML'deki placeholder'ı değiştiriyor:
app.use(async (req, res, next) => {
try {
const cspNonce = randomBytes(16).toString('base64');
const response = await angularApp.handle(req, { cspNonce });
if (!response) {
return next();
}
// placeholder'ı yalnız bir başlangıç etiketinin içinde değiştir; text node'larda ham '<' bulunmaz
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'
Her istekte farklı bir nonce var ve script etiketlerinde doğru değer duruyor; headless Chrome'da sayfa hiçbir CSP ihlali olmadan hydrate oldu. Replace'in neden bu kadar dar olduğunu bir hatadan öğrendim. İlk sürümde yalnız attribute kalıbını arıyordum:
// ilk sürüm
const html = (await response.text()).replace(
/(nonce|ngcspnonce)="NG_CSP_NONCE"/gi,
(_, attr: string) => `${attr}="${cspNonce}"`,
);
Bunun güvenli olduğunu düşünüyordum, çünkü kullanıcı verisinin " karakteriyle bir attribute üretemeyeceğini varsaymıştım. Kontrol etmek için bir kitabın başlığını Refactoring nonce="NG_CSP_NONCE" yaptım:
# kitap başlığı: 'Refactoring nonce="NG_CSP_NONCE"'
# ilk sürüm
<li>Refactoring nonce="ChuPh4uQJSXr8ruJjcrh0Q==" (980 TRY)</li>
# başlangıç etiketine bağlı sürüm
<li>Refactoring nonce="NG_CSP_NONCE" (980 TRY)</li>
HTML serialization'ı text node'larda &, < ve >'yi escape ediyor ama "'yi olduğu gibi bırakıyor. İlk sürüm bu yüzden o isteğin nonce'unu sayfadaki metnin içine yazdı. Tek başına bir script çalıştırmıyor, ama nonce'un gizli kalması CSP'nin dayandığı varsayım; başka bir markup injection'la birleştiğinde işe yarar hâle gelir. Son sürüm placeholder'ı yalnız bir başlangıç etiketinin içinde arıyor; text node ham bir < içeremediği için kullanıcı verisi bu kalıba giremiyor. Bir şey daha: nonce'lu HTML'i CDN'de ya da SSR'ın kendi cache'inde saklama; cache'lenmiş bir sayfa herkese aynı nonce'u verir.
Bundle'a Giren Her Şey Herkese Açık
Son tuzak SSR'a özgü değil ama SSR projelerinde daha sık görülüyor, çünkü ekip "bu kod sunucuda da çalışıyor" diye düşünüyor. Harita servisi için bir key'i environment dosyasına koydum ve BookList'te readonly mapsKey = environment.mapsApiKey; diye kullandım:
// 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
Production build key'i tarayıcı bundle'ına olduğu gibi yazdı; yorum satırı ise minification ile gitti. Bir değer tarayıcıya giden bir dosyadan import ediliyorsa public'tir, adı environment olsa da. SSR bu kuralı değiştirmez: component kodu hem sunucu hem tarayıcı bundle'ına girer. Gerçek bir secret yalnız server.ts gibi sadece sunucuda çalışan kodda process.env'den okunmalı; tarayıcının bir üçüncü parti API'yi çağırması gerekiyorsa çağrı BFF (Backend for Frontend) üzerinden yapılmalı ve key orada kalmalı.
Source map'ler ise bir adım daha ileri gidiyor:
$ 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
Map dosyası 2 MB'tan büyük ve içinde TypeScript kaynağının kendisi var: minification'ın sildiği yorum, TODO notu, dosya yapısı. SSR sunucusu da express.static ile bu dosyayı herkese servis etti. Hata izleme aracı için source map'e ihtiyacın varsa hidden source map üret ve dosyaları servis etme:
// angular.json, configurations.production
"sourceMap": { "scripts": true, "styles": false, "hidden": true }
// src/server.ts, express.static'ten önce
// hidden source map'ler hata izleme aracı için dist'te kalır ama asla servis edilmez
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 yalnız bundle'ın sonundaki sourceMappingURL yorumunu kaldırıyor; dosya hâlâ dist'te ve adı bundle'ın adına .map eklenmiş hâli, yani tahmin etmesi kolay. Bu yüzden ya deploy adımında map dosyalarını hata izleme aracına yükleyip silmelisin ya da yukarıdaki gibi sunucuda engellemelisin. Sunucu bundle'ı (dist/.../server) ise express.static'in kökünde olmadığı için dışarıdan istenemedi; denediğim /server.mjs isteği 404 döndü.
Üretimde Dikkat
SSR sunucusunu kendi ağ bölümüne koy ve oradan çıkışı kısıtla: yalnız API'ye ve gerçekten gereken servislere erişebilsin, bulut metadata adresine ve yönetim arayüzlerine erişemesin. allowedHosts ve interceptor uygulamanın içindeki kontroller; ağ kuralı ise kod hatası olduğunda da çalışan katman.
400 cevaplarını izle. Header "host" with value ... is not allowed satırı, deploy sonrası yanlış yapılandırmanın da, birinin Host header'ıyla oynamasının da ilk işareti. Bu satırlar için bir alarm kur ve log'a giren header değerlerinin uzunluğunu sınırla.
Angular sürümünü güvenlik yamaları açısından takip et. 2026'daki üç SSRF advisory'si de desteklenen major sürümlerde patch sürümlerle kapatıldı; ng update'i yalnız major sürümlerde değil, advisory yayınlandığında da çalıştır. CI'da (Continuous Integration) çalışan npm audit bu kayıtları GitHub Advisory Database'den raporlar, ama yalnız lock dosyasındaki sürümü kontrol eder; container image'ını yeniden build etmeyi unutma.
Sık Düşülen Tuzaklar
Birincisi, 400'leri susturmak için allowedHosts'a * yazmak. Lab'daki ilk deneme bunun sonucuydu ve Angular'ın kendi uyarısı da tam olarak bunu söylüyor: * yalnız Host ve X-Forwarded-Host'u başka bir katman doğruluyorsa kabul edilebilir.
İkincisi, trustProxyHeaders: true'yu, header'ları ezmeyen bir proxy'nin arkasında açmak. Bu durumda istemcinin gönderdiği X-Forwarded-Host doğrudan güvenilir hâle gelir.
Üçüncüsü, SSR sunucusunun iç ağa geniş erişimi olan bir ortamda (ör. veritabanıyla aynı subnet'te) çalışması. SSRF'nin etkisini belirleyen şey, sunucunun ağdan neye erişebildiği.
Dördüncüsü, "bu alanı göstermiyoruz" diye API cevabını daraltmamak; TransferState onu sayfaya yazar. Beşincisi, environment dosyasına secret koymak ve production'da source map'leri servis etmek.
Küçük Bir Deneme
★☆☆ NG_ALLOWED_HOSTS ortam değişkenini ve angular.json'daki listeyi birlikte kullan; iki listenin birleşimi mi yoksa yalnız biri mi geçerli oluyor, curl ile dene.
★★☆ Bookstore'u tarayıcıda http://api.bookstore.test:19454 gibi mutlak bir API adresi kullanacak şekilde değiştir ve sunucu tarafında HTTP_TRANSFER_CACHE_ORIGIN_MAP ile iç origin'i bu adrese eşle. Hydration'dan sonraki ikinci isteğin kaybolduğunu ve ng-state'teki "u" değerinin ne olduğunu kontrol et.
★★★ server.ts'e, render'dan önce çalışan ve req.hostname'i kendi allowlist'inle karşılaştıran bir Express middleware'i ekle. Angular'ın kendi kontrolünü kapatmadan, iki katmanın aynı istekleri reddettiğini bir test script'iyle doğrula.
Bir sonraki yazıda uygulamanın kendisinden onun bağımlılıklarına geçiyoruz: npm supply chain'i lifecycle script'lerini sınırlayarak, yeni sürümlere bekleme süresi koyarak ve provenance ile trusted publishing'i kullanarak nasıl sertleştireceğimizi konuşacağız.