Security · July 1, 2026

One-click unsubscribe posts to a URL the sender chose

TwinMail's dashboard (Technical Preview) has a one-click unsubscribe button. POST /api/unsubscribe reads a message's RFC 8058 headers and picks the best channel: an https target carrying the one-click marker gets a real HTTP POST; otherwise a mailto: target gets an email with "unsubscribe" as its subject and body. No usable target returns 400. It is also one of the few actions in the rules engine with no inverse: star, label, move, and archive can all be undone; unsubscribe cannot.

Read the first sentence the way an attacker would. Every sender who can land a message in the inbox gets to choose a URL the server will open a connection to. That is the textbook SSRF setup: the mail client sits inside the network boundary, and the List-Unsubscribe header is hostile input.

The guard, and the redirect that walked around it

The first defense checked the URL before connecting: https only, port 443 or absent, and every IP the hostname resolves to must be public. Private ranges, loopback, link-local, reserved, multicast: all rejected.

The hole was downstream. The POST used urllib's default opener, and that opener follows 3xx redirects. A host with a clean public IP passes the guard, then answers the POST with a 302 pointing at a private address, and urllib follows it — after validation already ran. Success was also defined as any status from 200 to 399, so the redirect itself counted as a win. Commit ce69dc7 closed both: a handler that returns the response as-is instead of dispatching redirect handling, and success tightened to strict 2xx. A failed POST now surfaces as HTTP 400 instead of a false "unsubscribed".

A PR reviewer flagged the no-redirect handler as possibly not working at all. The rebuttal was empirical: commit faa6e60 stands up a real local server that answers 302 and asserts the redirect target is never hit. In urllib, redirect handling is triggered by HTTPErrorProcessor.http_response calling parent.error; replace that method and the dispatch never fires. The prior test had only mocked the opener, so the real-server test stayed. A Copilot review pass added the dull armor eleven minutes later: network errors return False instead of raising, and an address that will not parse (an IPv6 zone id like %eth0) is treated as hostile.

The fix that looks complete

Here's the interesting bug. With redirects dead and the guard checking every resolved IP, the code reads as done. It is not, because the guard and the connection resolve the hostname twice. An attacker who runs the domain's DNS server answers the first lookup with a public IP and the second with 10.0.0.1. Validation passes; the socket opens inside the network. That is DNS rebinding, a time-of-check/time-of-use race the validator cannot win — the name it approved is allowed to change meaning between the check and the connect.

Resolve once, validate, pin

Commit 3a2227d, about three hours after the redirect fix, threw out urllib and rebuilt the POST on http.client with a pinned connection class. The function now resolves the hostname exactly once, checks that every returned IP is public, pins the first one, and opens the TCP socket to that pinned IP. TLS still wraps the socket with server_hostname set to the original domain, so SNI and certificate verification run against the real name. The POST never reaches a private address, because the socket opens only to an IP that passed the public-IP check inside the same function call. As a side effect, http.client never auto-follows redirects, so the no-redirect handler from the first fix became dead code and was deleted. The suite stood at 278 green after the rewrite, up from 258 earlier in the afternoon.

One functional refinement rode along afterward: the target URL's query string is preserved on the POST, because RFC 8058 targets from Mailchimp, SendGrid, and SES carry the per-recipient auth token there, and dropping it breaks the unsubscribe.

A test that fails loudly

The last commit of the arc, 96564fc, changed zero production code: 93 inserted lines, all tests. The key one lets the real connect() execute, patching the resolver, the socket factory, and the TLS wrap, and asserts the socket call received the pinned IP literal 93.184.216.34 exactly once. If a future edit swaps the pinned IP back to the hostname and quietly reintroduces the second resolution, that test breaks.

The whole arc landed in one afternoon: five commits on June 24, from 15:24 to 18:42. The unsubscribe test file now holds 24 tests, 15 of them aimed at these two attack shapes, out of 350 across the suite. The rule that holds up: when the input is hostile, connect to the address you validated, not the name that produced it.

Explore the suite.

Sign in to manage all three.