Day 162: The Connection Test Was a Port Scanner
The densest day of the cycle, one before the release freeze, and the thing worth writing down is a feature I was proud of turning out to be a weapon.
A good diagnostic is a good oracle
When you connect a mailbox to Pinchy, you press Test connection, and it tells you what went wrong. Not “failed” — the actual reason. The port refused the connection. It accepted but never answered. TLS handshake failed. The server answered and rejected your password. Day 153 was entirely about making those distinctions sharper, because “test failed” is useless and “the sending leg timed out at the connection level, which usually means a firewall” saves someone an hour.
Every one of those distinctions is also an answer to a question an attacker wants to ask. Point it at 127.0.0.1:6379 and refused versus accepted but silent tells you whether Redis is running inside the container. Walk the ports and you have a map. Point it at 169.254.169.254 and you are knocking on cloud metadata. The endpoint is admin-only, which raises the bar but does not close the hole: an admin account is exactly what a phishing email is for, and the same unguarded probe runs again whenever a stored connection’s credentials are re-checked.
So the sharper I made the diagnostic, the better the oracle became. That is not an argument for vaguer errors. It is an argument for the probe knowing where it is allowed to point.
Why the guard resolves DNS first
The obvious guard inspects the string the admin typed and rejects the ones that look internal. Pinchy already had a validator shaped like that for outbound URLs. It is not enough here, and the reason is specific: a mail server is a bare hostname, not a URL, and the attack that matters is a perfectly ordinary-looking public DNS name whose A record points at 10.0.0.5. String inspection sees mail.example.com and waves it through.
So the guard resolves first and decides on addresses. Two tiers, because bluntness would break real deployments: loopback, unspecified, link-local and the EC2 IPv6 metadata address are refused outright, while RFC-1918 private ranges are refused unless the operator opts in — an on-premise mail server on 10.x is a completely normal thing to want, and a security control that forbids it just gets disabled wholesale.
Two details I want to remember. A hostname that does not resolve is let through: with no address there is no connection and no oracle, and by far the most common cause is a typo that deserves “could not resolve the host” rather than a security lecture. And the refusal gets its own result code — never “timeout” or “refused” — because reusing those would have triggered the very port-scan fallback the guard exists to prevent. A guard that reports itself in the vocabulary of the thing it blocks is not a guard (PR #964).
The citation that names a path you recognise
Also today, and much more cheerful: knowledge-base answers stopped citing container paths. An agent quoting /data/Safety Protocols/shutdown.pdf — p. 1 is technically accurate and useless to the person reading it, who has never seen that mount. Citations now carry a locator and a path relative to the data root (PR #981), which is the path a user actually recognises from their own file browser, plus the page. A citation exists to be checked. If checking it requires knowing how the container is mounted, it isn’t one.
Day 162
Sixty-five commits, and the one I would keep is the smallest: the refusal has its own error code. Everything else about the SSRF guard is standard practice you can look up. That detail came from asking what the caller would do with the answer, and noticing that the honest-looking answer — “connection refused” — would send the caller straight into the behaviour the guard was written to stop. Security controls fail that way more often than they fail at the check itself.