Mert Özen Her Satırda Daha İleri
Güvenlik Frontend Güvenliği — #06

Üçüncü Parti Script'ler, iframe'ler ve postMessage: sandbox, frame-ancestors, Permissions-Policy ve Güvenli Mesajlaşma

Mert Özen 8 Kas 2026 16 dk 2 görüntülenme
Üçüncü Parti Script'ler, iframe'ler ve postMessage: sandbox, frame-ancestors, Permissions-Policy ve Güvenli Mesajlaşma

Sayfana script etiketiyle eklenen üçüncü parti kod senin origin'inde ve senin yetkinle çalışır; onu kendi origin'indeki sandbox'lı bir iframe'e taşı, sayfanı frame-ancestors header'ıyla çerçevelenmeye kapat, kullanmadığın API'leri Permissions-Policy ile kapat ve postMessage'da origin, kaynak pencere ve şemayı doğrula. Hepsini yerel üç origin'li bir lab'da headless Chrome ve Vitest ile gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2, Vitest 5.1, Node.js 24 ve Chrome 156 (headless) ile denenmiştir.

Önce Tahmin Et

Bookstore'un ödeme sayfasına bir ödeme sağlayıcısının widget'ını ekliyorsun. Sağlayıcının dokümanı iki yol sunuyor: sayfaya tek satırlık bir <script src="https://..."> eklemek ya da ödeme formunu sağlayıcının kendi origin'inden gelen bir <iframe> içinde göstermek. İkisi de ekranda aynı görünüyor. Sence hangisinde sağlayıcının kodu, senin sayfandaki e-posta alanını, localStorage'daki sepeti ve kullanıcının adına atılacak istekleri görebilir? Peki iframe'i seçtin ve sağlayıcı ödeme sonucunu sana postMessage ile bildiriyor; aynı anda açık başka bir sekme de sana "ödeme onaylandı" mesajı gönderebilir mi?

Bu yazıda bu soruları yerel bir laboratuvarda cevaplıyoruz. Üç origin kurdum: shop.bookstore.test:19455 (Bookstore'un Angular uygulaması), pay.widget.test:19456 (üçüncü parti ödeme widget'ı rolündeki kendi küçük sunucum) ve evil.test:19457 (kötü niyetli site rolündeki yine kendi sayfam). Chrome'u --host-resolver-rules ile bu üç adı 127.0.0.1'e yönlendirerek headless çalıştırdım; gösterilen bütün konsol satırları bu koşulardan. İnternetteki hiçbir servise dokunulmadı.

Üçüncü Parti Script Senin Yetkinle Çalışır

İlk deneme, sağlayıcının script'ini doğrudan sayfaya koymak:

<!-- demo/script.html, shop.bookstore.test:19455 -->
<input id="email" value="ada@example.com">
<script src="/demo/cart.js"></script>
<script src="http://pay.widget.test:19456/widget.js"></script>
console.log(`widget.js origin: ${location.origin}`);
console.log(`widget.js reads #email: ${document.querySelector('#email')?.value}`);
console.log(`widget.js reads localStorage: ${localStorage.getItem('cart')}`);
[console.log] widget.js origin: http://shop.bookstore.test:19455
[console.log] widget.js reads #email: ada@example.com
[console.log] widget.js reads localStorage: [{"bookId":1,"quantity":2}]

Script'in dosyası pay.widget.test'ten geldi ama çalıştığı origin shop.bookstore.test. Tarayıcı için script'in nereden indirildiği değil, hangi sayfaya yüklendiği önemli: bir <script> etiketiyle eklenen kod senin kodunla aynı yetkiye sahip. DOM'u (Document Object Model) okur ve değiştirir, form alanlarını dinler, localStorage'a erişir, HttpOnly olmayan cookie'leri görür ve senin origin'inden kullanıcının session'ıyla istek atar. Same-Origin Policy burada seni korumaz, çünkü ortada iki origin yok; script senin origin'inin içinde.

