(Story #2554) Route QA email to a shared maildev inbox - #2555
Conversation
📝 WalkthroughWalkthroughEmail routing now supports catch-all delivery through Maildev in local, development, and stage environments. Production rejects catch-all mode. Helm templates deploy Maildev conditionally, and documentation covers configuration and QA inbox access. ChangesEmail routing and Maildev delivery
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Application
participant MaildevSMTP
participant QAInbox
Application->>MaildevSMTP: Send catch-all email
MaildevSMTP->>QAInbox: Store email for web access
QAInbox->>MaildevSMTP: Request /maildev inbox
Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ba2d090 to
df43eae
Compare
980345f to
c4ee485
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@kube/boost/templates/maildev.yaml`:
- Around line 104-112: Update the Maildev Gateway route in the manifest around
the maildev backend reference so the externally published /maildev path is
protected by Gateway-level basic authentication that applies to HTTP and
WebSocket upgrades; alternatively remove the external route entirely. If
retaining the route with authentication, update the Maildev documentation to
describe the protected Socket.IO endpoint and ensure its documented behavior
matches the deployed configuration.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 43f23ddf-19e3-4f8a-be59-77c4e8d951dd
📒 Files selected for processing (8)
config/settings.pydocs/README.mddocs/email.mddocs/env_vars.mdkube/boost/templates/maildev.yamlkube/boost/values-cppal-dev-gke.yamlkube/boost/values-stage-gke.yamlkube/boost/values.yaml
| # More specific than the site's "/" rule, so it wins by path length. Also | ||
| # covers /maildev/socket.io/... which is how the inbox live-updates. | ||
| - matches: | ||
| - path: | ||
| type: PathPrefix | ||
| value: /maildev | ||
| backendRefs: | ||
| - name: maildev | ||
| port: 1080 |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== files =="
git ls-files | rg '(^|/)(maildev\.yaml|gateway\.yaml|docs/email\.md|.*maildev.*|.*socket.*|.*auth.*yaml|.*tls.*)$' || true
echo "== maildev_template =="
cat -n kube/boost/templates/maildev.yaml
echo "== gateway_template =="
cat -n kube/boost/templates/gateway.yaml
echo "== email doc excerpt =="
sed -n '1,90p' docs/email.md | cat -n
echo "== search auth policies / Gateway auth / maildev socket =="
rg -n "authentication|auth|socket\.io|MAILDEV_WEB_USER|MAILDEV_WEB_PASS|PathPrefix|maildev|www-boost|TLS|tls|AuthorizationPolicy|RequestAuthentication|OAuth2|client-certificate|basic" kube/boost docs || trueRepository: boostorg/website-v2
Length of output: 29440
🌐 Web query:
Maildev Socket.IO bypass Express basic auth docs email.md line 45
💡 Result:
There is no evidence of a documented vulnerability in MailDev specifically titled "Maildev Socket.IO bypass Express basic auth" at a file path named "email.md" line 45. It is likely that this query conflates several distinct technical concepts: 1. MailDev: A development tool for testing emails, which uses Express for its web interface and Socket.IO for real-time updates [1][2]. It supports HTTP basic authentication via the --web-user and --web-pass flags [1][2]. 2. Socket.IO Authentication: Socket.IO connections operate independently of standard HTTP request/response cycles [3]. Historically, developers have encountered issues where Socket.IO connections were established outside of the authentication protections applied to Express routes [4][3]. This is a known architectural characteristic of Socket.IO rather than a MailDev-specific exploit [4][3]. 3. email.md: This is a separate, unrelated library (unmta/emailmd) used for rendering Markdown into email-safe HTML, which maintains its own documentation [5][6]. It does not appear to have any security-related documentation involving MailDev, Socket.IO, or basic authentication bypasses [5][6]. If you are experiencing authentication bypass issues in an application using MailDev or Socket.IO, ensure you are utilizing the latest versions of these dependencies to mitigate known vulnerabilities (such as historical CORS misconfigurations) [7][8][9][10] and follow official Socket.IO documentation regarding the use of io.engine.use to apply middlewares to WebSocket upgrade requests [4][3].
Citations:
- 1: https://github.serafin.io/maildev/
- 2: https://github.com/diresi/MailDev
- 3: https://socket.io/docs/v4/middlewares/
- 4: Socket authentication seems to be bypassed when using ws client. socketio/socket.io#4899
- 5: https://github.com/unmta/emailmd/
- 6: https://www.emailmd.dev/
- 7: upgrade socket.io to fix CVE-2022-41940 and CVE-2022-2421 maildev/maildev#447
- 8: Upgrade
socket.iodue to CVE maildev/maildev#440 - 9: Critical vulnerability reported maildev/maildev#359
- 10: https://nvd.nist.gov/vuln/detail/CVE-2020-28481
Authorization Bypass (CWE-306): Missing Authentication for Critical Function
Reachability: External
Guard the Maildev live-update path at the Gateway.
The PathPrefix /maildev route publishes https://<mainFqdn>/maildev/socket.io/... on the public host. docs/email.md says maildev expresses basic auth as Express middleware, but its raw HTTP/server-level Socket.IO feed bypasses that middleware and can expose inbox messages and links. Add Gateway-level auth that also supports WebSocket upgrades, or stop publishing Maildev externally; update docs/email.md if the Socket.IO endpoint is protected.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@kube/boost/templates/maildev.yaml` around lines 104 - 112, Update the Maildev
Gateway route in the manifest around the maildev backend reference so the
externally published /maildev path is protected by Gateway-level basic
authentication that applies to HTTP and WebSocket upgrades; alternatively remove
the external route entirely. If retaining the route with authentication, update
the Maildev documentation to describe the protected Socket.IO endpoint and
ensure its documented behavior matches the deployed configuration.
Ref #2554
Why
Non-production deployments send real email to real recipients.
config/settings.pyhardcodedEMAIL_BACKENDto the Mailgun Anymail backend for every environment exceptLOCAL_DEVELOPMENT, and bothvalues-stage-gke.yamlandvalues-cppal-dev-gke.yamlsupply live Mailgun credentials and a verified sender domain.That blocks QA work which needs to sign in as existing users with real contribution history, because the sign-in verification email is delivered to that user's actual inbox. More broadly, any transactional email triggered while testing reaches real recipients, consumes email quota, and generates bounces and spam signals against the sender domain.
Local development already solves this with the
maildevcontainer indocker-compose.yml. This brings the same catch-all inbox tostageandcppal-dev, reachable in a browser so QA does not need cluster credentials.What changed
config/settings.py- a newCATCH_ALL_EMAILflag (defaultfalse) makes an environment send through Django's SMTP backend toEMAIL_HOST/EMAIL_PORTinstead of Mailgun. Django raisesImproperlyConfiguredat startup if the flag is ever enabled whileX_DEPLOYMENT_ENVisproduction.The flag is opt-in per values file rather than derived from the environment name. Deriving it (from
X_DEPLOYMENT_ENV, or by pattern-matchingDJANGO_FQDN) would silently switch environments that have no maildev pod, turning working mail into connection errors, and would re-enable real sending if a hostname were ever renamed.kube/boost/templates/maildev.yaml(new) - everything behind amaildevInstallflag, following the existingredisInstall/celeryInstallconvention so production cannot pick it up:Deployment(pinnedmaildev/maildev:2.2.1,replicas: 1) and aClusterIPServiceexposing SMTP1025and HTTP1080.HTTPRoutepublishing the inbox at/maildev/on the environment'smainFqdn, plus aHealthCheckPolicyand aGCPBackendPolicy.Values files -
maildevInstall: trueandCATCH_ALL_EMAIL: "true"in the two QA files;maildevInstall: falseplusmaildevImageTagas chart defaults. Production is untouched. TheMAILGUN_*entries stay in the QA files but are inert, so reverting is a one-line change.Docs - new
docs/email.md(routing per environment, how to reach the inbox, how it is wired), plusCATCH_ALL_EMAIL/EMAIL_*entries indocs/env_vars.mdand an index line indocs/README.md.How the inbox is exposed
The route is added at the existing GKE Gateway, so the Google load balancer that already fronts the site gains one URL-map rule. It reuses the existing static IP and certificate: no new hostname, no DNS record, no certificate, no LoadBalancer, and no change to the app's nginx config. Traffic to
/maildev/never reaches Django, gunicorn or the app pods.stage: https://www.stage.boost.org/maildev/cppal-dev: https://www.cppal-dev.boost.org/maildev/Access is gated by maildev's own HTTP basic auth (
MAILDEV_WEB_USER/MAILDEV_WEB_PASS).Three details that are load-bearing, all verified against
maildev/maildev:2.2.1:MAILDEV_BASE_PATHNAME=/maildevmakes maildev serve itself under the prefix, so no URL rewriting is needed at the edge, and the socket.io endpoint moves under the same prefix where thePathPrefixrule already covers it.HealthCheckPolicytargets/maildev/healthz, the only path maildev exempts from basic auth. A health check against/returns 401, which the load balancer reads as an unhealthy backend and answers with 503.GCPBackendPolicyraisestimeoutSec. On Google load balancers the backend timeout is the maximum lifetime of a WebSocket connection rather than an idle timeout, so the 30 second default would sever the inbox's live-update socket every 30 seconds.Deploy notes
A
maildev-authSecret must exist instageand incppal-devbefore deploying, or the maildev pod will not start. It is not in the repo because this repository is public. It follows the same convention as the 13 secrets this chart already consumes (pg,mailgun,django-secret-key, and so on): created out-of-band by an operator, referenced throughsecretKeyRef. The chart ships nokind: Secretand CI creates none.Name, keys and type must be exactly
maildev-auth/web_user+web_pass/Opaque, because the Deployment references them verbatim. A Secret with the right name but different keys fails in exactly the same way as a missing one. Do not create it inproduction, where nothing consumes it.Ordering matters, and the failure mode is fail-closed rather than degraded. If the Secret is absent at deploy time the pod stays in
CreateContainerConfigError, never passes readiness, and so never joins the Service endpoints, whileCATCH_ALL_EMAIL=truehas already switched that environment's Django to SMTP. Every outbound email in that environment then fails with a connection error instead of falling back to Mailgun. Recovery is to create the Secret andkubectl -n <ns> rollout restart deploy/maildev.Post-deploy checks, per namespace:
The
values-cppal-dev-gke.yamlchange only takes effect once thecppalliance/website-v2-qafork'scppal-devbranch picks up this commit.Two further things to confirm on the first deploy: that the Gateway controller auto-attached a NEG to the
maildevService (otherwise add acloud.google.com/negannotation), and that theGCPBackendPolicyCRD is present (kubectl get crd | grep gcpbackendpolicies;HealthCheckPolicyfrom the same GKE bundle is already used ingateway.yaml).Standardizing this Secret (a chart-templated
Secretfed from a CI secret, or External Secrets / GCP Secret Manager) is deliberately out of scope. Doing it for the 14th secret while the other 13 stay manual trades one inconsistency for a worse one; it is worth a separate ticket covering all of them at once.Verification
helm templateacross all four values files:production: no maildev objects at all, Mailgun backend unchanged.stageandcppal-dev:Deployment,Service,HTTPRoute,HealthCheckPolicyandGCPBackendPolicyall render;HTTPRouteresolves towww-boost-stage/www-boost-devon the correct hostname;CATCH_ALL_EMAIL,EMAIL_HOSTandEMAIL_PORTreach every container that renders.Values.Env-boost/wsgi,boost/nginx,celery-worker,celery-beatand the migrationsJob- which covers all workloads that can send mail, Celery included.values.yaml): no maildev objects.helm lintis clean for all three GKE values files.Settings behavior, exercised in the project image:
LOCAL_DEVELOPMENT=truemaildev:1025X_DEPLOYMENT_ENV=productionMAILGUN_*inANYMAILX_DEPLOYMENT_ENV=stageordevmaildev:1025, emptyANYMAILX_DEPLOYMENT_ENV=productionImproperlyConfiguredat startupAlso deployed on a local Kubernetes cluster, applying the chart-rendered manifest rather than a hand-written copy, which exercises the parts a bare container cannot:
deploy/maildevreached1/1Ready, so/maildev/healthzgenuinely answers 200 underMAILDEV_BASE_PATHNAME. That is the exact path theHealthCheckPolicyprobes./maildev/healthzreturns 200 with no credentials; every other path is gated, since maildev's basic-auth middleware runs ahead of routing.maildevon 1025 over cluster DNS delivered, matching how the app pods resolveEMAIL_HOST.sendtestemailandsend_mail(...)to a real-looking address were captured by the pod rather than delivered, and read back through the authenticated API.Against the pinned image directly: basic auth, the auth-exempt health path, relative asset resolution under the prefix and a real
101 Switching Protocolsupgrade at/maildev/socket.io/were all confirmed.pre-commit runpasses on all changed files.Not included
Risks and considerations
Non-production email is now fail-closed. If the maildev pod is down, sends raise instead of being delivered, and Celery tasks that send mail will error or retry. That is the correct trade for QA, since nothing silently escapes to real users, but it is a behaviour change from "Mailgun always accepts".
The
maildev-authSecret is a manual out-of-band prerequisite that a reviewer cannot see in the diff. See Deploy notes.Known limitation
maildev applies basic auth as Express middleware, but its socket.io channel attaches to the raw HTTP server and bypasses that middleware, emitting full message payloads. The password gate deters casual access to the UI; it is not a security boundary for message contents. The URL should not be published. Accepted for a QA inbox.
Summary by CodeRabbit
New Features
Documentation