From 4cbbbdcc1576bc9e403300884c441490f66aa870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 29 Jul 2026 21:41:58 -0300 Subject: [PATCH 1/4] net: reject non-address SocketAddress.parse input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Guilherme Araújo --- doc/api/net.md | 5 ++++ lib/internal/socketaddress.js | 10 ++++++++ test/parallel/test-socketaddress.js | 36 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/doc/api/net.md b/doc/api/net.md index 8588ffd0462f..82f1aa6f87c4 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -445,6 +445,11 @@ added: * Returns: {net.SocketAddress} Returns a `SocketAddress` if parsing was successful. Otherwise returns `undefined`. +The `input` may contain only hexadecimal digits, `x`, `.`, `:`, `[`, and `]`. +Anything else returns `undefined`, including other URL components such as +`user@1.2.3.4` or `1.2.3.4/foo`, whitespace, control characters, +percent-encoding, and non-ASCII characters. + ## Class: `net.Server` * `input` {string} An input string containing an IP address and optional port, @@ -445,12 +451,28 @@ added: * Returns: {net.SocketAddress} Returns a `SocketAddress` if parsing was successful. Otherwise returns `undefined`. -The address portion of `input` must be a valid IPv4 or IPv6 address as -recognized by the [WHATWG URL host parser][], and `input` may contain only -hexadecimal digits, `x`, `.`, `:`, `[`, and `]`. Anything else returns -`undefined`, including host names such as `example.com`, other URL components -such as `user@1.2.3.4` or `1.2.3.4/foo`, whitespace, control characters, -percent-encoding, and non-ASCII characters. +The entire input must match one of the following forms: + +```text +socket-address = ipv4-socket-address / ipv6-socket-address +ipv4-socket-address = ipv4-address [ ":" port ] +ipv6-socket-address = "[" ipv6-address [ "%" scope-id ] "]" [ ":" port ] + +ipv4-address = octet 3( "." octet ) +octet = 1*3DIGIT ; no leading zeros; value <= 255 +ipv6-address = RFC 4291 textual form: groups of 1*4HEXDIG (leading + zeros allowed, case-insensitive), "::" compression, and + an optional trailing embedded ipv4-address +port = 1*DIGIT ; leading zeros allowed; value <= 65535 +scope-id = 1*DIGIT ; leading zeros allowed; value <= 4294967295 +``` + +Anything else returns `undefined`, including URL components such as userinfo, +paths, queries and fragments, surrounding whitespace, host names, non-ASCII +digits, and the legacy IPv4 notations that permit octal (`0177.0.0.1`), +hexadecimal (`0x7f.0.0.1`), integer (`2130706433`) and shorthand (`127.1`) +addresses. An IPv6 zone id must be numeric; interface names such as +`%eth0` are not accepted. ## Class: `net.Server` @@ -2581,7 +2603,6 @@ console.log('listening on', server.address().port); [RFC 8305]: https://www.rfc-editor.org/rfc/rfc8305.txt [Readable Stream]: stream.md#class-streamreadable [Transferring TCP handles to other threads]: #transferring-tcp-handles-to-other-threads -[WHATWG URL host parser]: https://url.spec.whatwg.org/#host-parsing [`'close'`]: #event-close [`'connect'`]: #event-connect [`'connection'`]: #event-connection diff --git a/lib/internal/socketaddress.js b/lib/internal/socketaddress.js index 10c06fe08d3e..d4383551f77a 100644 --- a/lib/internal/socketaddress.js +++ b/lib/internal/socketaddress.js @@ -2,12 +2,12 @@ const { ObjectSetPrototypeOf, - RegExpPrototypeExec, Symbol, } = primordials; const { SocketAddress: _SocketAddress, + parseSocketAddress, AF_INET, AF_INET6, } = internalBinding('block_list'); @@ -38,13 +38,22 @@ const { kDeserialize, } = require('internal/worker/js_transferable'); -const { URLParse } = require('internal/url'); - const kHandle = Symbol('kHandle'); const kDetail = Symbol('kDetail'); -// The complete character set of an "${address}:${port}" input. -const kValidInput = /^[0-9a-fA-FxX.:[\]]+$/; +class InternalSocketAddress { + constructor(handle) { + markTransferMode(this, true, false); + + this[kHandle] = handle; + this[kDetail] = this[kHandle]?.detail({ + address: undefined, + port: undefined, + family: undefined, + flowlabel: undefined, + }); + } +} class SocketAddress { static isSocketAddress(value) { @@ -153,41 +162,9 @@ class SocketAddress { */ static parse(input) { validateString(input, 'input'); - if (RegExpPrototypeExec(kValidInput, input) === null) return; - // While URL.parse is not expected to throw, there are several - // other pieces here that do... the destucturing, the SocketAddress - // constructor, etc. So we wrap this in a try/catch to be safe. - try { - const { - hostname: address, - port, - } = URLParse(`http://${input}`); - if (address.startsWith('[') && address.endsWith(']')) { - return new SocketAddress({ - address: address.slice(1, -1), - port: port | 0, - family: 'ipv6', - }); - } - return new SocketAddress({ address, port: port | 0 }); - } catch { - // Ignore errors here. Return undefined if the input cannot - // be successfully parsed or is not a proper socket address. - } - } -} - -class InternalSocketAddress { - constructor(handle) { - markTransferMode(this, true, false); - - this[kHandle] = handle; - this[kDetail] = this[kHandle]?.detail({ - address: undefined, - port: undefined, - family: undefined, - flowlabel: undefined, - }); + const handle = parseSocketAddress(input); + if (handle === undefined) return undefined; + return new InternalSocketAddress(handle); } } diff --git a/node.gyp b/node.gyp index 4f7a3d1ff634..5424901f1a97 100644 --- a/node.gyp +++ b/node.gyp @@ -162,6 +162,7 @@ 'src/node_shadow_realm.cc', 'src/node_snapshotable.cc', 'src/node_sockaddr.cc', + 'src/node_sockaddr_parser.cc', 'src/node_stat_watcher.cc', 'src/node_symbols.cc', 'src/node_task_queue.cc', @@ -296,6 +297,7 @@ 'src/node_snapshot_builder.h', 'src/node_sockaddr.h', 'src/node_sockaddr-inl.h', + 'src/node_sockaddr_parser.h', 'src/node_stat_watcher.h', 'src/node_union_bytes.h', 'src/node_url.h', diff --git a/src/node_sockaddr.cc b/src/node_sockaddr.cc index e12fb86a3a95..573aeec10e78 100644 --- a/src/node_sockaddr.cc +++ b/src/node_sockaddr.cc @@ -7,10 +7,13 @@ #include "node_errors.h" #include "node_hash.h" #include "node_sockaddr-inl.h" // NOLINT(build/include_inline) +#include "node_sockaddr_parser.h" #include "uv.h" #include +#include #include +#include #include namespace node { @@ -69,6 +72,31 @@ bool SocketAddress::New(int32_t family, family, host, port, reinterpret_cast(addr->storage())); } +bool SocketAddress::Parse(std::string_view input, SocketAddress* addr) { + std::optional parsed = + sockaddr_parser::ParseSocketAddress(input); + if (!parsed.has_value()) return false; + + CHECK_LE(parsed->host.size(), sockaddr_parser::kMaxHostLength); + + char host[sockaddr_parser::kMaxHostLength + 1]; + host[parsed->host.copy(host, parsed->host.size())] = '\0'; + + if (!New(parsed->is_ipv6 ? AF_INET6 : AF_INET, host, parsed->port, addr)) { + return false; + } + + // libuv resolves a zone id as an interface name, never as a number. + // TODO(@araujogui): sin6_scope_id is neither exposed to JS nor hashed, so + // scoped addresses do not round-trip and collide in BlockList. + if (parsed->is_ipv6) { + reinterpret_cast(addr->storage())->sin6_scope_id = + parsed->scope_id; + } + + return true; +} + size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const { // Hash only the meaningful bytes (family + port + address), not the // full 128-byte sockaddr_storage. @@ -84,6 +112,8 @@ size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const { case AF_INET6: { const sockaddr_in6* ipv6 = reinterpret_cast(addr.raw()); + // TODO(@araujogui): sin6_scope_id is not hashed, so addresses that + // differ only by zone id collide. uint8_t buf[18]; memcpy(buf, &ipv6->sin6_port, 2); memcpy(buf + 2, &ipv6->sin6_addr, 16); @@ -1126,6 +1156,8 @@ void SocketAddressBase::Initialize(Environment* env, Local target) { "SocketAddress", GetConstructorTemplate(env), SetConstructorFunctionFlag::NONE); + + SetMethod(env->context(), target, "parseSocketAddress", Parse); } BaseObjectPtr SocketAddressBase::Create( @@ -1164,6 +1196,20 @@ void SocketAddressBase::New(const FunctionCallbackInfo& args) { new SocketAddressBase(env, args.This(), std::move(addr)); } +void SocketAddressBase::Parse(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK(args[0]->IsString()); // input + + Utf8Value input(env->isolate(), args[0]); + + auto addr = std::make_shared(); + if (!SocketAddress::Parse(input.ToStringView(), addr.get())) return; + + BaseObjectPtr base = + SocketAddressBase::Create(env, std::move(addr)); + if (base) args.GetReturnValue().Set(base->object()); +} + void SocketAddressBase::Detail(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); CHECK(args[0]->IsObject()); diff --git a/src/node_sockaddr.h b/src/node_sockaddr.h index 55354138fa84..1a923f727020 100644 --- a/src/node_sockaddr.h +++ b/src/node_sockaddr.h @@ -15,6 +15,7 @@ #include #include #include +#include #include namespace node { @@ -59,6 +60,9 @@ class SocketAddress : public MemoryRetainer { static bool New(const char* host, uint32_t port, SocketAddress* addr); + // Returns true if parsing input as an "ip[:port]" socket address succeeded. + static bool Parse(std::string_view input, SocketAddress* addr); + // Returns the port for an IPv4 or IPv6 address. inline static int GetPort(const sockaddr* addr); inline static int GetPort(const sockaddr_storage* addr); @@ -157,6 +161,7 @@ class SocketAddressBase : public BaseObject { Environment* env, std::shared_ptr address); static void New(const v8::FunctionCallbackInfo& args); + static void Parse(const v8::FunctionCallbackInfo& args); static void Detail(const v8::FunctionCallbackInfo& args); static void LegacyDetail(const v8::FunctionCallbackInfo& args); static void GetFlowLabel(const v8::FunctionCallbackInfo& args); diff --git a/src/node_sockaddr_parser.cc b/src/node_sockaddr_parser.cc new file mode 100644 index 000000000000..fd5c370acdb0 --- /dev/null +++ b/src/node_sockaddr_parser.cc @@ -0,0 +1,136 @@ +#include "node_sockaddr_parser.h" + +namespace node::sockaddr_parser { + +namespace { + +constexpr uint32_t kMaxPort = 65535; +constexpr uint32_t kMaxScopeId = UINT32_MAX; + +// Not std::isdigit: that one is locale dependent. +std::optional ToDigit(char c) { + if (c < '0' || c > '9') return std::nullopt; + return static_cast(c - '0'); +} + +// A cursor over the input. A Read that fails consumes nothing. +class Parser { + public: + explicit Parser(std::string_view input) : remaining_(input) {} + + bool done() const { return remaining_.empty(); } + + bool ReadChar(char expected) { + if (remaining_.empty() || remaining_.front() != expected) return false; + remaining_.remove_prefix(1); + return true; + } + + std::optional ReadNumber(uint32_t max_value); + std::optional ReadTaggedNumber(char tag, uint32_t max_value); + std::optional ReadHost(std::string_view delimiters); + + std::optional ReadIPv4SocketAddress(); + std::optional ReadIPv6SocketAddress(); + std::optional ReadSocketAddress(); + + private: + std::string_view remaining_; +}; + +std::optional Parser::ReadNumber(uint32_t max_value) { + const std::string_view start = remaining_; + + uint64_t value = 0; + size_t digits = 0; + + while (!remaining_.empty()) { + std::optional digit = ToDigit(remaining_.front()); + if (!digit.has_value()) break; + remaining_.remove_prefix(1); + value = value * 10 + digit.value(); + digits++; + if (value > max_value) break; + } + + if (digits == 0 || value > max_value) { + remaining_ = start; + return std::nullopt; + } + + return static_cast(value); +} + +std::optional Parser::ReadTaggedNumber(char tag, uint32_t max_value) { + const std::string_view start = remaining_; + + if (!ReadChar(tag)) return std::nullopt; + + std::optional value = ReadNumber(max_value); + if (!value.has_value()) remaining_ = start; + + return value; +} + +std::optional Parser::ReadHost(std::string_view delimiters) { + const size_t end = remaining_.find_first_of(delimiters); + const std::string_view host = + end == std::string_view::npos ? remaining_ : remaining_.substr(0, end); + + // uv_inet_pton reads only up to the first NUL. + if (host.size() > kMaxHostLength || + host.find('\0') != std::string_view::npos) { + return std::nullopt; + } + + remaining_.remove_prefix(host.size()); + + return host; +} + +std::optional Parser::ReadIPv4SocketAddress() { + std::optional host = ReadHost(":"); + if (!host.has_value()) return std::nullopt; + + parse_result result = {}; + result.host = *host; + result.port = + static_cast(ReadTaggedNumber(':', kMaxPort).value_or(0)); + + return result; +} + +std::optional Parser::ReadIPv6SocketAddress() { + if (!ReadChar('[')) return std::nullopt; + + std::optional host = ReadHost("%]"); + if (!host.has_value()) return std::nullopt; + + parse_result result = {}; + result.is_ipv6 = true; + result.host = *host; + result.scope_id = ReadTaggedNumber('%', kMaxScopeId).value_or(0); + + if (!ReadChar(']')) return std::nullopt; + + result.port = + static_cast(ReadTaggedNumber(':', kMaxPort).value_or(0)); + + return result; +} + +std::optional Parser::ReadSocketAddress() { + return remaining_.starts_with('[') ? ReadIPv6SocketAddress() + : ReadIPv4SocketAddress(); +} + +} // namespace + +std::optional ParseSocketAddress(std::string_view input) { + Parser parser(input); + std::optional result = parser.ReadSocketAddress(); + if (!result.has_value() || !parser.done()) return std::nullopt; + return result; +} + +} // namespace node::sockaddr_parser diff --git a/src/node_sockaddr_parser.h b/src/node_sockaddr_parser.h new file mode 100644 index 000000000000..c17bd0a25703 --- /dev/null +++ b/src/node_sockaddr_parser.h @@ -0,0 +1,32 @@ +#ifndef SRC_NODE_SOCKADDR_PARSER_H_ +#define SRC_NODE_SOCKADDR_PARSER_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include +#include +#include + +namespace node::sockaddr_parser { + +// The length of the longest numeric address, +// "ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255". +constexpr size_t kMaxHostLength = 45; + +struct parse_result { + bool is_ipv6; + std::string_view host; + uint16_t port; + uint32_t scope_id; +}; + +// Splits input into its components. The host is left for uv_inet_pton to +// validate. See the grammar in doc/api/net.md. +std::optional ParseSocketAddress(std::string_view input); + +} // namespace node::sockaddr_parser + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#endif // SRC_NODE_SOCKADDR_PARSER_H_ diff --git a/test/cctest/test_sockaddr.cc b/test/cctest/test_sockaddr.cc index adb15f9f84cf..85e46c14ee9a 100644 --- a/test/cctest/test_sockaddr.cc +++ b/test/cctest/test_sockaddr.cc @@ -1,5 +1,11 @@ #include "gtest/gtest.h" #include "node_sockaddr-inl.h" +#include "node_sockaddr_parser.h" + +#include +#include + +namespace sockaddr_parser = node::sockaddr_parser; using node::SocketAddress; using node::SocketAddressBlockList; @@ -293,6 +299,170 @@ TEST(SocketAddress, NewAutoFamily) { CHECK(!SocketAddress::New("not_an_address", 0, &addr)); } +TEST(SocketAddress, ParseValid) { + static constexpr struct { + std::string_view input; + int family; + std::string_view address; + int port; + } kAccepted[] = { + {"1.2.3.4", AF_INET, "1.2.3.4", 0}, + {"1.2.3.4:8080", AF_INET, "1.2.3.4", 8080}, + // A well known port must survive; the URL based parser dropped it. + {"1.2.3.4:80", AF_INET, "1.2.3.4", 80}, + {"1.2.3.4:443", AF_INET, "1.2.3.4", 443}, + // Leading zeros are allowed in the port but not in an octet. + {"1.2.3.4:080", AF_INET, "1.2.3.4", 80}, + {"0.0.0.0", AF_INET, "0.0.0.0", 0}, + {"255.255.255.255:65535", AF_INET, "255.255.255.255", 65535}, + {"[::1]:8080", AF_INET6, "::1", 8080}, + {"[::]", AF_INET6, "::", 0}, + {"[1:0::]", AF_INET6, "1::", 0}, + {"[1::8]:123", AF_INET6, "1::8", 123}, + {"[1:2:3:4:5:6:7:8]", AF_INET6, "1:2:3:4:5:6:7:8", 0}, + {"[ABCD::EF]:1", AF_INET6, "abcd::ef", 1}, + {"[::ffff:1.2.3.4]:80", AF_INET6, "::ffff:1.2.3.4", 80}, + {"[1:2:3:4:5:6:1.2.3.4]", AF_INET6, "1:2:3:4:5:6:102:304", 0}, + }; + + for (const auto& c : kAccepted) { + SocketAddress addr; + ASSERT_TRUE(SocketAddress::Parse(c.input, &addr)) + << "rejected: " << c.input; + EXPECT_EQ(addr.family(), c.family) << c.input; + EXPECT_EQ(addr.address(), c.address) << c.input; + EXPECT_EQ(addr.port(), c.port) << c.input; + } +} + +TEST(SocketAddress, ParseScopeId) { + SocketAddress addr; + + CHECK(SocketAddress::Parse("[fe80::1%2]:80", &addr)); + CHECK_EQ(addr.family(), AF_INET6); + CHECK_EQ(addr.address(), "fe80::1"); + CHECK_EQ(addr.port(), 80); + CHECK_EQ(reinterpret_cast(addr.data())->sin6_scope_id, + 2); + + CHECK(SocketAddress::Parse("[fe80::1]:80", &addr)); + CHECK_EQ(reinterpret_cast(addr.data())->sin6_scope_id, + 0); +} + +TEST(SocketAddress, ParseRejects) { + static constexpr std::string_view kRejected[] = { + // Legacy IPv4 forms. See CVE-2021-29923 and CVE-2021-29922. + "0177.0.0.1:80", + "01.2.3.4:80", + "1.2.3.04:80", + "00.0.0.0", + "0x7f.0.0.1:80", + "0xffffffff", + "0x.0x.0", + "2130706433:80", + "127.1:80", + "192.168.257:1", + "256", + "999999999:12", + + // Out of range or malformed IPv4. + "256.1.1.1:80", + "259.1.1.1", + "1.2.3.4.5:80", + "1.2.3", + "1.2.3.", + ".1.2.3.4", + "1.2.3.1234", + + // URL syntax. + "user@1.2.3.4:80", + "1.2.3.4:80/foo", + "1.2.3.4:80?x=1", + "1.2.3.4:80#f", + "http://1.2.3.4:80", + + // Leading or trailing junk. + " 1.2.3.4:80", + "1.2.3.4:80 ", + "1.2.3.4\t:80", + "1.2.3.4:", + "1.2.3.4::80", + "[::1]:8080extra", + + // A NUL byte must not hide the rest of the host from uv_inet_pton. + std::string_view("1.2.3.4\0junk:80", 15), + std::string_view("1.2.3.4:80\0junk", 15), + std::string_view("[::1\0junk]:80", 13), + + // Ports. + "1.2.3.4:65536", + "1.2.3.4:-1", + "1.2.3.4:+80", + "1.2.3.4:0x50", + + // IPv6 requires brackets and must be well formed. + "::1", + "[::1", + "::1]", + "[]", + "[12345::]", + "[1::2::3]", + "[1:2:3:4:5:6:7:8:9]", + "[1:2:3:4:5:6:7]", + "[1.2.3.4::]", + "[::1.2.3.4:5]", + "[:::]", + "[:1]", + + // Only numeric zone ids are accepted. + "[fe80::1%lo0]:80", + "[fe80::1%]:80", + "[fe80::1%2", + "[fe80::1%4294967296]:80", + + // Not addresses at all. + "", + "not an ip", + "abc.123", + "12:12:12", + "localhost:80", + }; + + for (std::string_view input : kRejected) { + SocketAddress addr; + EXPECT_FALSE(SocketAddress::Parse(input, &addr)) << "accepted: " << input; + } +} + +TEST(SocketAddress, ParseOverlongHost) { + // The host is copied into a fixed buffer before reaching uv_inet_pton. + SocketAddress addr; + CHECK(!SocketAddress::Parse( + std::string(sockaddr_parser::kMaxHostLength + 1, '1') + ":80", &addr)); + CHECK(!SocketAddress::Parse( + "[" + std::string(sockaddr_parser::kMaxHostLength + 1, 'a') + "]:80", + &addr)); + CHECK( + !SocketAddress::Parse("[::1%" + std::string(4096, '9') + "]:80", &addr)); + CHECK(!SocketAddress::Parse("1.2.3.4:" + std::string(4096, '9'), &addr)); +} + +TEST(SocketAddress, ParseMatchesLibuv) { + // The framing must hand libuv exactly the address text it would have been + // given directly. + SocketAddress parsed; + SocketAddress created; + + CHECK(SocketAddress::Parse("1.2.3.4:8080", &parsed)); + CHECK(SocketAddress::New(AF_INET, "1.2.3.4", 8080, &created)); + CHECK_EQ(parsed, created); + + CHECK(SocketAddress::Parse("[2001:db8::1]:443", &parsed)); + CHECK(SocketAddress::New(AF_INET6, "2001:db8::1", 443, &created)); + CHECK_EQ(parsed, created); +} + TEST(SocketAddress, HashIPv6) { sockaddr_storage s1, s2, s3; SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s1); diff --git a/test/parallel/test-socketaddress.js b/test/parallel/test-socketaddress.js index d6c98b7d2b0f..981ff8bdb5d0 100644 --- a/test/parallel/test-socketaddress.js +++ b/test/parallel/test-socketaddress.js @@ -4,8 +4,10 @@ const common = require('../common'); const assert = require('assert'); const { + BlockList, SocketAddress, } = require('net'); +const { inspect } = require('util'); const { InternalSocketAddress, @@ -139,75 +141,91 @@ describe('net.SocketAddress...', () => { }); it('SocketAddress.parse() works as expected', () => { + // The exhaustive grammar table lives in test/cctest/test_sockaddr.cc, + // next to the parser. These cover the JS layer and a few representatives. const good = [ { input: '1.2.3.4', address: '1.2.3.4', port: 0, family: 'ipv4' }, - { input: '192.168.257:1', address: '192.168.1.1', port: 1, family: 'ipv4' }, - { input: '256', address: '0.0.1.0', port: 0, family: 'ipv4' }, - { input: '999999999:12', address: '59.154.201.255', port: 12, family: 'ipv4' }, - { input: '0xffffffff', address: '255.255.255.255', port: 0, family: 'ipv4' }, - { input: '0x.0x.0', address: '0.0.0.0', port: 0, family: 'ipv4' }, + { input: '1.2.3.4:8080', address: '1.2.3.4', port: 8080, family: 'ipv4' }, + // A well known port must survive; the URL based parser dropped it. + { input: '1.2.3.4:80', address: '1.2.3.4', port: 80, family: 'ipv4' }, { input: '[1:0::]', address: '1::', port: 0, family: 'ipv6' }, { input: '[1::8]:123', address: '1::8', port: 123, family: 'ipv6' }, + { input: '[::ffff:1.2.3.4]:80', address: '::ffff:1.2.3.4', port: 80, family: 'ipv6' }, + // A numeric IPv6 zone id is accepted and does not appear in the address. + { input: '[fe80::1%2]:80', address: 'fe80::1', port: 80, family: 'ipv6' }, ]; - good.forEach((i) => { - const addr = SocketAddress.parse(i.input); - assert.strictEqual(addr.address, i.address); - assert.strictEqual(addr.port, i.port); - assert.strictEqual(addr.family, i.family); + good.forEach(({ input, ...expected }) => { + const addr = SocketAddress.parse(input); + assert.ok(addr, `${input} did not parse`); + assert.deepStrictEqual( + { address: addr.address, port: addr.port, family: addr.family }, + expected); }); const bad = [ - 'not an ip', - 'abc.123', - '259.1.1.1', - '12:12:12', - // Host names. - 'cabbage.ca', - 'cafe', - 'bad.cafe', - 'dead.beef', - // Arbitrary URL components. - 'user:80@5.6.7.8', - 'user@1.2.3.4', - '1.2.3.4/', - '1.2.3.4/foo', - '1.2.3.4\\foo', - '1.2.3.4?a=b', - '1.2.3.4#frag', - '[1::8]:123/x', - 'http://1.2.3.4', - // Whitespace and control characters. - '1.2.3\n.4', - '1.2.3\t.4', - '1.2.3.4\r', - '1.2.3.4 ', - ' 1.2.3.4', - '1.2.3.4\x00', - '1.2.3.4\x0b', - '1.2.3.4\x7f', - // Percent-encoding. - '1.2.3.%34', - '1%2E2%2E3%2E4', - '%30%78%66%66%66%66%66%66%66%66', - '1.2.3.%34:8080', - // IDNA. - '127.0.0.1', - '0x7f.1', - '1。2。3。4', - '1。2。3。4', - '⑧.0.0.1', - '1.2.3.4\u200b', - '1.2.3.4\ufeff', - '1.2.3.4\u00ad', - '1.2.3.4\u180e', - '1.2.3.4\u2064', - '1.2.3.4\ufe00', + // Legacy IPv4 forms. See CVE-2021-29923 and CVE-2021-29922. + '0177.0.0.1:80', + '0x7f.0.0.1:80', + '2130706433:80', + '127.1:80', + '192.168.257:1', + '0xffffffff', + // URL syntax. + 'user@1.2.3.4:80', + '1.2.3.4:80/foo', + // Strings that only reach the parser through Utf8Value. + '\u2460\u2461\u2462.4.5.6:80', + '1.2.3.4\u0000junk:80', + '1.2.3.4:80\u0000junk', + // Representative structural rejections. + '1.2.3.4:', + '1.2.3.4:65536', + '::1', + '[fe80::1%lo0]:80', + '', + 'localhost:80', ]; bad.forEach((i) => { - assert.strictEqual(SocketAddress.parse(i), undefined); + assert.strictEqual(SocketAddress.parse(i), undefined, `${i} should not parse`); }); + + assert.throws(() => SocketAddress.parse(1), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('SocketAddress.parse() returns a branded SocketAddress', () => { + const parsed = SocketAddress.parse('1.2.3.4:8080'); + + assert.ok(parsed instanceof SocketAddress); + assert.ok(SocketAddress.isSocketAddress(parsed)); + assert.strictEqual(parsed.constructor, SocketAddress); + }); + + it('SocketAddress.parse() matches the constructor', () => { + const parsed = SocketAddress.parse('1.2.3.4:8080'); + const built = new SocketAddress({ address: '1.2.3.4', port: 8080 }); + + assert.deepStrictEqual(parsed.toJSON(), built.toJSON()); + assert.strictEqual(inspect(parsed), inspect(built)); + }); + + it('SocketAddress.parse() returns a cloneable SocketAddress', () => { + const parsed = SocketAddress.parse('1.2.3.4:8080'); + const clone = structuredClone(parsed); + + assert.ok(clone instanceof SocketAddress); + assert.deepStrictEqual(clone.toJSON(), parsed.toJSON()); + }); + + it('SocketAddress.parse() returns a SocketAddress BlockList accepts', () => { + const list = new BlockList(); + list.addAddress(SocketAddress.parse('1.2.3.4:8080')); + + assert.ok(list.check('1.2.3.4')); + assert.ok(!list.check('1.2.3.5')); }); });