Web skimming (ödeme sayfasına sızan bir script'in kart ve kişi bilgilerini kopyalaması) bu gerçeğin kötüye kullanılmış hâli. Saldırgan senin sunucuna hiç girmez; sağlayıcının CDN'indeki (Content Delivery Network) dosyayı, bir npm bağımlılığını ya da tag manager'daki bir etiketi değiştirmesi yeter. XSS (Cross-Site Scripting) ile sonucu aynı, kapısı farklı: XSS'te saldırganın kodu sayfana sızar, burada ise sen onu kendi elinle davet etmişsindir.

OWASP (Open Worldwide Application Security Project) serisinin "Software Supply Chain Failures" yazısında SRI'yı (Subresource Integrity) konuştuk: integrity attribute'u dosyanın hash'ini sabitler ve dosya değişirse tarayıcı script'i çalıştırmaz. Sınırını görmek için sağlayıcının "her zaman son sürüm" adresini v1'in hash'iyle bağladım, sonra sunucudaki dosyayı v2 ile değiştirdim:

<script src="http://pay.widget.test:19456/widget-latest.js" integrity="sha384-wKZy9K02m+o2XAj0dbZ5wRCf9LnX9QMD/5ixBxpw0DtpuBhlygwe40cuvEcc0GeI" crossorigin="anonymous"></script>
[console.error] Failed to find a valid digest in the 'integrity' attribute for resource 'http://pay.widget.test:19456/widget-latest.js' with computed SHA-384 integrity '6AX6RUtFfC1INdNEGOrgB7IH6g5Kpqwoq5ytCU+0UrUEQlR8x5OD0OoYBJzPvBcQ'. The resource has been blocked.

SRI işini yaptı ve değişen dosyayı engelledi. Sorun şu: sürümsüz bir adresin içeriği sağlayıcı her yayın yaptığında meşru olarak değişir. Hash'i sabitlediğin gün ödeme sayfan, sağlayıcının bir sonraki güncellemesinde sessizce bozulur. Yani SRI yalnız sürümü URL'de (Uniform Resource Locator) sabit olan dosyalarda işe yarar; "latest" adresleri ve kendi içinden başka script yükleyen loader'lar için koruma sağlamaz. Bu sınırdan çıkmanın yolu, üçüncü parti kodu kendi origin'inden çıkarmak.

iframe ile Sınır Çizmek: sandbox

Üçüncü parti kodu kendi origin'inde bir iframe içinde çalıştırınca Same-Origin Policy yeniden devreye girer: iframe'deki kod senin DOM'una, storage'ına ve cookie'lerine erişemez. sandbox attribute'u bunun bir adım ötesi; iframe'deki belgeyi bir dizi kısıtla başlatır ve yalnız açıkça yazdığın allow-* izinlerini geri verir. Ne kadar kısıtlayıcı olduğunu görmek için aynı origin'den gelen bir test sayfasını sandbox="allow-scripts" ile yükledim:

<iframe sandbox="allow-scripts" src="/demo/frame-probe.html"></iframe>
<iframe sandbox="allow-scripts allow-same-origin" src="/demo/frame-escape.html"></iframe>
const results = [];
const attempt = (name, action) => {
  try {
    results.push(`${name}=${action()}`);
  } catch (error) {
    results.push(`${name}=${error.name}`);
  }
};
attempt('origin', () => self.origin);
attempt('cookie', () => document.cookie);
attempt('localStorage', () => localStorage.length);
attempt('parent.document', () => parent.document.title);
attempt('window.open', () => (window.open('/demo/script.html') === null ? 'null' : 'opened'));
attempt('alert', () => alert('hi'));
attempt('top.location', () => {
  top.location.href = 'http://evil.test:19457/';
  return 'set';
});
console.log(`sandboxed frame: ${results.join(' | ')}`);
[console.error] Blocked opening 'http://shop.bookstore.test:19455/demo/script.html' in a new window because the request was made in a sandboxed frame whose 'allow-popups' permission is not set.
[console.error] Ignored call to 'alert()'. The document is sandboxed, and the 'allow-modals' keyword is not set.
[console.error] Unsafe attempt to initiate navigation for frame with origin 'http://shop.bookstore.test:19455' from frame with URL 'http://shop.bookstore.test:19455/demo/frame-probe.html'. The frame attempting navigation of the top-level window is sandboxed, but the flag of 'allow-top-navigation' or 'allow-top-navigation-by-user-activation' is not set.
[console.log] sandboxed frame: origin=null | cookie=SecurityError | localStorage=SecurityError | parent.document=SecurityError | window.open=null | alert=undefined | top.location=SecurityError

Sayfa aynı sunucudan gelmesine rağmen origin'i null oldu. allow-same-origin verilmeyen bir sandbox belgeyi opaque (hiçbir origin'le eşleşmeyen) bir origin'e koyar; cookie, localStorage ve parent'ın DOM'u SecurityError verir. allow-popups olmadan yeni pencere açılamaz, allow-modals olmadan alert yok sayılır, allow-top-navigation olmadan üst pencere başka bir siteye yönlendirilemez. Son madde reklam ve widget'larda önemli: kullanıcıyı ödeme sayfasından sahte bir sayfaya taşımanın en kısa yolu top.location'a yazmak.

