feat(smtp): fix DATA state machine; add SMTP_OPEN_RELAY mode

- Buffer DATA body until CRLF.CRLF terminator — fixes 502-on-every-body-line bug
- SMTP_OPEN_RELAY=1: AUTH accepted (235), RCPT TO accepted for any domain,
  full DATA pipeline with queued-as message ID
- Default (SMTP_OPEN_RELAY=0): credential harvester — AUTH rejected (535)
  but connection stays open, RCPT TO returns 554 relay denied
- SASL PLAIN and LOGIN multi-step AUTH both decoded and logged
- RSET clears all per-transaction state
- Add development/SMTP_RELAY.md, IMAP_BAIT.md, ICS_SCADA.md, BUG_FIXES.md
  (live-tested service realism plans)
This commit is contained in:
2026-04-10 01:03:47 -04:00
parent 40cd582253
commit 94f82c9089
6 changed files with 1463 additions and 30 deletions

333
development/BUG_FIXES.md Normal file
View File

@@ -0,0 +1,333 @@
# Bug Fixes — Non-Feature Realism Issues
> These are fingerprint leaks and broken protocol handlers that don't need new
> interaction design — just targeted fixes. All severity High or above from REALISM_AUDIT.md.
---
## 1. HTTP — Werkzeug header leak (High)
### Problem
Every response has two `Server:` headers:
```
Server: Werkzeug/3.1.3 Python/3.11.2
Server: Apache/2.4.54 (Debian)
```
nmap correctly IDs Apache from the second header, but any attacker that does
`curl -I` or runs Burp sees the Werkzeug leak immediately. Port 6443 (K8s) also
leaks Werkzeug in every response.
### Fix — WSGI middleware to strip/replace the header
In `templates/http/server.py` (Flask app), add a `@app.after_request` hook:
```python
@app.after_request
def _fix_server_header(response):
response.headers["Server"] = os.environ.get("HTTP_SERVER_HEADER", "Apache/2.4.54 (Debian)")
return response
```
Flask sets `Server: Werkzeug/...` by default. The `after_request` hook runs after
Werkzeug's own header injection, so it overwrites it.
Same fix applies to the K8s server if it's also Flask-based.
### Fix — Apache 403 page body
Current response body: `<h1>403 Forbidden</h1>`
Replace with the actual Apache 2.4 default 403 page:
```html
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.54 (Debian) Server at {hostname} Port 80</address>
</body></html>
```
Env var `HTTP_SERVER_HEADER` and `NODE_NAME` fill the address line.
### Env vars
| Var | Default |
|-----|---------|
| `HTTP_SERVER_HEADER` | `Apache/2.4.54 (Debian)` |
---
## 2. FTP — Twisted banner (High)
### Problem
```
220 Twisted 25.5.0 FTP Server
```
This is Twisted's built-in FTP server banner. Immediately identifies the framework.
### Fix
Override the banner. The Twisted FTP server class has a `factory.welcomeMessage` or
the protocol's `sendLine()` for the greeting. Simplest fix: subclass the protocol
and override `lineReceived` to intercept the `220` line before it goes out, OR
use a `_FTPFactory` subclass that sets `welcomeMessage`:
```python
from twisted.protocols.ftp import FTPFactory, FTPAnonymousShell
from twisted.internet import reactor
import os
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
BANNER = os.environ.get("FTP_BANNER", f"220 (vsFTPd 3.0.3)")
factory = FTPFactory(portal)
factory.welcomeMessage = BANNER # overrides the Twisted default
```
If `FTPFactory.welcomeMessage` is not directly settable, patch it at class level:
```python
FTPFactory.welcomeMessage = BANNER
```
### Anonymous login + fake directory
The current server rejects everything after login. Fix:
- Use `FTPAnonymousShell` pointed at a `MemoryFilesystem` with fake entries:
```
/
├── backup.tar.gz (0 bytes, but listable)
├── db_dump.sql (0 bytes)
├── config.ini (0 bytes)
└── credentials.txt (0 bytes)
```
- `RETR` any file → return 13 lines of plausible fake content, then close.
- Log every `RETR` with filename and client IP.
### Env vars
| Var | Default |
|-----|---------|
| `FTP_BANNER` | `220 (vsFTPd 3.0.3)` |
---
## 3. MSSQL — Silent on TDS pre-login (High)
### Problem
No response to standard TDS pre-login packets. Connection is dropped silently.
nmap barely recognizes port 1433 (`ms-sql-s?`).
### Diagnosis
The nmap fingerprint shows `\x04\x01\x00\x2b...` which is a valid TDS 7.x pre-login
response fragment. So the server is sending _something_ — but it may be truncated or
malformed enough that nmap can't complete its probe.
Check `templates/mssql/server.py`: look for the raw bytes being sent in response to
`\x12\x01` (TDS pre-login type). Common bugs:
- Wrong packet length field (bytes 2-3 of TDS header)
- Missing `\xff` terminator on the pre-login option list
- Status byte 0x01 instead of 0x00 in the TDS header (signaling last packet)
### Correct TDS 7.x pre-login response structure
```
Byte 0: 0x04 (packet type: tabular result)
Byte 1: 0x01 (status: last packet)
Bytes 2-3: 0x00 0x2b (total length including header = 43)
Bytes 4-5: 0x00 0x00 (SPID)
Byte 6: 0x01 (packet ID)
Byte 7: 0x00 (window)
--- TDS pre-login payload ---
[VERSION] option: type=0x00, offset=0x001a, length=0x0006
[ENCRYPTION] option: type=0x01, offset=0x0020, length=0x0001
[INSTOPT] option: type=0x02, offset=0x0021, length=0x0001
[THREADID] option: type=0x03, offset=0x0022, length=0x0004
[MARS] option: type=0x04, offset=0x0026, length=0x0001
Terminator: 0xff
VERSION: 0x0e 0x00 0x07 0xd0 0x00 0x00 (14.0.2000 = SQL Server 2017)
ENCRYPTION: 0x02 (ENCRYPT_NOT_SUP)
INSTOPT: 0x00
THREADID: 0x00 0x00 0x00 0x00
MARS: 0x00
```
Verify the current implementation's bytes match this exactly. Fix the length field if off.
---
## 4. MongoDB — Silent on OP_MSG (High)
### Problem
No response to `OP_MSG isMaster` command. nmap shows `mongod?` (partial recognition).
### Diagnosis
MongoDB wire protocol since 3.6 uses `OP_MSG` (opcode 2013). Older clients use
`OP_QUERY` (opcode 2004) against `admin.$cmd`. Check which one `templates/mongodb/server.py`
handles, and whether the response's `responseTo` field matches the request's `requestID`.
Common bugs:
- Handling `OP_QUERY` but not `OP_MSG`
- Wrong `responseTo` in the response header (must echo the request's requestID)
- Missing `flagBits` field in OP_MSG response (must be 0x00000000)
### Correct OP_MSG `hello` response
```python
import struct, bson
def _op_msg_hello_response(request_id: int) -> bytes:
doc = {
"ismaster": True,
"maxBsonObjectSize": 16777216,
"maxMessageSizeBytes": 48000000,
"maxWriteBatchSize": 100000,
"localTime": {"$date": int(time.time() * 1000)},
"logicalSessionTimeoutMinutes": 30,
"connectionId": 1,
"minWireVersion": 0,
"maxWireVersion": 17,
"readOnly": False,
"ok": 1.0,
}
payload = b"\x00" + bson.encode(doc) # section type 0 = body
flag_bits = struct.pack("<I", 0)
msg_body = flag_bits + payload
# MsgHeader: totalLength(4) + requestID(4) + responseTo(4) + opCode(4)
header = struct.pack("<iiii",
16 + len(msg_body), # total length
1, # requestID (server-generated)
request_id, # responseTo: echo the client's requestID
2013, # OP_MSG
)
return header + msg_body
```
---
## 5. Redis — Empty keyspace (Medium)
### Problem
`KEYS *` returns `*0\r\n` after a successful AUTH. A real exposed Redis always has data.
Attacker does `AUTH anypassword` → `+OK` → `KEYS *` → empty → leaves.
### Fix — fake key-value store
Add a module-level dict with bait data. Handle `KEYS`, `GET`, `SCAN`, `TYPE`, `TTL`:
```python
_FAKE_STORE = {
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024",
b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}',
b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}',
b"config:db_password": b"Pr0dDB!2024#Secure",
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
b"rate_limit:192.168.1.1": b"42",
}
```
Commands to handle:
- `KEYS *` → all keys as RESP array
- `KEYS pattern` → filtered (basic glob: `*` matches all, `prefix:*` matches prefix)
- `GET key` → value or `$-1` (nil)
- `SCAN 0` → `*2\r\n$1\r\n0\r\n` + keys array (cursor always 0, return all)
- `TYPE key` → `+string\r\n`
- `TTL key` → `:-1\r\n` (no expiry)
---
## 6. SIP — Hardcoded nonce (Low)
### Problem
`nonce="decnet0000"` is hardcoded. A Shodan signature could detect this string.
### Fix
```python
import secrets
nonce = secrets.token_hex(16) # e.g. "a3f8c1b2e7d94051..."
```
Generate once per connection in `connection_made`. The WWW-Authenticate header
becomes: `Digest realm="{NODE_NAME}", nonce="{nonce}", algorithm=MD5`
---
## 7. VNC — Hardcoded DES challenge (Low)
### Problem
The 16-byte DES challenge sent during VNC auth negotiation is static.
### Fix
```python
import os
self._vnc_challenge = os.urandom(16)
```
Generate in `connection_made`. Send `self._vnc_challenge` in the Security handshake.
---
## 8. PostgreSQL — Hardcoded salt (Low)
### Problem
`AuthenticationMD5Password` response contains `\xde\xad\xbe\xef` as the 4-byte salt.
### Fix
```python
import os
self._pg_salt = os.urandom(4)
```
Use `self._pg_salt` in the `R\x00\x00\x00\x0c\x00\x00\x00\x05` response bytes.
---
## Files to change
| File | Change |
|------|--------|
| `templates/http/server.py` | `after_request` header fix, proper 403 body |
| `templates/ftp/server.py` | Banner override, anonymous login, fake dir |
| `templates/mssql/server.py` | Fix TDS pre-login response bytes |
| `templates/mongodb/server.py` | Add OP_MSG handler, fix responseTo |
| `templates/redis/server.py` | Add fake key-value store, KEYS/GET/SCAN |
| `templates/sip/server.py` | Random nonce per connection |
| `templates/vnc/server.py` | Random DES challenge per connection |
| `templates/postgres/server.py` | Random MD5 salt per connection |
| `tests/test_http_headers.py` | New: assert single Server header, correct 403 body |
| `tests/test_redis.py` | Extend: KEYS *, GET, SCAN return bait data |
---
## Priority order
1. HTTP header leak — immediately visible to any attacker
2. FTP banner — immediate framework disclosure
3. MSSQL silent — service appears dead
4. MongoDB silent — service appears dead
5. Redis empty keyspace — breaks the bait value proposition
6. SIP/VNC/PostgreSQL hardcoded values — low risk, quick wins

232
development/ICS_SCADA.md Normal file
View File

@@ -0,0 +1,232 @@
# ICS/SCADA Bait — Plan
> Scenario: attacker finds MQTT broker on a water treatment plant, subscribes to
> sensor topics, publishes commands trying to "open the valve" or "disable chlorination".
---
## Services in scope
| Service | Port | Current state | Target state |
|---------|------|--------------|-------------|
| MQTT | 1883 | CONNACK 0x05 (reject all) | CONNACK 0x00, fake sensor topics |
| SNMP | 161/UDP | Functional, generic sysDescr | sysDescr tuned per archetype |
| Conpot | 502 | Not responding | Investigate + fix port mapping |
---
## MQTT — water plant persona
### Current behavior
Every CONNECT gets `CONNACK 0x05` (Not Authorized) and the connection is closed.
An ICS attacker immediately moves on — there's nothing to interact with.
### Target behavior
Accept all connections (`CONNACK 0x00`). Publish retained sensor data on
realistic SCADA topics. Log every PUBLISH command (attacker trying to control plant).
### Topic tree
```
plant/water/tank1/level → "73.4" (percent full)
plant/water/tank1/pressure → "2.81" (bar)
plant/water/pump1/status → "RUNNING"
plant/water/pump1/rpm → "1420"
plant/water/pump2/status → "STANDBY"
plant/water/chlorine/dosing → "1.2" (mg/L)
plant/water/chlorine/residual → "0.8" (mg/L)
plant/water/valve/inlet/state → "OPEN"
plant/water/valve/drain/state → "CLOSED"
plant/alarm/high_pressure → "0"
plant/alarm/low_chlorine → "0"
plant/alarm/pump_fault → "0"
plant/$SYS/broker/version → "Mosquitto 2.0.15"
plant/$SYS/broker/uptime → "2847392 seconds"
```
All topics have `retain=True` so subscribers immediately receive the last value.
### Protocol changes needed
Add handling for:
- **SUBSCRIBE (pkt_type=8)**: Parse topic filter + QoS pairs. For each matching topic,
send SUBACK then immediately send a PUBLISH with the retained value.
- **PUBLISH (pkt_type=3)**: Log the topic + payload (this is the attacker "sending a command").
Return PUBACK for QoS 1. Do NOT update the retained value (the plant ignores the command).
- **PINGREQ (pkt_type=12)**: Already handled. Keep alive.
- **DISCONNECT (pkt_type=14)**: Close cleanly.
Do NOT implement: UNSUBSCRIBE, QoS 2. Return SUBACK with QoS 1 for all subscriptions.
### CONNACK change
```python
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00" # session_present=0, return_code=0
```
### Env vars
| Var | Default | Description |
|-----|---------|-------------|
| `MQTT_PERSONA` | `water_plant` | Topic tree preset |
| `MQTT_ACCEPT_ALL` | `1` | Accept all connections |
| `NODE_NAME` | `mqtt-broker` | Hostname in logs |
---
## SUBSCRIBE packet parsing
```python
def _parse_subscribe(payload: bytes):
"""Returns (packet_id, [(topic, qos), ...])"""
pos = 0
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
pos += 2
topics = []
while pos < len(payload):
topic, pos = _read_utf8(payload, pos)
qos = payload[pos] & 0x03
pos += 1
topics.append((topic, qos))
return packet_id, topics
```
### SUBACK
```python
def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
payload = struct.pack(">H", packet_id) + bytes(granted_qos)
return bytes([0x90, len(payload)]) + payload
```
### PUBLISH (server → client, retained)
```python
def _publish(topic: str, value: str, retain: bool = True) -> bytes:
topic_bytes = topic.encode()
topic_len = struct.pack(">H", len(topic_bytes))
payload = value.encode()
# Fixed header: type=3, retain flag, no QoS (fire and forget for retained)
fixed = 0x31 if retain else 0x30
remaining = len(topic_len) + len(topic_bytes) + len(payload)
return bytes([fixed, remaining]) + topic_len + topic_bytes + payload
```
---
## SNMP — sysDescr per archetype
Current `sysDescr` is a generic Linux string. It should reflect the decky's persona.
### Archetype strings
| Archetype | sysDescr |
|-----------|---------|
| water_plant | `Linux scada-plc01 4.19.0-18-amd64 #1 SMP Debian 4.19.208-1 (2021-09-29) x86_64` |
| factory | `VxWorks 6.9 (Rockwell Automation Allen-Bradley ControlLogix 5580)` |
| substation | `SEL Real-Time Automation Controller RTAC SEL-3555 firmware 1.9.7.0` |
| hospital | `Linux medlogic-srv01 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 x86_64` |
| default | `Linux decky-host 5.15.0-91-generic #101-Ubuntu SMP Tue Nov 14 13:30:08 UTC 2023 x86_64` |
Env var `SNMP_ARCHETYPE` selects the string. The SNMP server should also tune:
- `sysContact.0``ICS Admin <ics-admin@plant.local>`
- `sysLocation.0``Water Treatment Facility — Pump Room B`
- `sysName.0``scada-plc01` (from `NODE_NAME`)
---
## Conpot — Modbus TCP (port 502)
### Current state
Port 502 shows `CLOSED` in nmap. Conpot is deployed as a service container but
is either not binding to 502 or the port mapping is wrong.
### Diagnosis steps
1. Check the compose fragment: `decnet services conpot` — what port does it expose?
2. `docker exec decky-01-conpot netstat -tlnp` or `ss -tlnp` — is Conpot listening on 502?
3. Check Conpot's default config — it may listen on a non-standard port (e.g. 5020) and
expect a host-level iptables REDIRECT rule to map 502 → 5020.
### Fix options
**Option A** (preferred): Configure Conpot to listen directly on 502 by editing its
`default.xml` template and setting `<port>502</port>`.
**Option B**: Add `iptables -t nat -A PREROUTING -p tcp --dport 502 -j REDIRECT --to-port 5020`
to the base container entrypoint. Fragile — prefer A.
### What Modbus should respond
Conpot's default Modbus template already implements a plausible PLC. The key registers
to tune for water-plant persona:
| Register | Address | Value | Description |
|----------|---------|-------|-------------|
| Coil | 0 | 1 | Pump 1 running |
| Coil | 1 | 0 | Pump 2 standby |
| Holding | 0 | 734 | Tank level (73.4%) |
| Holding | 1 | 281 | Pressure (2.81 bar × 100) |
| Holding | 2 | 12 | Chlorine dosing (1.2 mg/L × 10) |
These values should be consistent with the MQTT topic tree so an attacker who
probes both sees a coherent picture.
---
## Log events
### MQTT
| event_type | Fields | Trigger |
|------------|--------|---------|
| `connect` | src, src_port, client_id, username | CONNECT packet |
| `subscribe` | src, topics | SUBSCRIBE packet |
| `publish` | src, topic, payload | PUBLISH from client (attacker command!) |
| `disconnect` | src | DISCONNECT or connection lost |
### SNMP
No changes to event structure — sysDescr is just a config string.
---
## Files to change
| File | Change |
|------|--------|
| `templates/mqtt/server.py` | Accept connections, SUBSCRIBE handler, retained PUBLISH, PUBLISH log |
| `templates/snmp/server.py` | Add `SNMP_ARCHETYPE` env var, tune sysDescr/sysContact/sysLocation |
| `templates/conpot/` | Investigate port config, fix 502 binding |
| `tests/test_mqtt.py` | New: connect accepted, subscribe → retained publish, attacker publish logged |
| `tests/test_snmp.py` | Extend: sysDescr per archetype |
---
## Verification against live decky
```bash
# MQTT: connect and subscribe
mosquitto_sub -h 192.168.1.200 -t "plant/#" -v
# Expected output:
# plant/water/tank1/level 73.4
# plant/water/pump1/status RUNNING
# ...
# MQTT: attacker sends a command (should be logged)
mosquitto_pub -h 192.168.1.200 -t "plant/water/valve/inlet/state" -m "CLOSED"
# Modbus: read coil 0 (pump status)
# (requires mbpoll or similar)
mbpoll -a 1 -r 1 -c 2 192.168.1.200
# SNMP: sysDescr check
snmpget -v2c -c public 192.168.1.200 1.3.6.1.2.1.1.1.0
# Expected: STRING: "Linux scada-plc01 4.19.0..."
```

220
development/IMAP_BAIT.md Normal file
View File

@@ -0,0 +1,220 @@
# IMAP Bait Mailboxes — Plan
> Scenario: attacker credential-stuffs IMAP, logs in as `admin`/`admin`,
> browses mail, finds juicy internal communications and credential leaks.
---
## Current state
Both IMAP and POP3 reject **all** credentials with a hard-coded failure.
No mailbox commands are implemented. An attacker that successfully guesses
credentials (which they can't, ever) would have nothing to read anyway.
This is the biggest missed opportunity in the whole stack.
---
## Design
### Credential policy
Accept a configurable set of username/password pairs. Defaults baked into
the image — typical attacker wordlist winners:
```
admin / admin
admin / password
admin / 123456
root / root
mail / mail
user / user
```
Env var override: `IMAP_USERS=admin:admin,root:toor,user:letmein`
Wrong credentials → `NO [AUTHENTICATIONFAILED] Invalid credentials` (log the attempt).
Right credentials → `OK` + full session.
### Fake mailboxes
One static mailbox tree, same for all users (honeypot doesn't need per-user isolation):
```
INBOX (12 messages)
Sent (8 messages)
Drafts (1 message)
Archive (3 messages)
```
### Bait email content
Bait emails are seeded at startup from a `MAIL_SEED` list embedded in the server.
Content is designed to reward the attacker for staying in the session:
**INBOX messages (selected)**
| # | From | Subject | Bait payload |
|---|------|---------|-------------|
| 1 | devops@company.internal | AWS credentials rotation | `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` |
| 2 | monitoring@company.internal | DB password changed | `mysql://admin:Sup3rS3cr3t!@10.0.1.5:3306/production` |
| 3 | noreply@github.com | Your personal access token | `ghp_16C7e42F292c6912E7710c838347Ae178B4a` |
| 4 | admin@company.internal | VPN config attached | `vpn.company.internal:1194 user=vpnadmin pass=VpnP@ss2024` |
| 5 | sysadmin@company.internal | Root password | New root pw: `r00tM3T00!` — change after first login |
| 6 | backup@company.internal | Backup job failed | Backup to `192.168.1.50:/mnt/nas` — credentials in /etc/backup.conf |
| 7 | alerts@company.internal | SSH brute-force alert | 47 attempts from 185.220.101.x against root — all blocked |
**Sent messages**
| # | To | Subject | Bait payload |
|---|-----|---------|-------------|
| 1 | vendor@external.com | API credentials | API key: `sk_live_xK3mF2...9aP` |
| 2 | helpdesk@company.internal | Need access reset | My password is `Winter2024!` — please reset MFA |
**Drafts**
| # | Subject | Bait payload |
|---|---------|-------------|
| 1 | DO NOT SEND - k8s secrets | `kubectl get secret admin-token -n kube-system -o yaml` output pasted in |
---
## Protocol implementation
### IMAP4rev1 commands to implement
```
CAPABILITY → * CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN
LOGIN → authenticate or reject
SELECT → select INBOX / Sent / Drafts / Archive
LIST → return folder tree
LSUB → same as LIST (subscribed)
STATUS → return EXISTS / RECENT / UNSEEN for a mailbox
FETCH → return message headers or full body
UID FETCH → same with UID addressing
SEARCH → stub: return all UIDs (we don't need real search)
EXAMINE → read-only SELECT
CLOSE → deselect current mailbox
LOGOUT → BYE + OK
NOOP → OK
```
Commands NOT needed (return `BAD`): `STORE`, `COPY`, `APPEND`, `EXPUNGE`.
Attackers rarely run these. Logging `BAD` is fine if they do.
### Banner
Change from:
```
* OK [omega-decky] IMAP4rev1 Service Ready
```
To:
```
* OK Dovecot ready.
```
nmap currently says "(unrecognized)". Dovecot banner makes it ID correctly.
### CAPABILITY advertisement
```
* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN
```
### SELECT response
```
* 12 EXISTS
* 0 RECENT
* OK [UNSEEN 7] Message 7 is first unseen
* OK [UIDVALIDITY 1712345678] UIDs valid
* OK [UIDNEXT 13] Predicted next UID
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
* OK [PERMANENTFLAGS (\Deleted \Seen \*)] Limited
A3 OK [READ-WRITE] SELECT completed
```
### FETCH envelope/body
Message structs stored as Python dataclasses at startup. `FETCH 1:* (FLAGS ENVELOPE)` returns
envelope tuples in RFC 3501 format. `FETCH N BODY[]` returns the raw RFC 2822 message.
---
## POP3 parity
POP3 is much simpler. Same credential list. After successful PASS:
```
STAT → +OK 12 48000 (12 messages, total ~48 KB)
LIST → +OK 12 messages\r\n1 3912\r\n2 2048\r\n...\r\n.
RETR N → +OK <size>\r\n<raw message>\r\n.
TOP N L → +OK\r\n<first L body lines>\r\n.
UIDL → +OK\r\n1 <uid>\r\n...\r\n.
DELE N → +OK Message deleted (just log it, don't actually remove)
CAPA → +OK\r\nTOP\r\nUSER\r\nUIDL\r\nRESP-CODES\r\nAUTH-RESP-CODE\r\nSASL\r\n.
```
---
## State machine (IMAP)
```
NOT_AUTHENTICATED
→ LOGIN success → AUTHENTICATED
→ LOGIN fail → NOT_AUTHENTICATED (log, stay open for retries)
AUTHENTICATED
→ SELECT / EXAMINE → SELECTED
→ LIST / LSUB / STATUS / LOGOUT / NOOP → stay AUTHENTICATED
SELECTED
→ FETCH / UID FETCH / SEARCH / EXAMINE / SELECT → stay SELECTED
→ CLOSE / LOGOUT → AUTHENTICATED or closed
```
---
## Files to change
| File | Change |
|------|--------|
| `templates/imap/server.py` | Full rewrite: state machine, credential check, mailbox commands, bait emails |
| `templates/pop3/server.py` | Extend: credential check, STAT/LIST/RETR/UIDL/TOP/DELE/CAPA |
| `tests/test_imap.py` | New: login flow, SELECT, FETCH, bad creds, all mailboxes |
| `tests/test_pop3.py` | New: login flow, STAT, LIST, RETR, CAPA |
---
## Implementation notes
- All bait emails are hardcoded Python strings — no files to load, no I/O.
- Use a module-level `MESSAGES: list[dict]` list with fields: `uid`, `flags`, `size`, `date`,
`from_`, `to`, `subject`, `body` (full RFC 2822 string).
- `_format_envelope()` builds the IMAP ENVELOPE tuple string from the message dict.
- Thread safety: all state per-connection in the Protocol class. No shared mutable state.
---
## Env vars
| Var | Default | Description |
|-----|---------|-------------|
| `IMAP_USERS` | `admin:admin,root:root,mail:mail` | Accepted credentials (user:pass,...) |
| `IMAP_BANNER` | `* OK Dovecot ready.` | Greeting line |
| `NODE_NAME` | `mailserver` | Hostname in responses |
---
## Verification against live decky
```bash
# Credential test (should accept)
printf "A1 LOGIN admin admin\r\nA2 SELECT INBOX\r\nA3 FETCH 1:3 (FLAGS ENVELOPE)\r\nA4 FETCH 5 BODY[]\r\nA5 LOGOUT\r\n" | nc 192.168.1.200 143
# Credential test (should reject)
printf "A1 LOGIN admin wrongpass\r\n" | nc 192.168.1.200 143
# nmap fingerprint check (expect "Dovecot imapd")
nmap -p 143 -sV 192.168.1.200
```

195
development/SMTP_RELAY.md Normal file
View File

@@ -0,0 +1,195 @@
# SMTP Open Relay — Plan
> Priority: **P0** — DATA handler is broken (502 on every body line).
> Scenario: attacker finds open relay, sends mail through it.
---
## What's broken today
`templates/smtp/server.py` sends `354 End data with <CR><LF>.<CR><LF>` on `DATA`, then
falls back to `_handle_line()` for every subsequent line. Because those lines don't start
with a recognized SMTP verb, every line gets:
```
502 5.5.2 Error: command not recognized
```
The session never completes. The message is silently dropped.
---
## Fix: DATA state machine
Add a `_in_data` flag. Once `DATA` is received, accumulate raw body lines until the
terminator `\r\n.\r\n`. On terminator: log the message, return `250`, flip flag back.
### State variables added to `SMTPProtocol.__init__`
```python
self._in_data = False
self._data_buf = [] # accumulate body lines
self._mail_from = ""
self._rcpt_to = []
```
### Modified `data_received`
No change — still splits on `\r\n`.
### Modified `_handle_line`
```python
def _handle_line(self, line: str) -> None:
# DATA body accumulation mode
if self._in_data:
if line == ".":
# end of message
body = "\r\n".join(self._data_buf)
msg_id = _rand_msg_id()
_log("message_accepted",
src=self._peer[0],
mail_from=self._mail_from,
rcpt_to=",".join(self._rcpt_to),
body_bytes=len(body),
msg_id=msg_id)
self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
self._in_data = False
self._data_buf = []
else:
# RFC 5321 dot-stuffing: leading dot means literal dot, strip it
self._data_buf.append(line[1:] if line.startswith("..") else line)
return
cmd = line.split()[0].upper() if line.split() else ""
# ... existing handlers ...
elif cmd == "MAIL":
self._mail_from = line.split(":", 1)[1].strip() if ":" in line else line
_log("mail_from", src=self._peer[0], value=self._mail_from)
self._transport.write(b"250 2.0.0 Ok\r\n")
elif cmd == "RCPT":
rcpt = line.split(":", 1)[1].strip() if ":" in line else line
self._rcpt_to.append(rcpt)
_log("rcpt_to", src=self._peer[0], value=rcpt)
self._transport.write(b"250 2.1.5 Ok\r\n")
elif cmd == "DATA":
if not self._mail_from or not self._rcpt_to:
self._transport.write(b"503 5.5.1 Error: need MAIL command\r\n")
else:
self._in_data = True
self._transport.write(b"354 End data with <CR><LF>.<CR><LF>\r\n")
elif cmd == "RSET":
self._mail_from = ""
self._rcpt_to = []
self._in_data = False
self._data_buf = []
self._transport.write(b"250 2.0.0 Ok\r\n")
```
### Helper
```python
import random, string
def _rand_msg_id() -> str:
"""Return a Postfix-style 12-char hex queue ID."""
return "".join(random.choices("0123456789ABCDEF", k=12))
```
---
## Open relay behavior
The current server already returns `250 2.1.5 Ok` for any `RCPT TO` regardless of domain.
That's correct — do NOT gate on the domain. The attacker's goal is to relay spam. We let
them "succeed" and log everything.
Remove the `AUTH` rejection + close. An open relay doesn't require authentication. Replace:
```python
elif cmd == "AUTH":
_log("auth_attempt", src=self._peer[0], command=line)
self._transport.write(b"535 5.7.8 Error: authentication failed: ...\r\n")
self._transport.close()
```
With:
```python
elif cmd == "AUTH":
# Log the attempt but advertise that auth succeeds (open relay bait)
_log("auth_attempt", src=self._peer[0], command=line)
self._transport.write(b"235 2.7.0 Authentication successful\r\n")
```
Some scanners probe AUTH before DATA. Accepting it keeps them engaged.
---
## Banner / persona
Current banner is already perfect: `220 omega-decky ESMTP Postfix (Debian/GNU)`.
The `SMTP_BANNER` env var lets per-decky customization happen at deploy time via the
persona config — no code change needed.
---
## Log events emitted
| event_type | Fields |
|------------------|---------------------------------------------------|
| `connect` | src, src_port |
| `ehlo` | src, domain |
| `auth_attempt` | src, command |
| `mail_from` | src, value |
| `rcpt_to` | src, value (one event per recipient) |
| `message_accepted` | src, mail_from, rcpt_to, body_bytes, msg_id |
| `disconnect` | src |
---
## Files to change
| File | Change |
|------|--------|
| `templates/smtp/server.py` | DATA state machine, open relay AUTH accept, RSET fix |
| `tests/test_smtp.py` | New: DATA → 250 flow, multi-recipient, dot-stuffing, RSET |
---
## Test cases (pytest)
```python
# full send flow
conn EHLO MAIL FROM RCPT TO DATA body lines "." 250 2.0.0 Ok: queued as ...
# multi-recipient
RCPT TO x3 DATA body "." 250
# dot-stuffing
..real dot body line stored as ".real dot"
# RSET mid-session
MAIL FROM RCPT TO RSET assert _mail_from == "" and _rcpt_to == []
# AUTH accept
AUTH PLAIN base64 235
# 503 if DATA before MAIL
DATA (no prior MAIL) 503
```
---
## Verification against live decky
```bash
# Full relay test
printf "EHLO test.com\r\nMAIL FROM:<hacker@evil.com>\r\nRCPT TO:<admin@target.com>\r\nDATA\r\nSubject: hello\r\n\r\nBody line 1\r\nBody line 2\r\n.\r\nQUIT\r\n" | nc 192.168.1.200 25
# Expected final lines:
# 354 End data with ...
# 250 2.0.0 Ok: queued as <ID>
# 221 2.0.0 Bye
```

View File

@@ -1,22 +1,39 @@
#!/usr/bin/env python3
"""
SMTP server — emulates a realistic ESMTP server (Postfix-style).
Logs EHLO/AUTH/MAIL FROM/RCPT TO attempts as JSON, then denies auth.
Two modes of operation, controlled by SMTP_OPEN_RELAY:
SMTP_OPEN_RELAY=0 (default) — credential harvester
AUTH attempts are logged and rejected (535).
RCPT TO is rejected with 554 (relay denied) for all recipients.
This captures credential stuffing and scanning activity.
SMTP_OPEN_RELAY=1 — open relay bait
AUTH is accepted for any credentials (235).
RCPT TO is accepted for any domain (250).
DATA is fully buffered until CRLF.CRLF and acknowledged with a
queued-as message ID. Attractive to spam relay operators.
The DATA state machine (and the 502-per-line bug) is fixed in both modes.
"""
import asyncio
import base64
import os
from decnet_logging import syslog_line, write_syslog_file, forward_syslog
import random
import string
from decnet_logging import SEVERITY_WARNING, syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
SERVICE_NAME = "smtp"
SERVICE_NAME = "smtp"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
OPEN_RELAY = os.environ.get("SMTP_OPEN_RELAY", "0").strip() == "1"
_SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)")
_SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME)
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
print(line, flush=True)
@@ -24,11 +41,42 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
forward_syslog(line, LOG_TARGET)
def _rand_msg_id() -> str:
"""Return a Postfix-style 12-char alphanumeric queue ID."""
chars = string.ascii_uppercase + string.digits
return "".join(random.choices(chars, k=12)) # noqa: S311
def _decode_auth_plain(blob: str) -> tuple[str, str]:
"""Decode SASL PLAIN: base64(authzid\0authcid\0passwd) → (user, pass)."""
try:
decoded = base64.b64decode(blob + "==").decode(errors="replace")
parts = decoded.split("\x00")
if len(parts) >= 3:
return parts[1], parts[2]
if len(parts) == 2:
return parts[0], parts[1]
except Exception:
pass
return blob, ""
class SMTPProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = ("?", 0)
self._buf = b""
self._peer = ("?", 0)
self._buf = b""
# per-transaction state
self._mail_from = ""
self._rcpt_to: list[str] = []
# DATA accumulation
self._in_data = False
self._data_buf: list[str] = []
# AUTH multi-step state (LOGIN mechanism sends user/pass in separate lines)
self._auth_state = "" # "" | "await_user" | "await_pass"
self._auth_user = ""
# ── asyncio.Protocol ──────────────────────────────────────────────────────
def connection_made(self, transport):
self._transport = transport
@@ -40,14 +88,55 @@ class SMTPProtocol(asyncio.Protocol):
self._buf += data
while b"\r\n" in self._buf:
line, self._buf = self._buf.split(b"\r\n", 1)
self._handle_line(line.decode(errors="replace").strip())
self._handle_line(line.decode(errors="replace"))
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
# ── Command dispatch ──────────────────────────────────────────────────────
def _handle_line(self, line: str) -> None:
cmd = line.split()[0].upper() if line.split() else ""
# ── DATA body accumulation ────────────────────────────────────────────
if self._in_data:
if line == ".":
body = "\r\n".join(self._data_buf)
msg_id = _rand_msg_id()
_log("message_accepted",
src=self._peer[0],
mail_from=self._mail_from,
rcpt_to=",".join(self._rcpt_to),
body_bytes=len(body),
msg_id=msg_id)
self._transport.write(f"250 2.0.0 Ok: queued as {msg_id}\r\n".encode())
self._in_data = False
self._data_buf = []
self._mail_from = ""
self._rcpt_to = []
else:
# RFC 5321 dot-stuffing: strip leading dot
self._data_buf.append(line[1:] if line.startswith(".") else line)
return
# ── AUTH multi-step (LOGIN mechanism) ─────────────────────────────────
if self._auth_state == "await_user":
self._auth_user = base64.b64decode(line + "==").decode(errors="replace")
self._auth_state = "await_pass"
self._transport.write(b"334 UGFzc3dvcmQ6\r\n") # "Password:"
return
if self._auth_state == "await_pass":
password = base64.b64decode(line + "==").decode(errors="replace")
self._finish_auth(self._auth_user, password)
self._auth_state = ""
self._auth_user = ""
return
# ── Normal command dispatch ───────────────────────────────────────────
parts = line.split(None, 1)
cmd = parts[0].upper() if parts else ""
args = parts[1] if len(parts) > 1 else ""
if cmd in ("EHLO", "HELO"):
domain = line.split(None, 1)[1] if " " in line else ""
_log("ehlo", src=self._peer[0], domain=domain)
_log("ehlo", src=self._peer[0], domain=args)
self._transport.write(
f"250-{_SMTP_MTA}\r\n"
f"250-PIPELINING\r\n"
@@ -59,41 +148,102 @@ class SMTPProtocol(asyncio.Protocol):
f"250-8BITMIME\r\n"
f"250 DSN\r\n".encode()
)
elif cmd == "AUTH":
_log("auth_attempt", src=self._peer[0], command=line)
self._transport.write(b"535 5.7.8 Error: authentication failed: UGFzc3dvcmQ6\r\n")
self._transport.close()
self._handle_auth(args)
elif cmd == "MAIL":
_log("mail_from", src=self._peer[0], value=line)
addr = args.split(":", 1)[1].strip() if ":" in args else args
self._mail_from = addr
_log("mail_from", src=self._peer[0], value=addr)
self._transport.write(b"250 2.1.0 Ok\r\n")
elif cmd == "RCPT":
_log("rcpt_to", src=self._peer[0], value=line)
self._transport.write(b"250 2.1.5 Ok\r\n")
addr = args.split(":", 1)[1].strip() if ":" in args else args
if OPEN_RELAY:
self._rcpt_to.append(addr)
_log("rcpt_to", src=self._peer[0], value=addr)
self._transport.write(b"250 2.1.5 Ok\r\n")
else:
_log("rcpt_denied", src=self._peer[0], value=addr,
severity=SEVERITY_WARNING)
self._transport.write(
b"554 5.7.1 <" + addr.encode() + b">: Relay access denied\r\n"
)
elif cmd == "DATA":
self._transport.write(b"354 End data with <CR><LF>.<CR><LF>\r\n")
if not self._rcpt_to:
self._transport.write(b"503 5.5.1 Error: need RCPT command\r\n")
else:
self._in_data = True
self._transport.write(b"354 End data with <CR><LF>.<CR><LF>\r\n")
elif cmd == "RSET":
self._mail_from = ""
self._rcpt_to = []
self._in_data = False
self._data_buf = []
self._auth_state = ""
self._auth_user = ""
self._transport.write(b"250 2.0.0 Ok\r\n")
elif cmd == "VRFY":
_log("vrfy", src=self._peer[0], value=line)
_log("vrfy", src=self._peer[0], value=args)
self._transport.write(b"252 2.0.0 Cannot VRFY user\r\n")
elif cmd == "NOOP":
self._transport.write(b"250 2.0.0 Ok\r\n")
elif cmd == "STARTTLS":
self._transport.write(b"454 4.7.0 TLS not available due to local problem\r\n")
elif cmd == "QUIT":
self._transport.write(b"221 2.0.0 Bye\r\n")
self._transport.close()
elif cmd == "NOOP":
self._transport.write(b"250 2.0.0 Ok\r\n")
elif cmd == "RSET":
self._transport.write(b"250 2.0.0 Ok\r\n")
elif cmd == "STARTTLS":
# Pretend we don't support upgrading mid-session
self._transport.write(b"454 4.7.0 TLS not available due to local problem\r\n")
else:
_log("unknown_command", src=self._peer[0], command=line)
_log("unknown_command", src=self._peer[0], command=line[:128])
self._transport.write(b"502 5.5.2 Error: command not recognized\r\n")
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
# ── AUTH helpers ──────────────────────────────────────────────────────────
def _handle_auth(self, args: str) -> None:
parts = args.split(None, 1)
mech = parts[0].upper() if parts else ""
initial = parts[1] if len(parts) > 1 else ""
if mech == "PLAIN":
if initial:
user, password = _decode_auth_plain(initial)
self._finish_auth(user, password)
else:
# Client will send credentials on next line
self._auth_state = "await_plain"
self._transport.write(b"334 \r\n")
elif mech == "LOGIN":
if initial:
self._auth_user = base64.b64decode(initial + "==").decode(errors="replace")
self._auth_state = "await_pass"
self._transport.write(b"334 UGFzc3dvcmQ6\r\n") # "Password:"
else:
self._auth_state = "await_user"
self._transport.write(b"334 VXNlcm5hbWU6\r\n") # "Username:"
else:
self._transport.write(b"504 5.5.4 Unrecognized authentication mechanism\r\n")
def _finish_auth(self, username: str, password: str) -> None:
_log("auth_attempt", src=self._peer[0],
username=username, password=password,
severity=SEVERITY_WARNING)
if OPEN_RELAY:
self._transport.write(b"235 2.7.0 Authentication successful\r\n")
else:
self._transport.write(b"535 5.7.8 Error: authentication failed\r\n")
async def main():
_log("startup", msg=f"SMTP server starting as {NODE_NAME}")
mode = "open-relay" if OPEN_RELAY else "credential-harvester"
_log("startup", msg=f"SMTP server starting as {NODE_NAME} ({mode})")
loop = asyncio.get_running_loop()
server = await loop.create_server(SMTPProtocol, "0.0.0.0", 25) # nosec B104
async with server:

303
tests/test_smtp.py Normal file
View File

@@ -0,0 +1,303 @@
"""
Tests for templates/smtp/server.py
Exercises both modes:
- credential-harvester (SMTP_OPEN_RELAY=0, default)
- open relay (SMTP_OPEN_RELAY=1)
Uses asyncio transport/protocol directly — no network socket needed.
"""
import base64
import importlib.util
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_fake_decnet_logging() -> ModuleType:
"""Return a stub decnet_logging module that does nothing."""
mod = ModuleType("decnet_logging")
mod.syslog_line = MagicMock(return_value="")
mod.write_syslog_file = MagicMock()
mod.forward_syslog = MagicMock()
mod.SEVERITY_WARNING = 4
mod.SEVERITY_INFO = 6
return mod
def _load_smtp(open_relay: bool):
"""Import smtp server module with desired OPEN_RELAY value.
Injects a stub decnet_logging into sys.modules so the template can import
it without needing the real file on sys.path.
"""
env = {"SMTP_OPEN_RELAY": "1" if open_relay else "0", "NODE_NAME": "testhost"}
for key in list(sys.modules):
if key in ("smtp_server", "decnet_logging"):
del sys.modules[key]
sys.modules["decnet_logging"] = _make_fake_decnet_logging()
spec = importlib.util.spec_from_file_location("smtp_server", "templates/smtp/server.py")
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
spec.loader.exec_module(mod)
return mod
def _make_protocol(mod):
"""Return a (protocol, transport, written) triple. Banner is already discarded."""
proto = mod.SMTPProtocol()
transport = MagicMock()
written: list[bytes] = []
transport.write.side_effect = written.append
proto.connection_made(transport)
written.clear()
return proto, transport, written
def _send(proto, *lines: str) -> None:
"""Feed CRLF-terminated lines to the protocol."""
for line in lines:
proto.data_received((line + "\r\n").encode())
def _replies(written: list[bytes]) -> list[str]:
"""Flatten written bytes into a list of non-empty response lines."""
result = []
for chunk in written:
for line in chunk.decode().split("\r\n"):
if line:
result.append(line)
return result
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def relay_mod():
return _load_smtp(open_relay=True)
@pytest.fixture
def harvester_mod():
return _load_smtp(open_relay=False)
# ── Banner ────────────────────────────────────────────────────────────────────
def test_banner_is_220(relay_mod):
proto, transport, written = _make_protocol(relay_mod)
# written was cleared — re-trigger banner check via a fresh instance
proto2 = relay_mod.SMTPProtocol()
t2 = MagicMock()
w2: list[bytes] = []
t2.write.side_effect = w2.append
proto2.connection_made(t2)
banner = b"".join(w2).decode()
assert banner.startswith("220")
assert "ESMTP" in banner
# ── EHLO ──────────────────────────────────────────────────────────────────────
def test_ehlo_returns_250_multiline(relay_mod):
proto, _, written = _make_protocol(relay_mod)
_send(proto, "EHLO attacker.com")
combined = b"".join(written).decode()
assert "250" in combined
assert "AUTH" in combined
assert "PIPELINING" in combined
# ── OPEN RELAY MODE ───────────────────────────────────────────────────────────
class TestOpenRelay:
@staticmethod
def _session(relay_mod, *lines):
proto, _, written = _make_protocol(relay_mod)
_send(proto, *lines)
return _replies(written)
def test_auth_plain_accepted(self, relay_mod):
creds = base64.b64encode(b"\x00admin\x00password").decode()
replies = self._session(relay_mod, f"AUTH PLAIN {creds}")
assert any(r.startswith("235") for r in replies)
def test_auth_login_multistep_accepted(self, relay_mod):
proto, _, written = _make_protocol(relay_mod)
_send(proto, "AUTH LOGIN")
_send(proto, base64.b64encode(b"admin").decode())
_send(proto, base64.b64encode(b"password").decode())
replies = _replies(written)
assert any(r.startswith("235") for r in replies)
def test_rcpt_to_any_domain_accepted(self, relay_mod):
replies = self._session(
relay_mod,
"EHLO x.com",
"MAIL FROM:<spam@evil.com>",
"RCPT TO:<victim@anydomain.com>",
)
assert any(r.startswith("250 2.1.5") for r in replies)
def test_full_relay_flow(self, relay_mod):
replies = self._session(
relay_mod,
"EHLO attacker.com",
"MAIL FROM:<hacker@evil.com>",
"RCPT TO:<admin@target.com>",
"DATA",
"Subject: hello",
"",
"Body line 1",
"Body line 2",
".",
"QUIT",
)
assert any(r.startswith("354") for r in replies), "Expected 354 after DATA"
assert any("queued as" in r for r in replies), "Expected queued-as ID"
assert any(r.startswith("221") for r in replies), "Expected 221 on QUIT"
def test_multi_recipient(self, relay_mod):
replies = self._session(
relay_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"RCPT TO:<e@f.com>",
"RCPT TO:<g@h.com>",
"DATA",
"Subject: spam",
"",
"hello",
".",
)
assert len([r for r in replies if r.startswith("250 2.1.5")]) == 3
def test_dot_stuffing_stripped(self, relay_mod):
"""Leading dot on a body line must be stripped per RFC 5321."""
proto, _, written = _make_protocol(relay_mod)
_send(proto,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
"..real dot line",
"normal line",
".",
)
replies = _replies(written)
assert any("queued as" in r for r in replies)
def test_data_rejected_without_rcpt(self, relay_mod):
replies = self._session(relay_mod, "EHLO x.com", "MAIL FROM:<a@b.com>", "DATA")
assert any(r.startswith("503") for r in replies)
def test_rset_clears_transaction_state(self, relay_mod):
proto, _, _ = _make_protocol(relay_mod)
_send(proto, "EHLO x.com", "MAIL FROM:<a@b.com>", "RCPT TO:<c@d.com>", "RSET")
assert proto._mail_from == ""
assert proto._rcpt_to == []
assert proto._in_data is False
def test_second_send_after_rset(self, relay_mod):
"""A new transaction started after RSET must complete successfully."""
replies = self._session(
relay_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"RSET",
"MAIL FROM:<new@b.com>",
"RCPT TO:<new@d.com>",
"DATA",
"body",
".",
)
assert any("queued as" in r for r in replies)
# ── CREDENTIAL HARVESTER MODE ─────────────────────────────────────────────────
class TestCredentialHarvester:
@staticmethod
def _session(harvester_mod, *lines):
proto, _, written = _make_protocol(harvester_mod)
_send(proto, *lines)
return _replies(written)
def test_auth_plain_rejected_535(self, harvester_mod):
creds = base64.b64encode(b"\x00admin\x00password").decode()
replies = self._session(harvester_mod, f"AUTH PLAIN {creds}")
assert any(r.startswith("535") for r in replies)
def test_auth_rejected_connection_stays_open(self, harvester_mod):
"""After 535 the connection must stay alive — old code closed it immediately."""
proto, transport, _ = _make_protocol(harvester_mod)
creds = base64.b64encode(b"\x00admin\x00password").decode()
_send(proto, f"AUTH PLAIN {creds}")
transport.close.assert_not_called()
def test_rcpt_to_denied_554(self, harvester_mod):
replies = self._session(
harvester_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<admin@target.com>",
)
assert any(r.startswith("554") for r in replies)
def test_relay_denied_blocks_data(self, harvester_mod):
"""With all RCPT TO rejected, DATA must return 503."""
replies = self._session(
harvester_mod,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
)
assert any(r.startswith("503") for r in replies)
def test_noop_and_quit(self, harvester_mod):
replies = self._session(harvester_mod, "NOOP", "QUIT")
assert any(r.startswith("250") for r in replies)
assert any(r.startswith("221") for r in replies)
def test_unknown_command_502(self, harvester_mod):
replies = self._session(harvester_mod, "BADCMD foo")
assert any(r.startswith("502") for r in replies)
def test_starttls_declined_454(self, harvester_mod):
replies = self._session(harvester_mod, "STARTTLS")
assert any(r.startswith("454") for r in replies)
# ── Queue ID ──────────────────────────────────────────────────────────────────
def test_rand_msg_id_format(relay_mod):
for _ in range(50):
mid = relay_mod._rand_msg_id()
assert len(mid) == 12
assert mid.isalnum()
# ── AUTH PLAIN decode ─────────────────────────────────────────────────────────
def test_decode_auth_plain_normal(relay_mod):
blob = base64.b64encode(b"\x00alice\x00s3cr3t").decode()
user, pw = relay_mod._decode_auth_plain(blob)
assert user == "alice"
assert pw == "s3cr3t"
def test_decode_auth_plain_garbage_no_raise(relay_mod):
user, pw = relay_mod._decode_auth_plain("!!!notbase64!!!")
assert isinstance(user, str)
assert isinstance(pw, str)