Assert the invariant that holds on both platforms, not macOS's parse
The test I wrote to document a parser differential was itself
platform-specific, passed on this Mac, and failed in CI — which is the
point it was making, at its own expense.
`inet_pton` does not agree with itself across platforms:
input Linux macOS
0177.0.0.1 rejected 177.0.0.1
010.0.0.1 rejected 10.0.0.1
2130706433 rejected rejected
127.1 rejected rejected
Darwin accepts a leading zero and reads it as decimal; Linux is strict
and rejects it; `inet_aton`, curl and every browser read `0177` as octal,
which is 127, which is loopback. Three readings of eleven characters, one
of them "public" and one of them loopback. I pinned the middle one.
The security-relevant fact is not which way any libc jumps. It is that
`isFetchableHost` refuses a four-numeric-label name by SHAPE, before
anything is resolved — no parsing, so nothing platform-dependent, so it
is the part that can actually be relied on. That is what the test asserts
now, across six spellings, plus the `IPAddress` results that genuinely
are the same on both.
Measured rather than reasoned about: the table above came from running
the same Swift program under `swift:6.1-noble` and natively.
664 tests, 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a5e30ed18a
commit
c626683190
@@ -25,15 +25,21 @@ struct IPAddress: Sendable, Equatable, CustomStringConvertible {
|
||||
/// Parses a presentation-form address. Returns nil for anything that is not
|
||||
/// one, including a hostname.
|
||||
///
|
||||
/// 🔴 **For resolver output, never for an untrusted literal.** `inet_pton`
|
||||
/// on Darwin accepts a leading zero and reads it as DECIMAL: `0177.0.0.1`
|
||||
/// parses to `177.0.0.1`, a public address. `inet_aton`, curl and every
|
||||
/// browser read `0177` as OCTAL — 127 — which is loopback. So that string
|
||||
/// is public to this parser and loopback to whatever would connect, which
|
||||
/// is a bypass waiting to happen if this is ever used to vet a name.
|
||||
/// Resolver output is already canonical and cannot carry the ambiguity;
|
||||
/// `PeerDirectory.isFetchableHost` is what rejects the literal spellings,
|
||||
/// before any resolution is attempted.
|
||||
/// 🔴 **For resolver output, never for an untrusted literal.**
|
||||
/// `inet_pton` does not agree with itself across platforms. On Darwin it
|
||||
/// accepts a leading zero and reads it as DECIMAL, so `0177.0.0.1` parses
|
||||
/// to `177.0.0.1` — a public address. On Linux, where this actually runs,
|
||||
/// it rejects the string. And `inet_aton`, curl and every browser read
|
||||
/// `0177` as OCTAL — 127 — which is loopback. Three readings of eleven
|
||||
/// characters, one of which is "public" and one of which connects to
|
||||
/// loopback.
|
||||
///
|
||||
/// Resolver output is already canonical and cannot carry that ambiguity,
|
||||
/// which is why this is only ever given resolver output.
|
||||
/// `PeerDirectory.isFetchableHost` is what rejects literal spellings, before
|
||||
/// any resolution is attempted, and it does so by shape rather than by
|
||||
/// parsing — which is what makes it platform-independent and therefore the
|
||||
/// part that can be relied on.
|
||||
init?(_ text: String) {
|
||||
var v4 = in_addr()
|
||||
if text.withCString({ inet_pton(AF_INET, $0, &v4) }) == 1 {
|
||||
|
||||
@@ -63,37 +63,48 @@ struct OutboundAddressGuardTests {
|
||||
#expect(ok.isPubliclyRoutable)
|
||||
}
|
||||
|
||||
/// 🔴 **A parser differential, found by writing the wrong assertion first.**
|
||||
/// 🔴 **A parser differential — and the differential itself differs by
|
||||
/// platform, which is how this test first went wrong.**
|
||||
///
|
||||
/// I expected `inet_pton` to reject `0177.0.0.1` outright. It does not: on
|
||||
/// Darwin it accepts the leading zero and reads it as DECIMAL, giving
|
||||
/// `177.0.0.1` — a public address. `inet_aton`, curl and every browser read
|
||||
/// `0177` as OCTAL, which is 127, which is loopback. So the same eleven
|
||||
/// characters are a public host to this parser and loopback to the thing
|
||||
/// that would actually connect.
|
||||
/// `inet_pton` on **Darwin** accepts a leading zero and reads it as
|
||||
/// DECIMAL: `0177.0.0.1` parses to `177.0.0.1`, a public address. On
|
||||
/// **Linux** — which is where the relay actually runs — it is strict and
|
||||
/// rejects the string outright. Meanwhile `inet_aton`, curl and every
|
||||
/// browser read `0177` as OCTAL, which is 127, which is loopback.
|
||||
///
|
||||
/// ⚠️ **Which is why `IPAddress` must only ever be given resolver output,
|
||||
/// never an untrusted literal.** Resolver output is already canonical, so
|
||||
/// the differential cannot arise there. What protects the literal case is
|
||||
/// `isFetchableHost`, which rejects any name of four numeric labels before
|
||||
/// resolution is even attempted — checked below, because that defence is
|
||||
/// now load-bearing for a reason it was not originally written for.
|
||||
@Test("odd address spellings do not become a public verdict on a private host")
|
||||
/// input Linux macOS
|
||||
/// 0177.0.0.1 rejected 177.0.0.1
|
||||
/// 010.0.0.1 rejected 10.0.0.1
|
||||
/// 2130706433 rejected rejected
|
||||
/// 127.1 rejected rejected
|
||||
///
|
||||
/// ⚠️ So this asserts the invariant that holds on BOTH, not one platform's
|
||||
/// parse. The first version pinned the macOS answer, passed here and failed
|
||||
/// in CI — proving the point it was written to make, at its own expense.
|
||||
///
|
||||
/// 🔴 **The defence is `isFetchableHost`, and it is platform-independent.**
|
||||
/// `IPAddress` must only ever be given resolver output, which is already
|
||||
/// canonical and cannot carry the ambiguity. What stops an odd literal is
|
||||
/// the name filter rejecting four numeric labels before anything is
|
||||
/// resolved, and that is what is checked here.
|
||||
@Test("odd address spellings never reach a resolver")
|
||||
func alternateSpellings() throws {
|
||||
// Universal: the canonical form parses the same everywhere.
|
||||
#expect(IPAddress("127.0.0.1")?.bytes == [127, 0, 0, 1])
|
||||
|
||||
// Documented, not endorsed: this is what Darwin actually does.
|
||||
#expect(IPAddress("0177.0.0.1")?.description == "177.0.0.1")
|
||||
// …and the spelling never reaches a resolver, because the name filter
|
||||
// rejects four numeric labels first.
|
||||
#expect(!PeerDirectory.isFetchableHost("0177.0.0.1"))
|
||||
#expect(!PeerDirectory.isFetchableHost("127.0.0.1"))
|
||||
#expect(!PeerDirectory.isFetchableHost("2130706433"))
|
||||
|
||||
// Strict in the directions people expect it to be.
|
||||
// Universal: these are not addresses to either libc.
|
||||
#expect(IPAddress("2130706433") == nil)
|
||||
#expect(IPAddress("127.1") == nil)
|
||||
#expect(IPAddress("not.a.host") == nil)
|
||||
|
||||
// 🔴 The actual guarantee. Whatever `IPAddress` would make of these —
|
||||
// and it disagrees with itself across platforms — none of them is ever
|
||||
// dialled, because the name never survives long enough to be resolved.
|
||||
for spelling in ["0177.0.0.1", "010.0.0.1", "127.0.0.1", "2130706433",
|
||||
"169.254.169.254", "192.168.1.1"] {
|
||||
#expect(!PeerDirectory.isFetchableHost(spelling),
|
||||
"\(spelling) must be refused as a peer name")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The guard
|
||||
|
||||
Reference in New Issue
Block a user