İkinci iframe ise sık yapılan bir hatayı gösteriyor:

console.log(`allow-scripts + allow-same-origin: parent title=${parent.document.title}, frameElement.sandbox="${frameElement.getAttribute('sandbox')}"`);
frameElement.removeAttribute('sandbox');
console.log(`after removeAttribute: sandbox=${frameElement.getAttribute('sandbox')}`);
[console.warning] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing.
[console.log] allow-scripts + allow-same-origin: parent title=Bookstore, frameElement.sandbox="allow-scripts allow-same-origin"
[console.log] after removeAttribute: sandbox=null

Chrome uyarıyı kendisi yazdı. allow-scripts ile allow-same-origin birlikte verilirse ve içerik senin origin'inden geliyorsa, iframe'deki script parent'ın DOM'una erişip kendi iframe element'inden sandbox'ı kaldırabilir. Bu ikiliyi yalnız içerik başka bir origin'den geliyorsa kullan; bu durumda Same-Origin Policy parent'a erişimi zaten engeller ve allow-same-origin yalnız iframe'in kendi origin'inin cookie ve storage'ını kullanmasına izin verir. Ödeme widget'ları genelde buna ihtiyaç duyar.

Angular tarafında bir kural daha var. iframe'in sandbox, allow gibi güvenlikle ilgili attribute'larını binding ile vermeye çalıştım, src'yi de bir property'den bağladım:

@Component({
  selector: 'app-dynamic-frame',
  template: `<iframe src="about:blank" [attr.sandbox]="sandbox"></iframe>`,
})
class DynamicFrame {
  sandbox = 'allow-scripts';
}

@Component({
  selector: 'app-dynamic-src',
  template: `<iframe [src]="url"></iframe>`,
})
class DynamicSrc {
  url = 'http://pay.widget.test:19456/frame.html';
}
NG0910: Angular has detected that the `sandbox` was applied as a binding to the <iframe> element (used in the '_DynamicFrame' component template). For security reasons, the `sandbox` can be set on the <iframe> element as a static attribute only.
To fix this, switch the `sandbox` binding to a static attribute in a template or in host bindings section. Find more at https://v22.angular.dev/errors/NG0910
NG0904: unsafe value used in a resource URL context (see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss). Find more at https://v22.angular.dev/errors/NG0904

Unit test içinde render ettiğimde Angular ikisini de reddetti. sandbox yalnız statik attribute olarak yazılabiliyor (NG0910), böylece bir değişkenin içeriği iframe'in kısıtlarını gevşetemiyor. iframe src'si ise resource URL context'i; dinamik bir değer için Angular bypassSecurityTrustResourceUrl ister. Bu bypass'ı kullanıcıdan gelen bir değerle çağırmak, serinin ikinci yazısındaki DOM XSS sink envanterine eklenecek bir satırdır. Sabit bir sağlayıcı adresini template'e statik yazmak en temiz yol.

frame-ancestors ile Çerçevelenmeye Karşı

Şimdi tersine bakalım: senin sayfan başkasının iframe'ine girerse ne olur? Clickjacking'de saldırgan, Bookstore'un hesap sayfasını kendi sitesinde görünmez bir iframe'e koyar ve kullanıcıyı başka bir şeye tıkladığını sanarken "yorumlarımı sil" butonuna tıklatır. Kullanıcının session cookie'si gider, çünkü istek gerçekten Bookstore'un sayfasından çıkıyor. Savunma, sayfanın kimler tarafından çerçevelenebileceğini response header'ında söylemek:

