Compare commits
6 Commits
df19c60b17
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bde4f884d | |||
| 9a4bab33e2 | |||
| e51740b8db | |||
| 4ab12f8ce6 | |||
| 7930235efd | |||
| bde999185a |
@@ -45,16 +45,16 @@ jobs:
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: "$RUNNER_OS-pip-${{ hashFiles('requirements-dev.txt') }}"
|
||||
key: "${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}"
|
||||
restore-keys: |
|
||||
$RUNNER_OS-pip-
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: python -m pytest -q
|
||||
|
||||
+6
-1
@@ -3,11 +3,16 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Create a dedicated non-root user and group to run the proxy.
|
||||
RUN groupadd --system appuser && useradd --system --gid appuser appuser
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY proxy_server.py ./
|
||||
COPY --chown=appuser:appuser proxy_server.py ./
|
||||
|
||||
EXPOSE 110 25
|
||||
|
||||
USER appuser
|
||||
|
||||
CMD ["python", "proxy_server.py"]
|
||||
|
||||
@@ -30,6 +30,11 @@ Proxy an unauthenticated, unencrypted POP3 / SMTP server to authenticated IMAPS
|
||||
- `BACKEND_SMTP_USE_SSL` (default `true`)
|
||||
- `BACKEND_SMTP_USE_TLS` (default `false`)
|
||||
|
||||
- `BACKEND_MUTATE` (default `false`) - when `true`, POP3 deletions are propagated to the
|
||||
backend IMAP server (STORE +FLAGS / EXPUNGE). Default behaviour is to never mutate
|
||||
the backend mailbox on POP client deletions; the proxy only hides messages for the
|
||||
duration of the client session.
|
||||
|
||||
## Build and run
|
||||
|
||||
This project targets the latest Python LTS release. The included `Dockerfile` uses `python:3.12-slim`, which is compatible with Python 3.12 and later LTS releases.
|
||||
@@ -63,3 +68,11 @@ pytest -q
|
||||
## Notes
|
||||
|
||||
This implementation begins the proxy with a minimal POP3 command set and SMTP delivery path. It is designed to start development on the required application architecture.
|
||||
|
||||
## Security
|
||||
|
||||
By design, the front-end POP3 (port 110) and SMTP (port 25) listeners are **unencrypted** and **unauthenticated**. Anyone who can reach port 110 obtains full mailbox access, and anyone who can reach port 25 can relay mail through the configured backend SMTP credentials, which is an open relay from the network's perspective.
|
||||
|
||||
Because of this, the listeners **must** be bound to a trusted internal network only, such as a private Docker bridge, a VPN interface, or localhost, and **must not** be exposed to untrusted networks or the public internet.
|
||||
|
||||
Operators who need to restrict the bind address can set `POP3_BIND_ADDR` / `SMTP_BIND_ADDR` to a specific internal interface instead of `0.0.0.0`.
|
||||
|
||||
+24
-16
@@ -51,6 +51,10 @@ class Settings:
|
||||
BACKEND_SMTP_PASS = os.getenv("BACKEND_SMTP_PASS")
|
||||
BACKEND_SMTP_USE_SSL = env_bool("BACKEND_SMTP_USE_SSL", True)
|
||||
BACKEND_SMTP_USE_TLS = env_bool("BACKEND_SMTP_USE_TLS", False)
|
||||
# When false (default) the proxy will not mutate backend mailboxes
|
||||
# (no STORE +FLAGS / EXPUNGE). Set to true only when deletions should
|
||||
# be propagated to the backend IMAP server.
|
||||
BACKEND_MUTATE = env_bool("BACKEND_MUTATE", False)
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
@@ -253,30 +257,31 @@ class POP3Session:
|
||||
return await self.send_line("-ERR Unsupported command")
|
||||
|
||||
async def handle_user(self, args):
|
||||
if len(args) != 1:
|
||||
return await self.send_line("-ERR USER requires username")
|
||||
self.username = args[0]
|
||||
# Accept any username. Client credentials are intentionally ignored;
|
||||
# some legacy clients insist on supplying them, so they are accepted
|
||||
# blindly. The backend is always reached with the proxy's own creds.
|
||||
self.username = args[0] if args else None
|
||||
return await self.send_line("+OK")
|
||||
|
||||
async def handle_pass(self, args):
|
||||
if len(args) != 1:
|
||||
return await self.send_line("-ERR PASS requires password")
|
||||
self.password = args[0]
|
||||
# Accept any password. See handle_user: client credentials are
|
||||
# accepted but never used or validated.
|
||||
self.password = args[0] if args else None
|
||||
await asyncio.to_thread(self.authenticate)
|
||||
return await self.send_line("+OK User authenticated")
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate to the IMAP backend using configured credentials."""
|
||||
if Settings.BACKEND_IMAP_USER and Settings.BACKEND_IMAP_PASS:
|
||||
username = Settings.BACKEND_IMAP_USER
|
||||
password = Settings.BACKEND_IMAP_PASS
|
||||
elif self.username and self.password:
|
||||
username = self.username
|
||||
password = self.password
|
||||
else:
|
||||
raise RuntimeError("No IMAP credentials available")
|
||||
"""Authenticate to the IMAP backend using the configured proxy credentials.
|
||||
|
||||
backend = IMAPBackend(username, password)
|
||||
Client-supplied POP3 credentials are deliberately ignored: the proxy
|
||||
always connects to the backend with ``BACKEND_IMAP_USER`` /
|
||||
``BACKEND_IMAP_PASS``. This is by design for legacy clients that require
|
||||
credentials to be entered even though the proxy does not use them.
|
||||
"""
|
||||
if not (Settings.BACKEND_IMAP_USER and Settings.BACKEND_IMAP_PASS):
|
||||
raise RuntimeError("Backend IMAP credentials are not configured")
|
||||
|
||||
backend = IMAPBackend(Settings.BACKEND_IMAP_USER, Settings.BACKEND_IMAP_PASS)
|
||||
backend.login()
|
||||
self._imap = backend
|
||||
# Snapshot the maildrop once; it stays static for the session lifetime
|
||||
@@ -375,9 +380,12 @@ class POP3Session:
|
||||
|
||||
async def handle_quit(self):
|
||||
if self._imap:
|
||||
# Only propagate deletes to the backend when explicitly enabled.
|
||||
if Settings.BACKEND_MUTATE:
|
||||
for uid in self.deleted:
|
||||
await asyncio.to_thread(self._imap.mark_deleted, uid)
|
||||
await asyncio.to_thread(self._imap.expunge)
|
||||
# Always logout the backend connection.
|
||||
self._imap.logout()
|
||||
await self.send_line("+OK Goodbye")
|
||||
|
||||
|
||||
+155
-2
@@ -143,6 +143,95 @@ def test_smtp_proxy_handler_forwards_message_over_ssl(monkeypatch):
|
||||
Settings.BACKEND_SMTP_PASS = previous_pass
|
||||
|
||||
|
||||
def test_pop3_quit_does_not_mutate_backend_by_default():
|
||||
import asyncio
|
||||
|
||||
class DummyBackend:
|
||||
def __init__(self):
|
||||
self.deleted_called = []
|
||||
self.expunge_called = False
|
||||
self.logged_out = False
|
||||
|
||||
def mark_deleted(self, uid):
|
||||
self.deleted_called.append(uid)
|
||||
|
||||
def expunge(self):
|
||||
self.expunge_called = True
|
||||
|
||||
def logout(self):
|
||||
self.logged_out = True
|
||||
|
||||
session = POP3Session(None, None)
|
||||
backend = DummyBackend()
|
||||
session._imap = backend
|
||||
session.deleted = {b"1", b"2"}
|
||||
|
||||
# Provide a dummy writer so send_line can be awaited without a socket.
|
||||
class DummyWriter:
|
||||
def __init__(self):
|
||||
self.buf = b""
|
||||
self.closed = False
|
||||
|
||||
def write(self, data):
|
||||
self.buf += data
|
||||
|
||||
async def drain(self):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
async def wait_closed(self):
|
||||
return None
|
||||
|
||||
session.writer = DummyWriter()
|
||||
|
||||
# Ensure default is not to mutate
|
||||
previous_mutate = Settings.BACKEND_MUTATE
|
||||
Settings.BACKEND_MUTATE = False
|
||||
asyncio.run(session.handle_quit())
|
||||
Settings.BACKEND_MUTATE = previous_mutate
|
||||
|
||||
assert backend.deleted_called == []
|
||||
assert backend.expunge_called is False
|
||||
assert backend.logged_out is True
|
||||
|
||||
|
||||
def test_pop3_quit_mutates_backend_when_enabled():
|
||||
import asyncio
|
||||
|
||||
class DummyBackend:
|
||||
def __init__(self):
|
||||
self.deleted_called = []
|
||||
self.expunge_called = False
|
||||
self.logged_out = False
|
||||
|
||||
def mark_deleted(self, uid):
|
||||
self.deleted_called.append(uid)
|
||||
|
||||
def expunge(self):
|
||||
self.expunge_called = True
|
||||
|
||||
def logout(self):
|
||||
self.logged_out = True
|
||||
|
||||
session = POP3Session(None, None)
|
||||
backend = DummyBackend()
|
||||
session._imap = backend
|
||||
session.deleted = {b"1", b"2"}
|
||||
|
||||
session.writer = DummyWriter()
|
||||
|
||||
previous_mutate = Settings.BACKEND_MUTATE
|
||||
Settings.BACKEND_MUTATE = True
|
||||
asyncio.run(session.handle_quit())
|
||||
Settings.BACKEND_MUTATE = previous_mutate
|
||||
|
||||
assert set(backend.deleted_called) == {b"1", b"2"}
|
||||
assert backend.expunge_called is True
|
||||
assert backend.logged_out is True
|
||||
|
||||
|
||||
class FakeWriter:
|
||||
def __init__(self):
|
||||
self.buffer = bytearray()
|
||||
@@ -160,6 +249,26 @@ class FakeWriter:
|
||||
pass
|
||||
|
||||
|
||||
class DummyWriter:
|
||||
"""Simple writer used by tests where we only need write/drain/close."""
|
||||
|
||||
def __init__(self):
|
||||
self.buf = b""
|
||||
self.closed = False
|
||||
|
||||
def write(self, data):
|
||||
self.buf += data
|
||||
|
||||
async def drain(self):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
async def wait_closed(self):
|
||||
return None
|
||||
|
||||
|
||||
class RecordingIMAP:
|
||||
"""In-memory IMAPBackend stand-in for POP3 session tests."""
|
||||
|
||||
@@ -227,6 +336,49 @@ def test_top_returns_headers_and_limited_body():
|
||||
assert output.endswith(b".\r\n")
|
||||
|
||||
|
||||
def test_authenticate_ignores_client_credentials(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_login(self):
|
||||
captured["username"] = self.username
|
||||
captured["password"] = self.password
|
||||
|
||||
monkeypatch.setattr(IMAPBackend, "login", fake_login)
|
||||
monkeypatch.setattr(IMAPBackend, "list_uids", lambda self: [])
|
||||
previous_user = Settings.BACKEND_IMAP_USER
|
||||
previous_pass = Settings.BACKEND_IMAP_PASS
|
||||
Settings.BACKEND_IMAP_USER = "backend-user"
|
||||
Settings.BACKEND_IMAP_PASS = "backend-pass"
|
||||
|
||||
session = POP3Session(None, FakeWriter())
|
||||
session.username = "client-user"
|
||||
session.password = "client-pass"
|
||||
session.authenticate()
|
||||
|
||||
# The proxy must connect with its own credentials, never the client's.
|
||||
assert captured["username"] == "backend-user"
|
||||
assert captured["password"] == "backend-pass"
|
||||
|
||||
Settings.BACKEND_IMAP_USER = previous_user
|
||||
Settings.BACKEND_IMAP_PASS = previous_pass
|
||||
|
||||
|
||||
def test_authenticate_requires_backend_credentials(monkeypatch):
|
||||
previous_user = Settings.BACKEND_IMAP_USER
|
||||
previous_pass = Settings.BACKEND_IMAP_PASS
|
||||
Settings.BACKEND_IMAP_USER = None
|
||||
Settings.BACKEND_IMAP_PASS = None
|
||||
|
||||
session = POP3Session(None, FakeWriter())
|
||||
session.username = "client-user"
|
||||
session.password = "client-pass"
|
||||
with pytest.raises(RuntimeError, match="Backend IMAP credentials are not configured"):
|
||||
session.authenticate()
|
||||
|
||||
Settings.BACKEND_IMAP_USER = previous_user
|
||||
Settings.BACKEND_IMAP_PASS = previous_pass
|
||||
|
||||
|
||||
def test_dele_survives_stat_list_uidl_until_quit():
|
||||
session = make_session([b"1", b"2", b"3"])
|
||||
asyncio.run(session.handle_dele(["2"]))
|
||||
@@ -235,6 +387,7 @@ def test_dele_survives_stat_list_uidl_until_quit():
|
||||
asyncio.run(session.handle_list([]))
|
||||
asyncio.run(session.handle_uidl([]))
|
||||
assert b"2" in session.deleted
|
||||
# Default behaviour is not to mutate the backend mailbox on QUIT.
|
||||
asyncio.run(session.handle_quit())
|
||||
assert session._imap.marked == [b"2"]
|
||||
assert session._imap.expunged is True
|
||||
assert session._imap.marked == []
|
||||
assert session._imap.expunged is False
|
||||
|
||||
Reference in New Issue
Block a user