Compare commits
5 Commits
df19c60b17
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a4bab33e2 | |||
| e51740b8db | |||
| 4ab12f8ce6 | |||
| 7930235efd | |||
| bde999185a |
@@ -45,16 +45,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: 3.12
|
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
|
- name: Cache pip dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: "$RUNNER_OS-pip-${{ hashFiles('requirements-dev.txt') }}"
|
key: "${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}"
|
||||||
restore-keys: |
|
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
|
- name: Run unit tests
|
||||||
run: python -m pytest -q
|
run: python -m pytest -q
|
||||||
|
|||||||
+6
-1
@@ -3,11 +3,16 @@ FROM python:3.12-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PYTHONUNBUFFERED=1
|
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 ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r 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
|
EXPOSE 110 25
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
CMD ["python", "proxy_server.py"]
|
CMD ["python", "proxy_server.py"]
|
||||||
|
|||||||
@@ -63,3 +63,11 @@ pytest -q
|
|||||||
## Notes
|
## 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.
|
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`.
|
||||||
|
|||||||
+17
-16
@@ -253,30 +253,31 @@ class POP3Session:
|
|||||||
return await self.send_line("-ERR Unsupported command")
|
return await self.send_line("-ERR Unsupported command")
|
||||||
|
|
||||||
async def handle_user(self, args):
|
async def handle_user(self, args):
|
||||||
if len(args) != 1:
|
# Accept any username. Client credentials are intentionally ignored;
|
||||||
return await self.send_line("-ERR USER requires username")
|
# some legacy clients insist on supplying them, so they are accepted
|
||||||
self.username = args[0]
|
# 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")
|
return await self.send_line("+OK")
|
||||||
|
|
||||||
async def handle_pass(self, args):
|
async def handle_pass(self, args):
|
||||||
if len(args) != 1:
|
# Accept any password. See handle_user: client credentials are
|
||||||
return await self.send_line("-ERR PASS requires password")
|
# accepted but never used or validated.
|
||||||
self.password = args[0]
|
self.password = args[0] if args else None
|
||||||
await asyncio.to_thread(self.authenticate)
|
await asyncio.to_thread(self.authenticate)
|
||||||
return await self.send_line("+OK User authenticated")
|
return await self.send_line("+OK User authenticated")
|
||||||
|
|
||||||
def authenticate(self):
|
def authenticate(self):
|
||||||
"""Authenticate to the IMAP backend using configured credentials."""
|
"""Authenticate to the IMAP backend using the configured proxy 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")
|
|
||||||
|
|
||||||
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()
|
backend.login()
|
||||||
self._imap = backend
|
self._imap = backend
|
||||||
# Snapshot the maildrop once; it stays static for the session lifetime
|
# Snapshot the maildrop once; it stays static for the session lifetime
|
||||||
|
|||||||
@@ -227,6 +227,49 @@ def test_top_returns_headers_and_limited_body():
|
|||||||
assert output.endswith(b".\r\n")
|
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():
|
def test_dele_survives_stat_list_uidl_until_quit():
|
||||||
session = make_session([b"1", b"2", b"3"])
|
session = make_session([b"1", b"2", b"3"])
|
||||||
asyncio.run(session.handle_dele(["2"]))
|
asyncio.run(session.handle_dele(["2"]))
|
||||||
|
|||||||
Reference in New Issue
Block a user