# demo/account.html: HTTP response header'ı
Content-Security-Policy: frame-ancestors 'self'

# demo/account-meta.html: aynı kural meta element'i olarak
<meta http-equiv="Content-Security-Policy" content="frame-ancestors 'self'">
<!-- evil.test:19457/framer.html -->
<iframe src="http://shop.bookstore.test:19455/demo/account.html"></iframe>
<iframe src="http://shop.bookstore.test:19455/demo/account-meta.html"></iframe>
[console.error] (evil.test:19457) Framing 'http://shop.bookstore.test:19455/' violates the following Content Security Policy directive: "frame-ancestors 'self'". The request has been blocked.
[console.error] (shop.bookstore.test:19455) The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.
[frame] chrome-error://chromewebdata/
[frame] http://shop.bookstore.test:19455/demo/account-meta.html

Header ile gelen kural çalıştı: Chrome çerçevelemeyi engelledi ve iframe'in yerine hata sayfası (chrome-error://chromewebdata/) koydu. Meta element'iyle verilen aynı kural ise yok sayıldı ve sayfa evil.test'in içinde açıldı. CSP (Content Security Policy) Level 3 spesifikasyonu (CSP3) bunu açıkça söylüyor: frame-ancestors, report-uri ve sandbox meta ile verilemez. Statik hosting'de index.html'e meta CSP koyan ekipler bu yüzden clickjacking korumasını sandıkları hâlde alamaz; bu direktif mutlaka sunucu, reverse proxy ya da CDN katmanından header olarak gönderilmeli.

Eski X-Frame-Options: DENY header'ı da aynı işi daha kaba biçimde yapar ve yalnız DENY ile SAMEORIGIN değerlerini tanır. İkisi birlikte gönderildiğinde CSP3'e göre frame-ancestors geçerli olur; eski istemciler için ikisini birlikte göndermek zararsız. Header'ı ASP.NET Core'dan üretmek, ilerideki "ASP.NET Core Güvenliği" serisinin CSP yazısının konusu.

Permissions-Policy ile Güçlü API'leri Kapatmak

Bookstore'un kamera, mikrofon ya da konuma ihtiyacı yok. O hâlde sayfaya yüklenen hiçbir kodun, ister kendi kodun ister bir widget olsun, bunları istemesine gerek yok. Permissions-Policy header'ı bir özelliği belge ve içindeki bütün iframe'ler için kapatır. Bu deneyi localhost üzerinden yaptım, çünkü konum ve kamera API'leri (Application Programming Interface) secure context ister: sayfa HTTPS (Hypertext Transfer Protocol Secure) ile ya da localhost'tan açılmalı. Düz HTTP (şifrelemesiz Hypertext Transfer Protocol) ile açılan shop.bookstore.test'te navigator.mediaDevices hiç tanımlı değildi.

# demo/geo.html response header'ı
Permissions-Policy: geolocation=(), camera=()
console.log(`allowsFeature: geolocation=${document.featurePolicy?.allowsFeature('geolocation')} camera=${document.featurePolicy?.allowsFeature('camera')}`);
navigator.geolocation.getCurrentPosition(
  () => console.log('geolocation: allowed'),
  (error) => console.log(`geolocation: error code=${error.code} message=${error.message}`),
);
navigator.mediaDevices
  .getUserMedia({ video: true })
  .then(() => console.log('camera: allowed'))
  .catch((error) => console.log(`camera: ${error.name} ${error.message}`));
[console.log] allowsFeature: geolocation=false camera=false
[console.error] Permissions policy violation: Geolocation access has been blocked because of a permissions policy applied to the current document. See https://crbug.com/414348233 for more details.
[console.error] Permissions policy violation: camera is not allowed in this document.
[console.log] geolocation: error code=1 message=Geolocation has been disabled in this document by permissions policy.
[console.log] camera: NotAllowedError Permission denied

Aynı sayfayı header olmadan açtığımda sonuç farklıydı:

[console.log] allowsFeature: geolocation=true camera=true
[console.log] geolocation: error code=1 message=User denied Geolocation
[console.log] camera: NotAllowedError Permission denied

