Third-Party Scripts, iframes and postMessage: sandbox, frame-ancestors, Permissions-Policy and Safe Messaging
Third-party code added with a script tag runs in your origin with your authority; move it into a sandboxed iframe on its own origin, close your page to framing with the frame-ancestors header, switch off unused APIs with Permissions-Policy and validate origin, source window and schema in postMessage. We show each step with real output from headless Chrome and Vitest in a local three-origin lab.
Version note: The examples in this post were run with Angular 22.2, Vitest 5.1, Node.js 24 and Chrome 156 (headless).
Guess First
You are adding a payment provider's widget to the Bookstore checkout page. The provider's docs offer two options: add a one-line <script src="https://..."> to the page, or show the payment form inside an <iframe> served from the provider's own origin. Both look the same on screen. In which one can the provider's code see the email field on your page, the cart in localStorage and the requests made on the user's behalf? And once you pick the iframe and the provider reports the payment result with postMessage, can another open tab also send you a "payment approved" message?
In this post we answer those questions in a local lab. I set up three origins: shop.bookstore.test:19455 (the Bookstore Angular app), pay.widget.test:19456 (my own small server playing the third-party payment widget) and evil.test:19457 (again my own page, playing a malicious site). I ran Chrome headless with --host-resolver-rules mapping these three names to 127.0.0.1; every console line shown comes from those runs. No service on the internet was touched.
A Third-Party Script Runs with Your Authority
The first experiment puts the provider's script straight into the page:
<!-- 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}]
The file came from pay.widget.test, but the origin it runs in is shop.bookstore.test. For the browser, what matters is not where a script was downloaded from but which page loaded it: code added with a <script> tag has the same authority as your own code. It reads and changes the DOM (Document Object Model), listens to form fields, accesses localStorage, sees cookies that are not HttpOnly and sends requests from your origin with the user's session. The Same-Origin Policy does not protect you here, because there are not two origins; the script lives inside yours.
Web skimming (a script that slips into a checkout page and copies card and personal data) is the abused form of this fact. The attacker never gets into your server; changing the file on the provider's CDN (Content Delivery Network), an npm dependency or a tag in the tag manager is enough. The outcome is the same as XSS (Cross-Site Scripting), only the door is different: with XSS the attacker's code sneaks into your page, here you invited it yourself.
In the "Software Supply Chain Failures" post of the OWASP (Open Worldwide Application Security Project) series we covered SRI (Subresource Integrity): the integrity attribute pins the file's hash and the browser refuses to run the script if the file changes. To see its limit, I pinned the provider's "always latest" address to v1's hash and then replaced the file on the server with v2:
<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 did its job and blocked the changed file. The problem: the content behind an unversioned address legitimately changes every time the provider ships. The day you pin the hash, your checkout page silently breaks on the provider's next update. So SRI only works for files whose version is fixed in the URL (Uniform Resource Locator); it offers no protection for "latest" addresses or for loaders that pull in more scripts themselves. The way out of this limit is to move third-party code out of your origin.
Drawing a Boundary with an iframe: sandbox
Once third-party code runs in an iframe on its own origin, the Same-Origin Policy is back in play: code in the iframe cannot reach your DOM, storage or cookies. The sandbox attribute goes one step further; it starts the framed document with a set of restrictions and gives back only the allow-* permissions you list explicitly. To see how restrictive it is, I loaded a test page from the same origin with sandbox="allow-scripts":
<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
Even though the page came from the same server, its origin became null. A sandbox without allow-same-origin puts the document into an opaque origin (one that matches no other origin); cookies, localStorage and the parent's DOM throw SecurityError. Without allow-popups no new window opens, without allow-modals alert is ignored, and without allow-top-navigation the top window cannot be sent to another site. The last one matters for ads and widgets: writing to top.location is the shortest way to move a user from your checkout page to a fake one.
The second iframe shows a common mistake:
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 wrote the warning itself. If allow-scripts and allow-same-origin are given together and the content comes from your origin, the framed script can reach the parent's DOM and remove sandbox from its own iframe element. Use this pair only when the content comes from another origin; then the Same-Origin Policy already blocks access to the parent, and allow-same-origin only lets the iframe use its own origin's cookies and storage. Payment widgets usually need that.
Angular adds one more rule. I tried to set the iframe's security-related attributes such as sandbox and allow through a binding, and bound src from a property:
@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
When I rendered them in a unit test, Angular rejected both. sandbox can only be written as a static attribute (NG0910), so the contents of a variable cannot loosen the iframe's restrictions. The iframe src is a resource URL context; for a dynamic value Angular asks for bypassSecurityTrustResourceUrl. Calling that bypass with a user-supplied value is one more line for the DOM XSS sink inventory from the second post of this series. Writing a fixed provider address statically into the template is the cleanest way.
Defending Against Framing with frame-ancestors
Now the other direction: what happens if your page ends up inside someone else's iframe? In clickjacking, the attacker puts the Bookstore account page into an invisible iframe on their site and makes the user click "delete my reviews" while they think they are clicking something else. The user's session cookie goes along, because the request really leaves from the Bookstore page. The defence is to say in a response header who may frame the page:
# demo/account.html: HTTP response header
Content-Security-Policy: frame-ancestors 'self'
# demo/account-meta.html: same rule as a meta element
<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
The rule delivered as a header worked: Chrome blocked the framing and put an error page (chrome-error://chromewebdata/) in place of the iframe. The same rule delivered as a meta element was ignored and the page opened inside evil.test. The CSP (Content Security Policy) Level 3 specification (CSP3) says so explicitly: frame-ancestors, report-uri and sandbox cannot be delivered via meta. Teams that put a meta CSP into index.html on static hosting therefore do not get the clickjacking protection they think they have; this directive must be sent as a header from the server, reverse proxy or CDN layer.
The older X-Frame-Options: DENY header does the same job more coarsely and only knows DENY and SAMEORIGIN. When both are sent, CSP3 says frame-ancestors wins; sending both for old clients is harmless. Producing the header from ASP.NET Core is the subject of the CSP post in the upcoming "ASP.NET Core Security" series.
Turning Off Powerful APIs with Permissions-Policy
The Bookstore does not need the camera, the microphone or the location. So no code loaded into the page, whether your own or a widget's, needs to ask for them. The Permissions-Policy header turns a feature off for the document and every iframe inside it. I ran this experiment on localhost, because the location and camera APIs (Application Programming Interface) require a secure context: the page must be opened over HTTPS (Hypertext Transfer Protocol Secure) or from localhost. On shop.bookstore.test, opened over plain HTTP (unencrypted Hypertext Transfer Protocol), navigator.mediaDevices was not even defined.
# 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
Opening the same page without the header gave a different result:
[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 declines permission prompts on its own, so both runs ended with an error; the difference is in the message. Without the policy the error comes from the user's decision ("User denied"); a real browser would have shown a permission prompt. With the policy no prompt is ever shown and the API is off for the document. Even if a widget tries to persuade the user to "share your location", the browser does not open the permission dialog.
Permissions-Policy is also how you hand permissions to iframes. The final headers on the Bookstore checkout page are these:
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 is open only to the Bookstore and the payment origin; the iframe side also carries allow="payment". From inside the payment iframe I saw payment=true geolocation=false (in the output of the next section). frame-src says iframes may only come from the payment origin; if an ad script tries to open an iframe from another address, CSP blocks it.
postMessage: Origin, Source and Schema
We separated the iframe from the parent; now they need to talk. window.postMessage is the only controlled channel between two origins, but the control is entirely on the receiving side. The form I see most often on the internet is this:
window.addEventListener('message', (event) => {
// DANGEROUS: writes to state without checking who sent it
this.lastResult.set(event.data);
});
This listener listens to anyone who has a reference to the page: a site that opened the Bookstore with window.open, any iframe on the page, even a browser extension. A safe message service for the Bookstore must do three checks: did the message come from the expected origin, from the expected window, and does its content match the expected schema. When sending, targetOrigin must not be "*"; otherwise, if the iframe has meanwhile been navigated elsewhere, the message goes there.
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 is never '*': only the payment origin may read this message
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}`);
}
}
The origin comparison is an exact equality. Checks written with startsWith or includes accept a name like http://pay.widget.test.evil.test. The schema check also fixes the number of fields and restricts orderId with a regular expression; if this value later goes into a template or a URL, characters you do not want there are kept out at the first step. The component sets up the iframe with static attributes:
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);
});
The widget side follows the same discipline: it only processes start-payment messages from the Bookstore origin and sends the answer to the exact origin. Opening the production build with the Bookstore headers:
[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"
I pinned the behaviour with unit tests. In Angular 22, ng test runs on Vitest by default:
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)
Finally I tried a forged message from outside in a real browser. The page on evil.test opens the Bookstore in a new window and a second later sends it a "payment approved" message:
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
The real payment result was accepted, the copy from evil.test was rejected. frame-ancestors does not help here, because the attacker did not frame the page; they opened it in a separate window. To cut the link with the opened window there is the Cross-Origin-Opener-Policy: same-origin header. I added it to the Bookstore and put a line on the evil.test page that prints shop.closed:
# Cross-Origin-Opener-Policy: same-origin, page opened as http://localhost:19455/
[console.log] (evil.test:19457) opener sees: closed=true
# same header, page opened as http://shop.bookstore.test:19455/ (plain 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
The page that received the header moved into a new browsing context group; the reference evil.test holds points to a closed window and the message it sends reaches nothing. The second run shows a trap: COOP (Cross-Origin-Opener-Policy) is only applied on trustworthy origins, that is on HTTPS and localhost; over plain HTTP the browser ignores the header. If your payment provider runs 3-D Secure verification in a popup, same-origin may cut that flow as well; in that case try same-origin-allow-popups and test the flow end to end.
In Production
For checkout pages these topics are no longer just good practice. Requirement 6.4.3 of PCI (Payment Card Industry) DSS (Data Security Standard) v4.0.1 asks for authorization, integrity checks and a justified inventory for every script running on the payment page; requirement 11.6.1 asks for a mechanism that detects unauthorized changes to the security-impacting headers and script contents of the payment page. Both have been mandatory since 31 March 2025. In practice it means the answer to "which scripts are on our page and who added them?" is written down in a file.
Keep the inventory together with CSP. If you use a nonce-based strict CSP instead of an allowlist (the CSP post of this series), a script added to the page without permission does not run and a violation report arrives; that report is the first news of a script outside the inventory. Keep the payment form itself in the provider's hosted iframe whenever possible: if the card fields are not in your DOM, a script on your page cannot read them.
Ask the same questions for every new widget: script or iframe, which sandbox permissions are really needed, which Permissions-Policy features it needs, which messages it sends with which schema. The answers should become checkable in code review; the schema function of the message service gives you exactly that.
Common Pitfalls
First, the tag manager. When the marketing team adds a script to production through the tag manager user interface, it bypasses code review, CSP and the inventory in one click. Remove the tag manager from the checkout page entirely, or at least use a separate container on the checkout page that loads only approved tags.
Second, using sandbox="allow-scripts allow-same-origin" with content from your own origin. As you saw in the lab, the iframe can remove its own sandbox.
Third, delivering frame-ancestors through meta. The browser ignores it and nothing happens apart from a one-line warning in the console.
Fourth, checking the origin in postMessage with indexOf, startsWith or a regular expression without a trailing $, and sending with "*". Fifth, passing message content into innerHTML or location.href without validation; even with the right origin, the provider itself may have been compromised.
A Small Experiment
★☆☆ Remove frame-src from the checkout page's CSP and add an iframe from evil.test to the page. Then put the directive back and compare the blocking message in the console.
★★☆ In the widget's frame.js, send the answer with "*" instead of SHOP_ORIGIN, then add a line that navigates the payment iframe to another origin and watch where the message goes. Write in your own words how targetOrigin protects the message against the wrong recipient.
★★★ Add a timeout to PaymentMessageService: if no valid result arrives within 30 seconds after start-payment, the status becomes timeout. Test the behaviour with Vitest's fake timers.
In the next post we move from the browser to the server: in Angular SSR (Server-Side Rendering) the server itself sends requests like a client. We will try allowedHosts, Host header validation, the data that goes into TransferState and the secrets that leak into the bundle on a local SSR server.