Test an Iframe Message Handler
Build tests that verify an iframe widget rejects unsafe postMessage events.
Introduction
30 Second Summary
A widget inside a webpage can listen when its surrounding page speaks to it. If it trusts every message, an unexpected page can tell it what to do.
In this project, you will build an iframe widget that receives messages from its parent page through the postMessage API. You will create an automated test suite that checks trusted origins, untrusted origins, malformed messages, and a deliberate security regression.
What You'll Build
Picture your widget updating for a trusted parent message, rejecting a second page from another origin, and exposing a missing security check through a failing test.
By the end of this project, you'll have:
- A trusted message demo where the parent page sends text to the embedded widget and updates what you see on screen.
- A wrong-origin rejection flow where the widget ignores an untrusted page and records the rejection in the browser console.
- A regression-catching test suite that passes for valid messages, wrong origins, and malformed data before failing when the origin check disappears.
- Secret Mission: Take on an optional challenge to push your iframe security testing skills further.
Are there any prerequisites?
You'll need Node.js, a modern browser, and a code editor.
Everything runs locally through a static server, so there are no service costs.
Before We Start
A secure messaging demo depends on a predictable local workspace. Preparing the tools now keeps later browser behavior focused on the handler you are testing.
Visual Studio Code will hold the project files. Your browser will show the parent page plus its embedded widget.
Node.js will run the local tooling. npm will install the test runner later.
In This Step, Get Ready To:
- Prepare Visual Studio Code for the project files.
- Verify Node.js plus npm from a terminal.
- Open an empty project folder as your workspace.
Prepare Your Editor and Browser
You are already viewing this project in a modern browser, so the browser requirement is covered. Visual Studio Code gives you a file explorer plus a terminal in one window.
- Keep this project open in your current browser tab.
- Press Cmd+Space on macOS or the Windows key on Windows.
- Type Visual Studio Code into the search bar.
- Press Enter.
✔️ Visual Studio Code Opens
Your editor is available. You now have a place to open the project folder.
ⓧ Visual Studio Code Is Missing
- Open the official Visual Studio Code download page in your browser.
- Download the installer that matches your operating system.
- Run the downloaded installer.
- Follow the installation prompts shown on your screen.
- Approve the request if your operating system asks for permission.
- Press Cmd+Space on macOS or the Windows key on Windows.
- Type Visual Studio Code into the search bar.
- Press Enter.
Once Visual Studio Code opens, your editor is ready for the project folder.
Still Unable to Open the Editor?
Confirm that you ran the installer instead of leaving it in your downloads folder.
Restart your computer if the installed app does not appear in system search.
Help me install Visual Studio Code on my computer.
Check Node.js and npm
Node.js executes JavaScript outside the browser. npm installs the packages used by local servers plus automated tests.
- Return to Visual Studio Code.
- Click View in the top menu.
- Click Terminal.
- Check whether both tools are available by running these commands:
node --version
npm --version
What Do These Commands Check?
- The first command asks Node.js to print its installed version.
- The second command asks npm to print its installed version.
- Two version numbers confirm that your terminal can find both tools.
✔️ I See Two Version Numbers
That is the runtime ready. Your terminal can now use Node.js plus npm.
ⓧ Command Not Found
- Open the official Node.js download page in your browser.
- Select the installer marked LTS for your operating system.
- Run the downloaded installer.
- Approve the request if your operating system asks for permission.
- Complete the installation prompts shown on your screen.
The Node.js installer includes npm. Restarting the editor gives its terminal access to the newly installed commands.
- Restart Visual Studio Code.
- Click View in the top menu.
- Click Terminal.
- Verify the installation by running these commands:
node --version
npm --version
What Should You See?
The terminal should print one Node.js version number followed by one npm version number. This confirms that the restarted terminal can find both tools.
Versions Still Missing?
- Close every open terminal after installing Node.js.
- Restart Visual Studio Code before checking the versions again.
- Confirm that the Node.js installer reached its completion screen.
Why can my terminal not find Node.js or npm after installation?
Create and Open the Project Folder
Visual Studio Code treats an opened folder as a workspace. Keeping this workspace empty gives the next step a clean starting point.
Visual Studio Code may show a Workspace Trust prompt when the folder opens. You create this empty folder yourself, so it is safe to trust.
- Switch back to Visual Studio Code.
- Click File in the top menu.
- Click Open Folder....
- Select New Folder in the folder picker.
- Enter iframe-message-handler as the folder name.
- Select Select Folder on Windows.
- Select Open on macOS.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
You should see iframe-message-handler at the top of the Explorer. No files should appear beneath it.
Before you run the final check, do you expect both version commands to work inside this empty workspace?
- Click View in the top menu.
- Click Terminal.
- Confirm that the workspace terminal can find both tools by running these commands:
node --version
npm --version
What Does This Final Check Prove?
Visual Studio Code opens its terminal at the current workspace. Successful version output proves that the project folder can use the local Node.js toolchain.
- Look at the Explorer in the left sidebar.
You should see a Node.js version on the first terminal line. You should see an npm version on the second line.
The Explorer should show the empty iframe-message-handler folder. No parent page, widget page, attacker page, server file, or test file should exist.
Folder or Versions Missing?
- Use File followed by Open Folder... if the Explorer does not show iframe-message-handler.
- Restart Visual Studio Code if the terminal still cannot find Node.js.
- Delete any accidental files from the Explorer so the workspace is empty.
Help me check my empty Visual Studio Code workspace.
Your local setup is ready. The editor has an empty workspace while its terminal can access Node.js plus npm.
Next, you will build the parent page that embeds the widget and sends its first message.
Build the Parent and Widget
An embedded iframe runs as its own page. The browser's same-origin policy blocks the parent from directly changing a cross-origin iframe's document.
The postMessage API provides a controlled message path between those pages. You will use Node.js to serve the parent page from one origin and the widget from another.
In this step, get ready to:
- Build a widget that displays received text.
- Build a parent page that sends text to the embedded widget.
- Run both local origins until the widget shows the parent's message.
Build the widget receiver
An origin includes a page's protocol, host, and port. The widget uses http://localhost:3001 as its origin.
The widget needs a visible starting state before it can show received text. Its message listener will replace that starting text with the payload sent by the parent.
- Create widget/index.html inside the open project folder with your editor's file sidebar.
- Add the widget interface and message listener by copying this code into widget/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Message Widget</title>
</head>
<body>
<!-- Show the payload sent by the parent page. -->
<p id="widget-text">Waiting for a message...</p>
<script>
// Keep a reference to the visible widget text.
const widgetText = document.querySelector("#widget-text");
// Display the payload from every incoming message.
window.addEventListener("message", (event) => {
widgetText.textContent = event.data.text;
});
</script>
</body>
</html>
What does this code do?
- The widget-text element provides a visible waiting state.
- The message listener runs when the widget receives a posted payload.
- The event.data.text value replaces the widget's visible text.
- The listener currently processes every incoming message without checking its sender.
- Save widget/index.html.
- Confirm that index.html appears inside the widget folder in your editor's file sidebar.
Widget file missing?
- Check that the folder is named widget.
- Check that the file uses the .html extension.
Ask for help if the path still looks wrong: Help me check my widget file structure.
A local HTTP server gives the widget its port-based origin. The server process stays active in its terminal while it handles browser requests.
- Create widget/server.mjs inside the widget folder with your editor's file sidebar.
- Add the widget server by copying this code into widget/server.mjs:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const port = 3001;
// Load the widget page once when the server starts.
const page = await readFile(new URL("./index.html", import.meta.url));
const server = createServer((request, response) => {
// Serve the widget HTML for each local request.
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(page);
});
server.listen(port, () => {
console.log(`Widget: http://localhost:${port}`);
});
How does the widget server work?
- The readFile() call loads index.html from the same folder as the server.
- The createServer() callback returns the saved widget page for each request.
- The listen() call assigns port 3001 to the widget server.
- Save widget/server.mjs.
- Create a terminal session with your editor's terminal controls.
- Start the widget server by running this command:
node widget/server.mjs
What does this command do?
The node command runs the widget server file. The process keeps port 3001 available for browser requests.
- Enter http://localhost:3001 in your browser's address bar.
You should see Waiting for a message... on the page. That confirms the widget is live on its own origin.
Widget page not loading?
- Confirm that the widget server process is still running in the terminal.
- Check that server.mjs sits beside index.html inside the widget folder.
- Check that the browser URL uses port 3001.
Ask for help if the waiting text does not appear: Help me troubleshoot the widget server.
Build the parent sender
The parent page holds a reference to the widget's window through the iframe. It waits for the iframe to load before sending the display payload.
The parent uses http://localhost:3000 as its origin. The widget remains available at http://localhost:3001.
- Create parent/index.html inside the open project folder with your editor's file sidebar.
- Add the iframe and sender by copying this code into parent/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parent Page</title>
</head>
<body>
<h1>Parent Page</h1>
<!-- Load the widget from its separate origin. -->
<iframe id="widget-frame" src="http://localhost:3001" title="Message widget"></iframe>
<script>
// Keep the frame and display payload ready for the load event.
const widgetFrame = document.querySelector("#widget-frame");
const message = { text: "Hello from the parent page" };
// Send the payload after the widget is ready to receive it.
widgetFrame.addEventListener("load", () => {
widgetFrame.contentWindow.postMessage(message, "http://localhost:3001");
});
</script>
</body>
</html>
How does the parent send the message?
- The iframe loads the widget from port 3001.
- The message object carries the display text.
- The load listener waits until the widget is ready.
- The postMessage() call sends the object to the widget window.
- Save parent/index.html.
- Confirm that index.html appears inside the parent folder in your editor's file sidebar.
Parent file missing?
- Check that the folder is named parent.
- Check that the complete file path is parent/index.html.
Ask for help if the file appears elsewhere: Help me correct the parent file structure.
The parent needs a second server to establish port 3000 as the sender's origin. A separate terminal session lets both servers remain active.
- Create parent/server.mjs inside the parent folder with your editor's file sidebar.
- Add the parent server by copying this code into parent/server.mjs:
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const port = 3000;
// Load the parent page once when the server starts.
const page = await readFile(new URL("./index.html", import.meta.url));
const server = createServer((request, response) => {
// Serve the parent HTML for each local request.
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(page);
});
server.listen(port, () => {
console.log(`Parent: http://localhost:${port}`);
});
How does the parent server work?
- The server reads parent/index.html when it starts.
- The request callback returns the saved parent page.
- Port 3000 gives the parent a separate origin from the widget.
- Save parent/server.mjs.
- Create a second terminal session with your editor's terminal controls.
- Start the parent server by running this command in the second terminal:
node parent/server.mjs
What does this command do?
The command starts the parent server while the widget server continues running. The two active processes provide the separate local origins used by the message flow.
You should see Parent: http://localhost:3000 in the second terminal. Keep both terminal sessions running.
Parent server not starting?
- Check that the first terminal is running widget/server.mjs.
- Check that the second terminal is running parent/server.mjs.
- Confirm that parent/server.mjs uses port 3000.
Ask for help if the process exits: Help me troubleshoot the parent server.
Verify the cross-origin message
The complete path now starts on the parent page. The iframe loads before the parent posts its display payload.
Before you load the parent page, what text do you expect to replace the widget's waiting message?
- Enter http://localhost:3000 in your browser's address bar.
You should see the Parent Page heading above the embedded widget. Inside the iframe, you should see Hello from the parent page.
That visible replacement proves the message crossed from port 3000 to port 3001. Your parent-to-widget message path now works.
Widget still showing the waiting text?
- Confirm that both server terminals are still running.
- Check that the iframe src points to http://localhost:3001.
- Check that the payload uses the text property expected by event.data.text.
Ask for help if the message is still missing: Help me debug the parent-to-widget message flow.
✔️ Awesome, I've got everything!
Great work. Your parent page now sends a payload across local origins that the embedded widget displays.
ⓧ I'd like to double check the full code
- Compare each saved file with its matching reference below.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Message Widget</title>
</head>
<body>
<!-- Show the payload sent by the parent page. -->
<p id="widget-text">Waiting for a message...</p>
<script>
// Keep a reference to the visible widget text.
const widgetText = document.querySelector("#widget-text");
// Display the payload from every incoming message.
window.addEventListener("message", (event) => {
widgetText.textContent = event.data.text;
});
</script>
</body>
</html>
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const port = 3001;
// Load the widget page once when the server starts.
const page = await readFile(new URL("./index.html", import.meta.url));
const server = createServer((request, response) => {
// Serve the widget HTML for each local request.
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(page);
});
server.listen(port, () => {
console.log(`Widget: http://localhost:${port}`);
});
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parent Page</title>
</head>
<body>
<h1>Parent Page</h1>
<!-- Load the widget from its separate origin. -->
<iframe id="widget-frame" src="http://localhost:3001" title="Message widget"></iframe>
<script>
// Keep the frame and display payload ready for the load event.
const widgetFrame = document.querySelector("#widget-frame");
const message = { text: "Hello from the parent page" };
// Send the payload after the widget is ready to receive it.
widgetFrame.addEventListener("load", () => {
widgetFrame.contentWindow.postMessage(message, "http://localhost:3001");
});
</script>
</body>
</html>
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
const port = 3000;
// Load the parent page once when the server starts.
const page = await readFile(new URL("./index.html", import.meta.url));
const server = createServer((request, response) => {
// Serve the parent HTML for each local request.
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(page);
});
server.listen(port, () => {
console.log(`Parent: http://localhost:${port}`);
});
Your two-page message flow works across separate local origins. Next, you will add an origin check so the widget can decide which senders to trust.
Validate the Parent Origin
Your parent page already sends display text through the browser's postMessage API. The widget receives that message from its iframe.
The current handler trusts every incoming message. This leaves the visible widget text open to any page that can reach the frame.
An exact browser origin check creates the trusted boundary. An acceptance log makes that security decision visible in the browser console.
In this step, get ready to:
- Allow messages from http://localhost:3000.
- Stop every other origin before it updates the widget text.
- Record accepted messages in the browser console.
Add the allowed origin guard
Every message event includes event.origin. The widget can compare this value with its trusted parent before processing the message payload.
Why do the ports matter?
A browser origin includes the protocol. It also includes the hostname.
The port completes the origin. This makes http://localhost:3000 separate from http://localhost:3001.
Your parent runs on port 3000. The widget therefore uses the parent's full origin as its allowed value.
- Switch back to the project in your code editor.
- Use project-wide search to locate the widget source file containing the message-event listener.
- Select the search result whose callback updates the visible widget text from the received message payload.
- Insert an early-return guard before the existing visible-text update using http://localhost:3000 as the exact allowed origin. Configure the guard to return whenever event.origin does not match this value.
- Save the widget source file.
Before you refresh, predict whether the parent message will pass the exact origin check.
- Return to the parent page at http://localhost:3000.
- Refresh the parent page to send its message again.
You should see the same parent-sent text inside the widget. This visible update proves that the allowed origin reached the existing display logic.
That trust boundary is now in place. The legitimate parent can still communicate with the widget.
Did the widget stop updating?
- Confirm that the guard sits inside the existing message callback.
- Check that the guard runs before the visible-text update.
- Check that the allowed value is exactly http://localhost:3000 with no trailing slash.
Help me debug an origin guard that blocks the allowed parent message.
Log accepted messages
Code after the guard runs only for an allowed sender. Placing the acceptance log there records the handler's decision.
- Return to the same widget source file.
- Add a browser-console log directly after the origin guard that identifies the message as accepted.
- Save the widget source file.
- Return to the parent page at http://localhost:3000.
- Use your browser's developer-tools menu to display the browser console.
Before you refresh again, predict which two places will show evidence that the handler accepted the event.
- Refresh the parent page to trigger another message from the allowed origin.
You should see the parent-sent text inside the widget. You should also see an acceptance entry in the browser console.
You have secured the trusted path without breaking it. The widget now checks the sender before it updates its visible text.
Missing the acceptance entry?
- Confirm that the acceptance log appears after the origin guard inside the message callback.
- Clear any browser-console filters that hide messages from embedded frames.
- Confirm that the widget remains available at http://localhost:3001.
Help me find why an accepted iframe message updates the widget without appearing in the browser console.
Your widget now accepts its trusted parent through an exact origin check. Next, you will send a message from a second origin to prove that the guard rejects it.
Reject an Untrusted Sender
Your trusted parent already updates the widget from http://localhost:3000. The accepted console entry confirms that the widget recognizes its expected sender.
Security bugs often hide in the rejected path. This step uses the postMessage API from a second origin to prove that the widget blocks an untrusted page.
You will create an attacker page on http://localhost:3002. You will also make the rejected decision visible in the browser console.
In this step, get ready to:
- Create an attacker page on http://localhost:3002 that targets the existing widget.
- Log rejected origins from the widget's message handler.
- Prove that the attacker message cannot update the widget.
Create the attacker page
A browser origin includes the protocol plus the hostname. It also includes the port.
The trusted page uses port 3000. The attacker uses port 3002, so the browser treats it as a different sender.
- In your code editor's file tree, duplicate the existing parent page file into a separate location for the attacker page.
- Keep the copied iframe source pointing to the existing widget at http://localhost:3001.
- Change only the display text in the copied postMessage payload so it is visibly different from the trusted parent's text.
- Save the copied attacker page.
- Return to the local static-server setup from earlier.
- Start a third instance of the same static server for the attacker page on port 3002.
- Open http://localhost:3002 in a new browser tab.
You should see the attacker page load the same widget from http://localhost:3001. You now have three local origins running at once.
Attacker page not loading?
- Confirm that the third static server uses port 3002.
- Check that the third server serves the location containing the copied attacker page.
- Verify that the original servers still respond at http://localhost:3000 and http://localhost:3001.
Help me troubleshoot why my attacker page is not loading on localhost port 3002.
Log the rejected origin
The existing guard prevents an untrusted message from reaching the visible text update. A rejection log turns that silent decision into evidence you can inspect.
- Switch back to the widget file from earlier.
- Find the existing origin check that allows only http://localhost:3000.
- Extend the mismatch path with a rejection log that includes event.origin before the handler stops.
- Save the widget file.
- Return to the trusted parent page at http://localhost:3000.
- Reload the trusted parent page.
You should still see the parent-sent text inside the widget. The browser console should still show that the message from http://localhost:3000 was accepted.
Why log a rejected message?
The rejection log shows that the handler received a message before refusing it. Including event.origin identifies the sender that failed the check.
This gives you visible proof that the guard made the decision. The widget text remains protected because the handler stops before the update.
Trusted message no longer accepted?
- Check that the allowed origin still matches http://localhost:3000 exactly.
- Confirm that the rejection log runs only when event.origin does not match the allowed origin.
- Check that the accepted message still reaches the existing visible text update.
Help me find why my trusted localhost message is being rejected after I added the rejection log.
Send the untrusted message
Both pages now use the same messaging flow. The sender's origin decides whether the widget processes the payload.
- Return to the trusted parent page at http://localhost:3000.
- Reload the trusted parent page.
The widget should display the trusted parent's text. The console should show an accepted message from http://localhost:3000.
Before you reload the attacker page, do you expect its display text to replace the widget text or be rejected?
- Switch to the attacker page at http://localhost:3002.
- Reload the attacker page.
- Inspect the browser console for the widget's rejection entry.
You should see a rejection entry naming http://localhost:3002. The widget should keep its original text instead of displaying the attacker-specific text.
Attacker message still updates the widget?
- Confirm that the attacker page itself is served from http://localhost:3002.
- Check that the widget compares event.origin with http://localhost:3000 before updating visible text.
- Verify that the mismatch path stops the handler before the accepted-message logic runs.
Help me debug why a postMessage from localhost port 3002 can still update my iframe widget.
You have proved that the widget accepts its trusted parent while blocking a second local origin. Next, you will turn this browser evidence into an automated test for the accepted message.
Test an Accepted Message
Your browser checks proved that the widget accepts messages from http://localhost:3000. They also proved that it rejects messages from http://localhost:3002.
A manual check can be forgotten when the handler changes. Vitest turns the accepted path into a repeatable test. jsdom supplies the browser-like DOM APIs that the widget needs.
In this step, get ready to:
- Install the test dependencies.
- Configure a browser-like test environment.
- Automate the accepted-message check.
Prepare the browser-like test environment
Vitest discovers JavaScript test files before running their assertions. jsdom gives those tests access to browser APIs such as window and MessageEvent.
- Install Vitest plus jsdom as development dependencies by running this command:
npm install --save-dev vitest jsdom
What does this command install?
- Vitest runs the test files in your project.
- jsdom provides the browser-like environment used by the widget test.
- The development dependency setting keeps both packages separate from code shipped to users.
- Confirm the terminal returns to its prompt without reporting a dependency installation failure.
Dependency installation failed?
- Confirm the terminal is using the project folder that is already open in your editor.
- Check that your internet connection can reach the package registry.
- Close any process that has locked the project dependency files.
Ask for help with the installation output:
Help me diagnose why Vitest and jsdom did not install in my JavaScript project.
Vitest uses its configuration file to choose the environment for every test. Setting the environment to jsdom makes the widget's browser APIs available automatically.
- Select the New File button in the editor's Explorer sidebar.
- Enter vitest.config.js as the file name.
- Configure the browser-like environment by pasting this code:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// Provide DOM APIs such as window and MessageEvent
environment: 'jsdom',
},
})
What does this configuration do?
- The defineConfig() helper describes how Vitest runs this project.
- The environment setting loads jsdom before the tests start.
- The widget test can now dispatch a browser-style message event.
- Save vitest.config.js.
- Confirm the editor's Explorer sidebar lists vitest.config.js inside the open project folder.
Configuration file missing?
- Check that the file name ends with .js.
- Move the file into the same project folder that contains the widget files.
- Save the file before continuing.
Help me check my Vitest configuration:
Help me find why my Vitest configuration is not loading the jsdom environment.
✔️ Awesome, I've got everything!
Great. Double-check that vitest.config.js is saved before you create the test.
ⓧ I'd like to double check the full code
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// Provide DOM APIs such as window and MessageEvent
environment: 'jsdom',
},
})
Create the first handler test
The first test covers the known-good path. It recreates the widget's text target before loading the existing message listener.
The test then dispatches a valid message from http://localhost:3000. Its assertion checks the visible text produced by the handler.
- Select the New File button in the editor's Explorer sidebar.
- Enter widget.test.js as the file name.
- Build the accepted-message test by pasting this code:
import { beforeAll, expect, test } from 'vitest'
beforeAll(async () => {
// Create the widget text target before the handler is loaded
document.body.innerHTML = '<p id="widget-text">Waiting for a message...</p>'
await import('./widget.js')
})
test('accepts a valid message from the allowed origin', () => {
// Dispatch the allowed origin with a valid display-text payload
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:3000',
data: { type: 'display-text', text: 'Hello from the test' },
}))
// Verify the handler displayed the accepted text
expect(document.querySelector('#widget-text').textContent).toBe('Hello from the test')
})
What does this test prove?
- The beforeAll() setup creates the visible text target before the widget script loads.
- The MessageEvent carries the allowed origin with a valid display-text payload.
- The expect() assertion confirms that the handler displayed the accepted text.
- Save widget.test.js.
- Confirm the editor's Explorer sidebar lists widget.test.js inside the open project folder.
✔️ Awesome, I've got everything!
Your accepted-message test is ready. Save widget.test.js before running it.
ⓧ I'd like to double check the full code
import { beforeAll, expect, test } from 'vitest'
beforeAll(async () => {
// Create the widget text target before the handler is loaded
document.body.innerHTML = '<p id="widget-text">Waiting for a message...</p>'
await import('./widget.js')
})
test('accepts a valid message from the allowed origin', () => {
// Dispatch the allowed origin with a valid display-text payload
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:3000',
data: { type: 'display-text', text: 'Hello from the test' },
}))
// Verify the handler displayed the accepted text
expect(document.querySelector('#widget-text').textContent).toBe('Hello from the test')
})
Prove the accepted path works
Before you run the test, do you expect the simulated message to pass the widget's origin check?
- Run the accepted-message test once by entering this command:
npx vitest run --environment=jsdom
What should I see?
Vitest should report one passing test. The passing assertion proves that a valid message from http://localhost:3000 updates the widget's displayed text.
You have turned the browser's accepted-message check into repeatable evidence.
Test not passing?
- Confirm widget.test.js imports the widget script from its actual location.
- Check that widget-text matches the displayed-text element used by the widget handler.
- Compare the test payload with the display-text payload used by the parent page.
Help me debug the failing accepted-message test:
Help me trace why my valid MessageEvent does not update the widget text in Vitest.
Your accepted path is now protected by a repeatable test. Next, you will test the hostile origin plus a malformed payload.
Cover Rejected Messages
Your first automated test proves the widget accepts a valid postMessage from its trusted parent. The expected message can now update the displayed text.
A secure handler also needs proof that rejected messages leave the widget unchanged. In this step, you will cover the attacker origin plus a malformed payload.
In this step, get ready to:
- Add payload validation to the widget message handler.
- Prove the attacker origin cannot change the widget text.
- Prove a malformed message cannot change the widget text.
Reject malformed payloads in the handler
The origin check controls who can send a message. A payload check controls which message shapes the widget accepts.
What should the payload guard protect?
- The guard checks that the incoming data matches the valid display-text shape used by the passing test.
- A malformed payload follows the widget's rejection logging path.
- The handler returns before malformed data can change the displayed text.
- In the widget source file from earlier, locate the point after the allowed-origin check passes but before the displayed text changes.
- Add a payload guard that follows the contract above.
- Save the widget source file.
Before you rerun the accepted-message test, do you expect the valid payload to pass through the new guard?
- Rerun the accepted-message test with the same test command from the previous step.
You should see the accepted-message test pass again. Good. The new guard preserves the valid path.
Did the accepted-message test start failing?
- Compare the guard with the valid display-text payload already used by the passing test.
- Check that the handler reaches the text update when the payload has the expected shape.
Ask for help with the guard: Why is my valid widget message being rejected by the new payload check?
Add a wrong-origin test
A rejection log shows that the handler noticed an untrusted sender. An unchanged widget proves the sender had no visible effect.
- Switch back to the widget message-handler test file from earlier.
- Add a test that dispatches a valid display-text payload from http://localhost:3002.
- Make the test verify that the widget records a rejection.
- Make the test verify that the displayed text keeps its original value.
- Save the widget message-handler test file.
Before you rerun the suite, do you expect the attacker message to change the widget text?
- Rerun the test suite with the same test command from the previous step.
You should see two passing tests. The second test proves that http://localhost:3002 cannot change the widget.
Why check the displayed text?
The displayed text is the protected state in this widget. Checking its original value proves the rejected event stopped before the update.
Is the wrong-origin test failing?
- Check that the test dispatches the event from http://localhost:3002.
- Confirm that the test gives the widget text a known value before dispatching the event.
- Verify that the wrong-origin branch returns before the text update.
Get help with the rejection path: Why can my wrong-origin message still change the widget text?
Add a malformed-message test
A trusted origin can still send incomplete data. The payload guard keeps that data away from the widget text.
- Add a third test beside the two existing message-handler tests.
- Dispatch the third message from http://localhost:3000 with data that does not match the valid display-text shape.
- Make the test verify that the widget records a rejection.
- Make the test verify that the displayed text keeps its original value.
- Save the widget message-handler test file.
Before you run the full suite, do you expect every accepted path plus every rejection path to pass?
- Run the complete message-handler test suite with the same test command from the previous step.
You should see three passing tests. Your suite now covers a valid message plus two rejection paths.
Is the malformed-message test failing?
- Confirm that the malformed event still uses the allowed origin http://localhost:3000.
- Check that its data differs from the valid display-text shape.
- Verify that the payload guard runs before the widget text update.
Ask for help with the payload test: Why is my malformed widget message being accepted?
✔️ Awesome, I've got everything!
Your widget source plus the message-handler test file are saved. The full suite reports three passing tests.
ⓧ I'd like to double check the full code
Compare your final widget handler plus test suite with these required behaviors:
- The widget accepts a valid display-text payload from http://localhost:3000.
- The widget rejects a message from http://localhost:3002 without changing its displayed text.
- The widget rejects a malformed message from http://localhost:3000 without changing its displayed text.
- The complete message-handler test suite reports three passing tests.
Your test suite now protects the widget's accepted path plus both rejection paths. Next, you will remove the origin check on purpose to prove the suite catches the security regression.
Prove the Security Test Fails
Your message-handler suite is green after proving the trusted path works. Its other tests protect the two rejection paths.
A regression test earns trust when it turns red after the guarded behavior breaks. This step introduces that break on purpose so you can see the wrong-origin test catch it.
In this step, get ready to:
- Prove the wrong-origin test turns red after origin validation disappears.
- Return the widget to its secure passing state.
Introduce a controlled regression
The origin guard separates the trusted parent from the attacker page. Removing that guard creates the exact vulnerability your wrong-origin test is meant to detect.
- Return to the widget message-handler file from earlier.
- Use your editor search to find event.origin.
- Temporarily remove only the branch that rejects messages when event.origin differs from http://localhost:3000.
- Save the widget message-handler file.
Before you rerun the suite, do you think the wrong-origin case can stay green without its origin guard?
- Rerun the complete three-test suite with the same test command you used earlier.
The wrong-origin test should turn red. Its assertion fails because the widget accepts the message from http://localhost:3002.
The widget text changes when the test expected it to stay untouched. The allowed-message test should stay green.
The malformed-message test should also stay green. That red test is the evidence you wanted.
Why the Red Test Matters
This failure proves the suite can detect an origin-validation regression. A vulnerable handler cannot slip through with a fully green test run.
The failing assertion connects the security rule to an observable consequence. An untrusted message changed widget text that should have remained protected.
Wrong-Origin Test Still Passing?
- Confirm you saved the widget message-handler file that the tests load.
- Check that the branch comparing event.origin with http://localhost:3000 is temporarily absent.
- Rerun the complete suite without limiting the run to a different test.
Help me find why the wrong-origin test stayed green.
Restore the secure handler
The red test has completed its job. The widget now needs its origin guard back before the project can return to a secure baseline.
- Return to the same location in the widget message-handler file.
- Restore the branch that rejects messages when event.origin differs from http://localhost:3000.
- Save the widget message-handler file.
Before you rerun the suite, do you expect the wrong-origin test to return to green after the guard is restored?
- Rerun the complete three-test suite with the same test command you used earlier.
You should see all three tests pass. The allowed message updates the widget.
The wrong-origin message leaves the widget text unchanged. The malformed message also leaves the widget text unchanged.
You have closed the loop. Your suite now proves the vulnerability is detected while the restored handler keeps both rejection paths secure.
Tests Not All Green Again?
- Confirm the restored comparison uses http://localhost:3000 as the exact allowed origin.
- Check that the malformed-payload rejection remains in the handler.
- Save the restored handler before rerunning the complete suite.
Help me restore the three passing message-handler tests.
Your widget is secure again. The passing suite now carries proof that its origin check protects real behavior.
Secret mission
Catch a Lookalike Origin Bug
A malicious origin can look almost identical to a trusted one. Add a temporary lookalike-origin test to expose why prefix matching is unsafe, then restore the secure three-test baseline.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the local demo running, pause its server processes, or delete the project folder. Everything runs locally, so there are no ongoing costs.
Resources you used:
- Running local process: the parent page server at http://localhost:3000.
- Running local process: the widget iframe server at http://localhost:3001.
- Running local process: the attacker page server at http://localhost:3002.
- Local files: the project folder currently open in your code editor.
Keep everything running
No action needed. Choose this if you are still testing the widget's message handler in your browser.
The three local pages remain available at their current origins. Your restored origin check and passing test suite stay ready for future experiments.
Keeping these local resources costs nothing.
Pause - I'll come back to this later
Shut down the three running server processes to free up local resources. Your project files and test suite remain available for your next session.
- Stop the parent page server by pressing Ctrl+C in its terminal.
- Stop the widget iframe server by pressing Ctrl+C in its terminal.
- Stop the attacker page server by pressing Ctrl+C in its terminal.
You should see each server process end. The terminal prompts become available again.
Your project folder stays intact, including the restored origin check and the three passing tests.
Delete - I don't want to use this again
Remove the local runtime resources and project files to start fresh. Deleting the folder removes your reusable widget test template, so choose this only when you no longer need it.
- Stop the parent page server by pressing Ctrl+C in its terminal.
- Stop the widget iframe server by pressing Ctrl+C in its terminal.
- Stop the attacker page server by pressing Ctrl+C in its terminal.
- Locate the project folder that remains open in your code editor by using your operating system's file manager.
- Delete that project folder by using your file manager's standard delete action.
- Confirm the project folder no longer appears in its previous location.
The three local origins stop responding once their server processes end. The project files and installed test dependencies are removed with the folder.
Nice Work!
Nice Work!
Great work! You built a repeatable iframe security lab where trusted messages update the widget while untrusted or malformed messages are rejected. Your deliberate regression made the wrong-origin test fail before the restored check returned all three tests to green.
You've learned how to:
- Create a working iframe message exchange where a parent page updates visible widget text through the browser postMessage API.
- Protect a widget with origin validation plus payload validation for untrusted or malformed messages.
- Build an automated test suite that proves valid messages work. Trigger an intentional security regression that makes the wrong-origin test fail.
- Secret Mission: Take on an optional challenge to push your message-handler testing skills further.
Ready to quiz yourself?