Headless Chrome izin isteğini kendiliğinden reddettiği için her iki koşuda da hata aldık; fark mesajda. Policy yokken hata kullanıcının kararından geliyor ("User denied"); gerçek bir tarayıcıda kullanıcıya izin sorusu çıkardı. Policy varken soru hiç sorulmuyor ve API belgede kapalı. Bir widget kullanıcıyı "konumunu paylaş" diye ikna etmeye çalışsa bile tarayıcı izin penceresini açmaz.

Permissions-Policy iframe'lere izin dağıtmak için de kullanılır. Bookstore'un ödeme sayfasında son hâli şu header'lar:

Content-Security-Policy: default-src 'self'; script-src 'self'; frame-src http://pay.widget.test:19456; frame-ancestors 'self'; object-src 'none'; base-uri 'self'
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self "http://pay.widget.test:19456")

payment yalnız Bookstore'a ve ödeme origin'ine açık; iframe tarafında da allow="payment" yazılı. Ödeme iframe'inin içinden baktığımda payment=true geolocation=false gördüm (bir sonraki bölümün çıktısında). frame-src ise sayfaya yalnız ödeme origin'inden iframe eklenebileceğini söylüyor; bir reklam script'i başka bir adresten iframe açmaya çalışırsa CSP engeller.

postMessage: Origin, Kaynak ve Şema

iframe ile parent'ı ayırdık; şimdi konuşmaları gerekiyor. window.postMessage iki origin arasındaki tek kontrollü kanal, ama kontrol tamamen alıcıda. İnternette en sık gördüğüm hâli şu:

window.addEventListener('message', (event) => {
  // TEHLİKELİ: kimin gönderdiğine bakmadan state'e yazıyor
  this.lastResult.set(event.data);
});

Bu listener, sayfaya referansı olan herkesi dinler: Bookstore'u window.open ile açan bir site, sayfadaki herhangi bir iframe, hatta bir tarayıcı eklentisi. Bookstore için güvenli bir message service'i üç kontrol yapmalı: mesaj beklenen origin'den mi geldi, beklenen pencereden mi geldi ve içeriği beklenen şemaya uyuyor mu. Gönderirken de targetOrigin'e "*" yazılmamalı; yoksa iframe o sırada başka bir adrese yönlendirilmişse mesaj oraya gider.

import { Injectable, InjectionToken, inject, signal } from '@angular/core';

export const PAYMENT_ORIGIN = new InjectionToken<string>('PAYMENT_ORIGIN', {
  factory: () => 'http://pay.widget.test:19456',
});

export interface PaymentResult {
  type: 'payment-result';
  orderId: string;
  status: 'approved' | 'declined';
}

export function isPaymentResult(data: unknown): data is PaymentResult {
  if (typeof data !== 'object' || data === null) {
    return false;
  }
  const message = data as Record<string, unknown>;
  return (
    Object.keys(message).length === 3 &&
    message['type'] === 'payment-result' &&
    typeof message['orderId'] === 'string' &&
    /^ORD-\d{1,10}$/.test(message['orderId']) &&
    (message['status'] === 'approved' || message['status'] === 'declined')
  );
}

@Injectable({ providedIn: 'root' })
export class PaymentMessageService {
  private readonly paymentOrigin = inject(PAYMENT_ORIGIN);
  private frame: Window | null = null;
  readonly lastResult = signal<PaymentResult | null>(null);

  connect(frame: Window): () => void {
    this.frame = frame;
    const listener = (event: MessageEvent) => this.handle(event);
    window.addEventListener('message', listener);
    return () => window.removeEventListener('message', listener);
  }

  startPayment(orderId: string, amount: number): void {
    // targetOrigin asla "*" değil: bu mesajı yalnız ödeme origin'i okuyabilir
    this.frame?.postMessage({ type: 'start-payment', orderId, amount }, this.paymentOrigin);
  }

  handle(event: MessageEvent): void {
    if (event.origin !== this.paymentOrigin) {
      console.warn(`[payment] rejected: origin ${event.origin}`);
      return;
    }
    if (event.source !== this.frame) {
      console.warn('[payment] rejected: unknown source window');
      return;
    }
    if (!isPaymentResult(event.data)) {
      console.warn(`[payment] rejected: invalid message ${JSON.stringify(event.data)}`);
      return;
    }
    this.lastResult.set(event.data);
    console.info(`[payment] accepted: ${event.data.orderId} ${event.data.status}`);
  }
}

