Skip to content

Commit 7332b5b

Browse files
committed
fix(net): Dns rejects the unspecified addresses (#2043, SR-AUD-304 wildcard half)
Dns::GetHostAddresses("0.0.0.0") returned the wildcard address. This ticket was split out of #2039 precisely because it is the one half of the finding that REMOVES A WORKING, MEANINGFUL RESULT, so it needed evidence rather than judgement. The reference supplies it at three separate sites: Dns.cs:686-690 on the string path, and :46-50 / :158-162 on the IPAddress overloads, all raising ArgumentException(SR.net_invalid_ip_addr) for IPAddress.Any or IPAddress.IPv6Any. .NET's message says why, and is transcribed rather than paraphrased: these are unspecified addresses. They name every local interface to bind, and nothing at all to connect -- so resolving one to itself hands a caller a target it cannot use. The rejection runs BEFORE the address-family check, matching the reference's own ordering. GetHostAddresses("0.0.0.0", InterNetworkV6) is therefore an ArgumentException, not a SocketException about the family, and a mutation that moves the check after the family test is caught. Only the two unspecified addresses moved. 127.0.0.1, ::1 and 8.8.8.8 still resolve, and so does 0.0.0.1 -- the last of these asserting that the check is an equality rather than a "starts with zero" test, which is the obvious way to get this wrong. +1 net test. Three mutations, all caught. Gate: 17,270 run, 0 failed, 38 executables -- green. Downstream, measured: zero Dns sites in either consumer. SR-AUD-304 -> remediated, both halves. Record: docs/Migration-DnsWildcardRejection.md.
1 parent 5ea4e13 commit 7332b5b

6 files changed

Lines changed: 134 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

audit/AUDIT_FINDINGS_INDEX.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `Dns::GetHostAddresses` rejects the unspecified addresses (ticket #2043)
5+
6+
*2026-08-17.* `Dns::GetHostAddresses("0.0.0.0")` returned the wildcard address. .NET raises
7+
`ArgumentException`.
8+
9+
Landed under `docs/StandingApprovals.md` SA-5. No public signature, layout, vtable or `noexcept`
10+
change. **This removes a result that previously worked** — read §2.
11+
12+
---
13+
14+
## 1. What changed
15+
16+
| Call | Was | Is |
17+
|---|---|---|
18+
| `GetHostAddresses("0.0.0.0")` | `{0.0.0.0}` | `ArgumentException` |
19+
| `GetHostAddresses("::")` | `{::}` | `ArgumentException` |
20+
| the same with an explicit `AddressFamily` | resolved | `ArgumentException` |
21+
| `GetHostAddresses("127.0.0.1")`, `"::1"`, `"8.8.8.8"`, `"0.0.0.1"` || **unchanged** |
22+
23+
The rejection comes **before** the address-family check, matching `Dns.cs:686-690`, which tests
24+
the wildcard immediately after `TryParse` and before anything else looks at the value. So
25+
`GetHostAddresses("0.0.0.0", InterNetworkV6)` is an `ArgumentException`, not a `SocketException`
26+
about the family — and a test asserts that ordering.
27+
28+
## 2. Why a working result is being removed
29+
30+
This ticket was split out of #2039 precisely because it is the one half of SR-AUD-304 that takes
31+
away something that worked, so it needed evidence rather than judgement. The reference supplies
32+
it in three places — `Dns.cs:686-690` on the string path, and `:46-50` and `:158-162` on the
33+
`IPAddress` overloads — all raising `ArgumentException(SR.net_invalid_ip_addr)`.
34+
35+
.NET's message says why, and the port transcribes rather than paraphrases it:
36+
37+
> IPv4 address 0.0.0.0 and IPv6 address ::0 are unspecified addresses that cannot be used as a
38+
> target address.
39+
40+
They name *every local interface* to `bind`, and *nothing at all* to `connect`. Resolving one to
41+
itself hands the caller a target it cannot use.
42+
43+
## 3. To migrate
44+
45+
If you passed `0.0.0.0` or `::` to `GetHostAddresses`, you were about to connect to an address
46+
that cannot be connected to. Use `IPAddress::Any` / `IPAddress::IPv6Any` directly for a `bind`,
47+
where they mean what you want, and do not route them through name resolution.
48+
49+
## 4. Downstream, measured
50+
51+
Neither `cna` nor `mobile-eggbert` references `System::Net::Dns`**zero sites in both**.
52+
Neither repository was modified.

modules/net/src/System/Net/Dns.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/Net/Dns.hpp"
5+
#include "System/ArgumentException.hpp"
56
#include "System/Net/Sockets/SocketException.hpp"
67
#include <array>
78
#include <cstring>
@@ -189,6 +190,32 @@ namespace System::Net {
189190
// and found nothing'". docs/SystemNetNamespaceReviewPlan.md §7.3 predicted an empty
190191
// result for the AddressFamily::Unix row; that prediction was made without those tests
191192
// in view and is corrected in §17.6 rather than followed.
193+
/**
194+
* @brief Rejects the two unspecified ("wildcard") addresses, as .NET does.
195+
*
196+
* Ticket #2043 (SR-AUD-304's wildcard half). `GetHostAddresses("0.0.0.0")` returned the
197+
* wildcard address, and the ticket was split out of #2039 precisely because this is the
198+
* one half that **removes a working, meaningful result** -- so it needed evidence rather
199+
* than judgement. The reference supplies it: `Dns.cs:686-690` in the string path and
200+
* `:46-50`, `:158-162` in the `IPAddress` overloads all raise
201+
* `ArgumentException(SR.net_invalid_ip_addr)` for `IPAddress.Any` or `IPAddress.IPv6Any`.
202+
*
203+
* .NET's message says why, and it is transcribed rather than paraphrased: these are
204+
* *unspecified* addresses. They name "every local interface" to `bind`, and nothing at
205+
* all to `connect` -- so resolving one to itself hands a caller a target it cannot use.
206+
*
207+
* @param parameterName `"hostName"` on the string path and `"address"` on the
208+
* `IPAddress` one, matching .NET's own `nameof` at each site.
209+
*/
210+
void throwIfUnspecifiedAddress(const IPAddress& address, const char* parameterName) {
211+
if (address == IPAddress::Any || address == IPAddress::IPv6Any) {
212+
throw System::ArgumentException(
213+
"IPv4 address 0.0.0.0 and IPv6 address ::0 are unspecified addresses that "
214+
"cannot be used as a target address.",
215+
parameterName);
216+
}
217+
}
218+
192219
[[noreturn]] void throwNoAddressOfRequestedFamily(const std::string& host, AddressFamily family) {
193220
throw SocketException(SocketError::HostNotFound,
194221
"Dns: host '" + host + "' has no address in the requested address family '" +
@@ -224,6 +251,9 @@ namespace System::Net {
224251
throw System::PlatformNotSupportedException("Dns.GetHostAddresses is not supported on Emscripten.");
225252
#else
226253
if (auto literal = tryParseIPLiteral(hostNameOrAddress)) {
254+
// #2043: BEFORE the family check, matching Dns.cs:686-690, which tests the wildcard
255+
// immediately after IPAddress.TryParse and before anything else looks at the value.
256+
throwIfUnspecifiedAddress(*literal, "hostNameOrAddress");
227257
if (!literalSatisfiesFamily(family, *literal)) {
228258
throwNoAddressOfRequestedFamily(hostNameOrAddress, family);
229259
}

modules/net/tests/System/Net/DnsLiteralAndDuplicateTests.cpp

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
// These tests use only IP literals and names this container resolves from /etc/hosts, so none
2828
// of them needs a network. The one place a real DNS answer would be required is guarded.
2929
#include <gtest/gtest.h>
30+
#include "System/ArgumentException.hpp"
3031
#include <algorithm>
3132
#include <string>
3233
#include <vector>
@@ -265,21 +266,57 @@ TEST(DnsErrorMessageTests, GetHostEntryFailureDoesNotNameAWin32Error) {
265266
}
266267

267268
// ---------------------------------------------------------------------------
268-
// The wildcard is DELIBERATELY unchanged -- it is the gated ticket #2043's half
269-
// of SR-AUD-304, and this pin exists so that ticket cannot land silently.
269+
// #2043 RESOLVED -- the wildcard literals are rejected.
270+
//
271+
// The ticket was split out of #2039 precisely because this is the one half of SR-AUD-304 that
272+
// REMOVES A WORKING, MEANINGFUL RESULT, so it needed evidence rather than judgement. The
273+
// reference supplies it: Dns.cs:686-690 on the string path, and :46-50 / :158-162 on the
274+
// IPAddress overloads, all raise ArgumentException(SR.net_invalid_ip_addr) for IPAddress.Any or
275+
// IPAddress.IPv6Any.
270276
// ---------------------------------------------------------------------------
271277

272-
TEST(DnsWildcardPinTests, WildcardLiteralsStillResolveToThemselves_SeeTicket2043) {
273-
const std::vector<IPAddress> v4 = Dns::GetHostAddresses("0.0.0.0");
274-
ASSERT_EQ(v4.size(), 1u);
275-
EXPECT_EQ(v4[0], IPAddress::Any);
278+
TEST(DnsWildcardPinTests, Fix2043_WildcardLiteralsAreRejected) {
279+
// .NET's message says WHY, and it is transcribed rather than paraphrased: these are
280+
// UNSPECIFIED addresses. They name "every local interface" to bind, and nothing at all to
281+
// connect -- so resolving one to itself hands a caller a target it cannot use.
282+
for (const char* wildcard : {"0.0.0.0", "::"}) {
283+
SCOPED_TRACE(wildcard);
284+
EXPECT_THROW((void)Dns::GetHostAddresses(wildcard), System::ArgumentException);
285+
EXPECT_THROW((void)Dns::GetHostAddresses(wildcard, AddressFamily::InterNetwork),
286+
System::ArgumentException);
287+
EXPECT_THROW((void)Dns::GetHostAddresses(wildcard, AddressFamily::InterNetworkV6),
288+
System::ArgumentException);
289+
}
290+
291+
// The rejection comes BEFORE the family check, matching Dns.cs:686-690, which tests the
292+
// wildcard immediately after TryParse and before anything else looks at the value. So
293+
// "0.0.0.0" with an IPv6-only request is an ArgumentException, not a SocketException about
294+
// the family.
295+
try {
296+
(void)Dns::GetHostAddresses("0.0.0.0", AddressFamily::InterNetworkV6);
297+
ADD_FAILURE() << "expected an ArgumentException";
298+
} catch (const System::ArgumentException&) {
299+
SUCCEED();
300+
} catch (const SocketException&) {
301+
ADD_FAILURE() << "the family check ran first; the wildcard check must come before it";
302+
}
303+
}
304+
305+
TEST(DnsWildcardPinTests, Fix2043_EveryOtherLiteralStillResolvesToItself) {
306+
// The invariance row: only the two unspecified addresses moved. A loopback literal is not
307+
// one of them, and neither is any ordinary address.
308+
const std::vector<IPAddress> loopback = Dns::GetHostAddresses("127.0.0.1");
309+
ASSERT_EQ(loopback.size(), 1u);
310+
EXPECT_EQ(loopback[0], IPAddress::Loopback);
311+
312+
const std::vector<IPAddress> v6Loopback = Dns::GetHostAddresses("::1");
313+
ASSERT_EQ(v6Loopback.size(), 1u);
314+
EXPECT_EQ(v6Loopback[0], IPAddress::IPv6Loopback);
276315

277-
const std::vector<IPAddress> v6 = Dns::GetHostAddresses("::");
278-
ASSERT_EQ(v6.size(), 1u);
279-
EXPECT_EQ(v6[0], IPAddress::IPv6Any);
316+
const std::vector<IPAddress> ordinary = Dns::GetHostAddresses("8.8.8.8");
317+
ASSERT_EQ(ordinary.size(), 1u);
280318

281-
const std::vector<IPAddress> v4Family =
282-
Dns::GetHostAddresses("0.0.0.0", AddressFamily::InterNetwork);
283-
ASSERT_EQ(v4Family.size(), 1u);
284-
EXPECT_EQ(v4Family[0], IPAddress::Any);
319+
// ...and 0.0.0.1 is NOT the wildcard, so the check must be an equality rather than a
320+
// "starts with zero" test.
321+
EXPECT_NO_THROW((void)Dns::GetHostAddresses("0.0.0.1"));
285322
}

plan.sqlite3

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)