Summary
django-ses ships an SES event webhook (SESEventWebhookView) that receives
SNS notifications for bounces, complaints, deliveries, opens and clicks. The
view verifies the SNS signature on each notification — and with
AWS_SES_VERIFY_EVENT_SIGNATURES = True (the default) that verification is
real: an invalid signature is rejected with a 400.
The problem is what a valid signature proves. It proves a message came from
an AWS SNS topic. It does not prove the message came from your topic.
TopicArn appears in the package only inside log statements and in the list
of fields covered by the signature — it is never compared against an expected
value, and there is no setting that would let an operator configure one.
Any AWS account can therefore create a topic of its own, subscribe the
deployment’s webhook URL to it, and start publishing. The subscription
confirmation is accepted automatically — handle_subscription_confirmation()
fetches the notification’s SubscribeURL and the subscription goes live with
no operator involvement — and from then on every notification the attacker
publishes is signed by AWS, verifies correctly, and is dispatched as a
genuine delivery event. No credential belonging to the deployment is needed
at any point.
With AWS_SES_ADD_BOUNCE_TO_BLACKLIST = True, a single forged
permanent-bounce event adds any address the attacker names to
BlacklistedEmail, and filter_blacklisted_recipients() then strips that
address from every outgoing message. The attacker decides which users stop
receiving password resets, MFA codes and invoices. Fixed in 4.8.1
(2026-10-12).
Background
django-ses is the long-standing Django email backend for Amazon SES. Beyond
sending, it provides webhook views that receive SNS notifications from SES
and reflect them into the application: bounces and complaints can be added to
a BlacklistedEmail table, and applications commonly register signal
handlers (bounce_received, complaint_received, delivery_received,
open_received, click_received) to keep their own delivery state in sync.
The README’s standard setup mounts SESEventWebhookView at a public URL and
configures the SNS topic to deliver to it. Because the endpoint is public,
the package verifies the SNS message signature before acting on a
notification: the signing certificate is fetched from an
sns.<region>.amazonaws.com URL that must match an anchored regex and a
trusted-domain list, and the signature itself is checked over the canonical
message fields. That machinery does what it claims to do.
What the machinery does not establish is which topic sent the message. Nothing between the signature check and the event dispatch inspects the sender’s identity.
The vulnerability
The view’s post() is, in full, two steps:
if settings.VERIFY_EVENT_SIGNATURES and not self.verify_event_message(notification):
return HttpResponseBadRequest("Signature verification failed.")
if notification.get("Type") == "SubscriptionConfirmation":
self.handle_subscription_confirmation(notification)
Signature verification, then act on the notification — with no check on
TopicArn in between. The same is true of the deprecated handle_bounce
path, and of confirm_sns_subscription() in utils.py:
subscribe_url = notification.get("SubscribeURL")
urlopen(subscribe_url).read()
so a subscription request from any topic is confirmed by simply fetching the URL the notification names.
The full attack, from an attacker’s own AWS account:
- Create an SNS topic in that account.
- Subscribe the deployment’s webhook URL to it.
- AWS sends a
SubscriptionConfirmationsigned with a genuinesns.<region>.amazonaws.comcertificate. django-ses verifies it — correctly, the signature really is valid — then fetchesSubscribeURL, and the subscription is live. - Publish anything to the topic. AWS signs it, django-ses verifies it,
parses the attacker-controlled
Messagefield and dispatches it as a delivery event.
TopicArn never participates. A check of the published releases (1.0.0,
2.0.0, 3.0.0, 3.5.0, 4.0.0, 4.4.0, 4.8.0) shows that no version ever compared
it against an expected value, none ever had a topic-related setting in
conf.py, and automatic subscription confirmation is present throughout —
in views.py in 1.0.0, moved to utils.py from 2.0.0 on. Every published
version is affected.
Two things worth stating precisely, because both bound the finding:
- The signature check itself is not broken. The anchored certificate-URL
regex and the label-wise suffix match against
AWS_SNS_EVENT_CERT_TRUSTED_DOMAINSboth hold up; the report that led to this advisory explicitly does not claim otherwise. The claim is narrower: a signature alone does not establish origin. SubscribeURLgoes tourlopen()unconstrained. That acceptsfile://and anything else urllib supports, so a confirmed subscription would be a fetch primitive — but it sits behind signature verification, so an outsider cannot reach it today. It is closed as a hardening measure in the same fix, not because it was independently exploitable.
Verifying it
The behaviour was reproduced against a stock lab: django-ses 4.8.0, Django
6.1.1, SESEventWebhookView mounted exactly as the README shows,
AWS_SES_VERIFY_EVENT_SIGNATURES = True (the default, and the control under
test), AWS_SES_ADD_BOUNCE_TO_BLACKLIST = True:
victim address blacklisted before: False
TopicArn presented : arn:aws:sns:us-east-1:999999999999:attacker-controlled-topic
signature verification result : True
webhook HTTP status : 200
victim address blacklisted after: True
recipients surviving the filter : []
One substitution has to be stated plainly: AWS’s SNS signing key is not
available to a lab, so the lab signs with its own key and seeds the package’s
certificate cache under a URL that satisfies the package’s own domain rules.
The signature verification code runs completely unmodified and returns
True. The real attack needs no substitution at all, because AWS signs the
attacker’s own topic’s messages with a genuine certificate and the
verification path never looks at which topic sent them. The lab isolates the
missing control rather than bypassing the one that is present.
The fix itself was then verified end-to-end before it was merged, on a live
runserver against the fork PR head (8324e04): a 13-scenario E2E matrix
(21 checks) covering attacker-topic and trusted-topic notifications across
both webhook paths, SubscriptionConfirmation and UnsubscribeConfirmation
handling, and hostile SubscribeURL shapes (file://, http://, lookalike
suffixes, userinfo tricks) with an instrumented urlopen sink — plus a
30-case predicate battery over the two new helpers, all failing closed, and
the vendor test suite (Ran 96 tests ... OK).
Impact
With AWS_SES_ADD_BOUNCE_TO_BLACKLIST = True, a forged permanent-bounce
event adds an attacker-chosen address to BlacklistedEmail.
filter_blacklisted_recipients() then strips it from every outgoing message
and the send fails with “Refusing to send email. All recipients were filtered
by the blacklist”. The attacker decides which users stop receiving password
resets, MFA codes and invoices. AWS_SES_ADD_COMPLAINT_TO_BLACKLIST reaches
the same state through complaints.
With default settings the blacklist is off and the impact is narrower, but
not nothing: every bounce_received, complaint_received,
delivery_received, open_received and click_received handler in the
application receives attacker-controlled mail_obj and raw message while
believing it came from AWS.
No credential belonging to the deployment is needed at any point, and the
webhook is public by design — it is the endpoint SES is supposed to reach.
The advisory rates it CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N = 7.5
High (CWE-290, CWE-346): remote, no privileges, no user interaction, and
the damage is to integrity — the application’s record of what AWS actually
did.
The fix
Fixed on 2026-10-12 and released as 4.8.1. The fix implements both recommendations from the report:
- A trusted-topic allowlist. A new setting,
AWS_SNS_EVENT_TRUSTED_TOPIC_ARNS, checked inpost()andhandle_bounce()immediately after signature verification (untrusted topics get a 400 “Untrusted topic.”) and again insideconfirm_sns_subscription(), so an untrusted topic cannot attach in the first place. The default is an empty list, which preserves the historical behaviour of accepting any topic — the setting must be configured to actually block, and theconf.pycomment says so plainly: “Leaving this empty preserves the historical behaviour of accepting any topic, which is not safe for a publicly reachable webhook.” SubscribeURLconstrained to SNS.is_trusted_sns_subscribe_url()requireshttpson ansns.<region>.amazonaws.com(or.cn) host before the confirmation fetch happens, removing thefile://fetch primitive from behind the signature gate.
The default was the real decision, and it belongs to the maintainers. An empty default keeps every deployment working and leaves them as exposed as they are today; reject-unless-configured is safe and breaks every deployment on upgrade. The maintainers chose the empty default with documentation. If you run django-ses with a public webhook, set the setting.
Timeline
- 2026-09-11 — Reported via GitHub’s private vulnerability reporting (with a same-day correction simplifying the report).
- 2026-09-12 — Patch attached in the advisory thread.
- 2026-10-09 — Maintainer opens the fix PR on the advisory fork and asks for verification.
- 2026-10-10 — Fix verified end-to-end on the fork PR head (
8324e04): 13-scenario E2E matrix (21 checks), 30-case predicate battery, vendor suite green. - 2026-10-12 — Merged (
a98f37d3), released as 4.8.1, and the advisory published:GHSA-2vww-32j3-5v92, High (CVSS 3.1AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N= 7.5, CWE-290/CWE-346), no CVE assigned.
Disclosure
Full advisory: GHSA-2vww-32j3-5v92
(published 2026-10-12; High; no CVE assigned). The fix is in
v4.8.1
(commit a98f37d3). This writeup describes behaviour verified against
django-ses 4.8.0 in a local lab and the fix as merged for 4.8.1.