Origin karşılaştırması tam eşitlikle yapılıyor. startsWith ya da includes ile yapılan kontroller, http://pay.widget.test.evil.test gibi bir adı kabul eder. Şema kontrolü de alan sayısını sabitliyor ve orderId'yi bir regular expression ile sınırlıyor; bu değer daha sonra template'e ya da bir URL'ye girecekse sızmasını istemediğin karakterler ilk adımda dışarıda kalıyor. Component tarafı iframe'i statik attribute'larla kuruyor:

import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
import { PaymentMessageService } from './payment-message.service';

@Component({
  selector: 'app-checkout',
  template: `
    <h1>Checkout</h1>
    <iframe
      #paymentFrame
      title="Payment"
      src="http://pay.widget.test:19456/frame.html"
      sandbox="allow-scripts allow-forms allow-same-origin"
      allow="payment"
      (load)="onFrameLoad()"
    ></iframe>
    <p id="result">{{ payments.lastResult()?.status ?? 'waiting' }}</p>
  `,
})
export class Checkout {
  protected readonly payments = inject(PaymentMessageService);
  private readonly frame = viewChild.required<ElementRef<HTMLIFrameElement>>('paymentFrame');

  constructor() {
    const destroyRef = inject(DestroyRef);
    afterNextRender(() => {
      const disconnect = this.payments.connect(this.frame().nativeElement.contentWindow!);
      destroyRef.onDestroy(disconnect);
    });
  }

  onFrameLoad(): void {
    this.payments.startPayment('ORD-1042', 1250);
  }
}
const SHOP_ORIGIN = 'http://shop.bookstore.test:19455';
console.log(`widget frame: payment=${document.featurePolicy?.allowsFeature('payment')} geolocation=${document.featurePolicy?.allowsFeature('geolocation')}`);
window.addEventListener('message', (event) => {
  if (event.origin !== SHOP_ORIGIN || event.data?.type !== 'start-payment') {
    return;
  }
  console.log(`widget frame: start-payment ${event.data.orderId} ${event.data.amount}`);
  event.source.postMessage({ type: 'payment-result', orderId: event.data.orderId, status: 'approved' }, SHOP_ORIGIN);
});

Widget tarafı da aynı disiplinle yazıldı: yalnız Bookstore'un origin'inden gelen start-payment mesajını işliyor ve cevabı tam origin'e gönderiyor. Production build'i Bookstore'un header'larıyla açtığımda:

[console.log] (pay.widget.test:19456) widget frame: payment=true geolocation=false
[console.log] (pay.widget.test:19456) widget frame: start-payment ORD-1042 1250
[console.info] (shop.bookstore.test:19455) [payment] accepted: ORD-1042 approved
[eval] "approved"

Davranışı unit test'lerle sabitledim. ng test, Angular 22'de varsayılan olarak Vitest ile çalışıyor:

import { TestBed } from '@angular/core/testing';
import { PAYMENT_ORIGIN, PaymentMessageService } from './payment-message.service';

describe('PaymentMessageService', () => {
  const origin = 'http://pay.widget.test:19456';
  const frame = {} as Window;
  let service: PaymentMessageService;

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [{ provide: PAYMENT_ORIGIN, useValue: origin }] });
    service = TestBed.inject(PaymentMessageService);
    service.connect(frame);
  });

  function message(data: unknown, from = origin, source: Window = frame): MessageEvent {
    return new MessageEvent('message', { data, origin: from, source: source as MessageEventSource });
  }

  const valid = { type: 'payment-result', orderId: 'ORD-1042', status: 'approved' };

  it('accepts a valid result from the payment frame', () => {
    service.handle(message(valid));
    expect(service.lastResult()).toEqual(valid);
  });

  it('rejects a look-alike origin', () => {
    service.handle(message(valid, 'http://pay.widget.test.evil.test:19456'));
    expect(service.lastResult()).toBeNull();
  });

  it('rejects the right origin from another window', () => {
    service.handle(message(valid, origin, {} as Window));
    expect(service.lastResult()).toBeNull();
  });

  it('rejects extra fields and malformed ids', () => {
    service.handle(message({ ...valid, redirectUrl: 'javascript:alert(1)' }));
    service.handle(message({ ...valid, orderId: '<img src=x onerror=alert(1)>' }));
    expect(service.lastResult()).toBeNull();
  });
});
stderr | src/app/payment-message.service.spec.ts > PaymentMessageService > rejects a look-alike origin
[payment] rejected: origin http://pay.widget.test.evil.test:19456
stderr | src/app/payment-message.service.spec.ts > PaymentMessageService > rejects the right origin from another window
[payment] rejected: unknown source window
stderr | src/app/payment-message.service.spec.ts > PaymentMessageService > rejects extra fields and malformed ids
[payment] rejected: invalid message {"type":"payment-result","orderId":"ORD-1042","status":"approved","redirectUrl":"javascript:alert(1)"}
[payment] rejected: invalid message {"type":"payment-result","orderId":"<img src=x onerror=alert(1)>","status":"approved"}
 ✓ |bookstore-web| src/app/payment-message.service.spec.ts > PaymentMessageService > accepts a valid result from the payment frame 6ms
 ✓ |bookstore-web| src/app/payment-message.service.spec.ts > PaymentMessageService > rejects a look-alike origin 1ms
 ✓ |bookstore-web| src/app/payment-message.service.spec.ts > PaymentMessageService > rejects the right origin from another window 1ms
 ✓ |bookstore-web| src/app/payment-message.service.spec.ts > PaymentMessageService > rejects extra fields and malformed ids 1ms
 Test Files  1 passed (1)
      Tests  4 passed (4)

Son olarak gerçek bir tarayıcıda dışarıdan sahte mesaj denedim. evil.test'teki sayfa Bookstore'u yeni pencerede açıp bir saniye sonra ona "ödeme onaylandı" mesajı gönderiyor:

const shop = window.open('http://shop.bookstore.test:19455/');
setTimeout(() => {
  shop.postMessage({ type: 'payment-result', orderId: 'ORD-1042', status: 'approved' }, '*');
}, 1000);
[page] http://shop.bookstore.test:19455/
[console.log] (pay.widget.test:19456) widget frame: start-payment ORD-1042 1250
[console.info] (shop.bookstore.test:19455) [payment] accepted: ORD-1042 approved
[console.warning] (shop.bookstore.test:19455) [payment] rejected: origin http://evil.test:19457

Gerçek ödeme sonucu kabul edildi, evil.test'ten gelen kopya reddedildi. frame-ancestors burada yardımcı olmaz, çünkü saldırgan sayfayı çerçevelemedi, ayrı pencerede açtı. Açılan pencereyle bağı koparmak için Cross-Origin-Opener-Policy: same-origin header'ı var. Bookstore'a bu header'ı ekleyip evil.test'teki sayfaya shop.closed'ı yazdıran bir satır koydum:

# Cross-Origin-Opener-Policy: same-origin, sayfa http://localhost:19455/ olarak açıldı
[console.log] (evil.test:19457) opener sees: closed=true

# aynı header, sayfa http://shop.bookstore.test:19455/ olarak açıldı (düz HTTP)
[console.error] (shop.bookstore.test:19455) The Cross-Origin-Opener-Policy header has been ignored, because the URL's origin was untrustworthy. It was defined either in the final response or a redirect. Please deliver the response using the HTTPS protocol. You can also use the 'localhost' origin instead. See https://www.w3.org/TR/powerful-features/#potentially-trustworthy-origin and https://html.spec.whatwg.org/#the-cross-origin-opener-policy-header.
[console.log] (evil.test:19457) opener sees: closed=false

Header'ı alan sayfa yeni bir browsing context group'a taşındı; evil.test'in elindeki referans kapalı bir pencereyi gösteriyor ve gönderdiği mesaj hiçbir yere ulaşmıyor. İkinci koşu ise bir tuzağı gösteriyor: COOP (Cross-Origin-Opener-Policy) yalnız güvenilir origin'lerde, yani HTTPS'te ve localhost'ta uygulanıyor; düz HTTP'de tarayıcı header'ı yok sayıyor. Ödeme sağlayıcın 3-D Secure doğrulamasını popup'la yapıyorsa same-origin o akışı da koparabilir; o durumda same-origin-allow-popups değerini dene ve akışı uçtan uca test et.

Üretimde Dikkat

Ödeme sayfaları için bu konular artık yalnız iyi pratik değil. PCI (Payment Card Industry) DSS (Data Security Standard) v4.0.1'in 6.4.3 maddesi ödeme sayfasında çalışan her script için yetkilendirme, bütünlük kontrolü ve gerekçeli bir envanter istiyor; 11.6.1 maddesi de ödeme sayfasının güvenlikle ilgili header'larında ve script içeriğinde yetkisiz değişikliği yakalayan bir mekanizma istiyor. İki madde 31 Mart 2025'ten beri zorunlu. Pratikte bu, "sayfamızda hangi script'ler var ve kim ekledi?" sorusunun cevabının bir dosyada yazılı olması demek.

Envanteri CSP ile birlikte tut. Allowlist yerine nonce tabanlı strict CSP kullanıyorsan (serinin CSP yazısı), sayfaya izinsiz eklenen bir script çalışmaz ve violation raporu gelir; rapor, envanter dışı bir script'in ilk haberidir. Ödeme formunun kendisini mümkün olduğunca sağlayıcının hosted iframe'inde tut: kart alanları senin DOM'unda değilse sayfandaki bir script onları okuyamaz.

Her yeni widget için aynı soruları sor: script mi iframe mi, hangi sandbox izinleri gerçekten gerekli, hangi Permissions-Policy özelliklerine ihtiyacı var, hangi mesajları hangi şemayla gönderiyor. Cevaplar kod incelemesinde kontrol edilebilir hâle gelmeli; message service'inin şema fonksiyonu tam olarak bunu sağlıyor.

Sık Düşülen Tuzaklar

Birincisi, tag manager. Pazarlama ekibinin tag manager arayüzünden production'a script eklemesi, code review'u, CSP'yi ve envanteri tek tıkla atlar. Tag manager'ı ödeme sayfasından tamamen çıkar ya da en azından ödeme sayfasında yalnız onaylı etiketlerin yüklendiği ayrı bir container kullan.

İkincisi, sandbox="allow-scripts allow-same-origin"'ı kendi origin'inden gelen içerikle kullanmak. Lab'da gördüğün gibi iframe kendi sandbox'ını kaldırabiliyor.

Üçüncüsü, frame-ancestors'ı meta ile vermek. Tarayıcı bunu yok sayar ve konsoldaki tek satırlık uyarı dışında hiçbir şey olmaz.

Dördüncüsü, postMessage'da origin'i indexOf, startsWith ya da regular expression'ın sonuna $ koymadan kontrol etmek ve gönderirken "*" kullanmak. Beşincisi, mesajın içeriğini doğrulamadan innerHTML'e ya da location.href'e vermek; origin doğru olsa bile sağlayıcının kendisi ele geçirilmiş olabilir.

Küçük Bir Deneme

★☆☆ Ödeme sayfasının CSP'sinden frame-src'yi kaldır ve evil.test'ten bir iframe'i sayfaya ekle. Sonra direktifi geri koyup konsoldaki engelleme mesajını karşılaştır.

★★☆ Widget'ın frame.js'inde cevabı SHOP_ORIGIN yerine "*" ile gönder, ardından ödeme iframe'ini başka bir origin'e yönlendiren bir satır ekleyerek mesajın nereye gittiğini gözle. targetOrigin'in mesajı yanlış alıcıya karşı nasıl koruduğunu kendi cümlelerinle yaz.

★★★ PaymentMessageService'e bir zaman aşımı ekle: start-payment gönderildikten sonra 30 saniye içinde geçerli bir sonuç gelmezse durum timeout olsun. Davranışı Vitest'in fake timer'larıyla test et.

Bir sonraki yazıda tarayıcıdan sunucuya geçiyoruz: Angular SSR'da (Server-Side Rendering) sunucunun kendisi bir istemci gibi istek atıyor. allowedHosts, Host header doğrulaması, TransferState'e giren veri ve bundle'a sızan secret'lar konusunu yerel bir SSR sunucusunda deneyeceğiz.