From 5def4d46ca7ec68535e4f939fad1791c255eed79 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:22:21 +0200 Subject: [PATCH] [socket] Add an IPv4 allow list (#20025) Co-authored-by: J. Nick Koston --- esphome/components/socket/__init__.py | 37 ++++++++- esphome/components/socket/ipv4_allow.h | 56 ++++++++++++++ esphome/components/socket/socket.cpp | 44 +++++------ esphome/components/socket/socket.h | 4 + .../socket/test_ipv4_allow_host.cpp | 75 +++++++++++++++++++ .../socket/test_socket_ipv4_allow.py | 46 ++++++++++++ 6 files changed, 239 insertions(+), 23 deletions(-) create mode 100644 esphome/components/socket/ipv4_allow.h create mode 100644 tests/components/socket/test_ipv4_allow_host.cpp create mode 100644 tests/unit_tests/components/socket/test_socket_ipv4_allow.py diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index a814f46e3b..850f7ebcbc 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -1,18 +1,22 @@ from collections.abc import Callable, MutableMapping from dataclasses import dataclass from enum import StrEnum +from ipaddress import IPv4Address, IPv4Network import logging import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] +socket_ns = cg.esphome_ns.namespace("socket") +Ipv4AllowEntry = socket_ns.struct("Ipv4AllowEntry") + CONF_IMPLEMENTATION = "implementation" IMPLEMENTATION_LWIP_TCP = "lwip_tcp" IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets" @@ -140,6 +144,37 @@ def require_wake_loop_threadsafe() -> None: cg.add_define("USE_SOCKET_SELECT_SUPPORT") +# For an Ipv4Allow config option; a sanity cap on the list length. +IPV4_ALLOW_SCHEMA = cv.All(cv.ensure_list(cv.ipv4network), cv.Length(max=255)) + + +def _network_order(addr: IPv4Address) -> int: + """The s_addr value for addr on the little endian targets.""" + return int.from_bytes(addr.packed, "little") + + +def add_ipv4_allow( + setter: cg.MockObj, networks: list[IPv4Network], owner_id: ID | str +) -> None: + """Emit a flash array for validated IPV4_ALLOW_SCHEMA entries and wire it to setter. + + PROGMEM on esp8266. Emits nothing for an empty list. + """ + if not networks: + return + entries = [ + cg.StructInitializer( + Ipv4AllowEntry, + ("addr", _network_order(net.network_address)), + ("mask", _network_order(net.netmask)), + ) + for net in networks + ] + arr_id = ID(f"{owner_id}_ipv4_allow", is_declaration=True, type=Ipv4AllowEntry) + arr = cg.progmem_array(arr_id, cg.ArrayInitializer(*entries)) + cg.add(setter(arr, len(entries))) + + def require_ipv4_resolve() -> None: """Compile the shared IPv4 lookup; call from a consumer's to_code.""" cg.add_define("USE_SOCKET_IPV4_RESOLVE") diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h new file mode 100644 index 0000000000..adb00b5449 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.h @@ -0,0 +1,56 @@ +#pragma once + +#include "headers.h" +#include "socket.h" +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome::socket { + +/// One allowed IPv4 network, network byte order, host bits cleared. +/// Lives in flash; read via progmem_memcpy. +struct Ipv4AllowEntry { + uint32_t addr; + uint32_t mask; +}; + +/// IPv4 peers that may connect. An empty list allows every peer. +class Ipv4Allow { + public: + void set(const Ipv4AllowEntry *entries, size_t count) { + this->entries_ = entries; + this->count_ = count; + } + + /// A v4 mapped IPv6 peer is unwrapped; any other family fails a non empty list. + bool allows(const struct sockaddr *peer) const { + if (this->count_ == 0) { + return true; + } + uint32_t addr; + return sockaddr_to_ipv4(peer, &addr) && this->allows(addr); + } + + /// addr is network byte order, as it sits in a sockaddr_in. + bool allows(uint32_t addr) const { + if (this->count_ == 0) { + return true; + } + for (size_t i = 0; i != this->count_; i++) { + Ipv4AllowEntry entry; + progmem_memcpy(&entry, &this->entries_[i], sizeof(entry)); + if ((addr & entry.mask) == entry.addr) { + return true; + } + } + return false; + } + + private: + const Ipv4AllowEntry *entries_{nullptr}; + size_t count_{0}; +}; + +} // namespace esphome::socket diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f2d428af77..f6d1daa124 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -78,6 +78,25 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t #endif #endif +bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out) { + if (addr->sa_family == AF_INET) { + *out = reinterpret_cast(addr)->sin_addr.s_addr; + return true; + } +#if USE_NETWORK_IPV6 + if (addr->sa_family == AF_INET6) { + // ::ffff:a.b.c.d; s6_addr is the portable byte view on every stack. + static constexpr uint8_t V4_MAPPED_PREFIX[12] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF}; + const uint8_t *bytes = reinterpret_cast(addr)->sin6_addr.s6_addr; + if (memcmp(bytes, V4_MAPPED_PREFIX, sizeof(V4_MAPPED_PREFIX)) == 0) { + memcpy(out, bytes + sizeof(V4_MAPPED_PREFIX), sizeof(*out)); + return true; + } + } +#endif + return false; +} + // Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf) { if (addr_ptr->sa_family == AF_INET && len >= sizeof(const struct sockaddr_in)) { @@ -88,29 +107,10 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s #if USE_NETWORK_IPV6 else if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) { const auto *addr = reinterpret_cast(addr_ptr); -#ifdef USE_HOST - // Format IPv4-mapped IPv6 addresses as regular IPv4 (POSIX layout, no LWIP union) - if (IN6_IS_ADDR_V4MAPPED(&addr->sin6_addr) && - esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { + uint32_t v4; + // Format a v4 mapped peer as plain IPv4. + if (sockaddr_to_ipv4(addr_ptr, &v4) && esphome_inet_ntop4(&v4, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); - } -#elif defined(USE_ZEPHYR) - // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX - // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so - // detect the ::ffff:0:0/96 prefix directly on the address words. - if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && - addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && - esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { - return strlen(buf.data()); - } -#elif !defined(USE_SOCKET_IMPL_LWIP_TCP) - // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) - if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && - addr->sin6_addr.un.u32_addr[2] == htonl(0xFFFF) && - esphome_inet_ntop4(&addr->sin6_addr.un.u32_addr[3], buf.data(), buf.size()) != nullptr) { - return strlen(buf.data()); - } -#endif if (esphome_inet_ntop6(&addr->sin6_addr, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8ce392ff1d..9d7dedbbbf 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -137,6 +137,10 @@ inline std::unique_ptr socket_ip_loop_monitored(int type, int prot /// @return Size of the sockaddr structure used, or 0 on error socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port); +/// Network order IPv4 of an AF_INET or v4 mapped AF_INET6 peer; false for anything else. +/// addr must hold the family's full struct. +bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out); + /// Convenience overload for std::string (backward compatible). inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port) { return set_sockaddr(addr, addrlen, ip_address.c_str(), port); diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp new file mode 100644 index 0000000000..e4257b41c4 --- /dev/null +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -0,0 +1,75 @@ +#include + +#include + +#include "esphome/components/socket/ipv4_allow.h" +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// The size_t count packs into the pointer's padding; no RAM over a uint8_t. +static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow"); + +// 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared, +// mirroring what add_ipv4_allow emits. +static const Ipv4AllowEntry ENTRIES[] = { + {htonl(0xC0A8AF14), htonl(0xFFFFFFFF)}, + {htonl(0xC0A8AF00), htonl(0xFFFFFF00)}, +}; + +// Runs the peer through the same parser production addresses go through. +static bool allows_peer(const Ipv4Allow &list, const char *ip) { + struct sockaddr_storage peer {}; + EXPECT_NE(set_sockaddr(reinterpret_cast(&peer), sizeof(peer), ip, 0), 0); + return list.allows(reinterpret_cast(&peer)); +} + +TEST(Ipv4Allow, EmptyAllowsEveryPeer) { + Ipv4Allow list; + EXPECT_TRUE(list.allows(htonl(0xC0A8AF01))); + EXPECT_TRUE(allows_peer(list, "10.0.0.1")); + EXPECT_TRUE(allows_peer(list, "fe80::1")); +} + +TEST(Ipv4Allow, MatchesHostAndNetworkEntries) { + Ipv4Allow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(list.allows(htonl(0xC0A8AF14))); + EXPECT_TRUE(list.allows(htonl(0xC0A8AF01))); + EXPECT_TRUE(list.allows(htonl(0xC0A8AFFF))); + EXPECT_FALSE(list.allows(htonl(0xC0A8B001))); +} + +TEST(Ipv4Allow, ChecksTheV4PeerInsideASockaddr) { + Ipv4Allow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(allows_peer(list, "192.168.175.66")); + EXPECT_FALSE(allows_peer(list, "10.0.0.1")); +} + +TEST(Ipv4Allow, UnwrapsAV4MappedIpv6Peer) { + Ipv4Allow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(allows_peer(list, "::ffff:192.168.175.66")); + // A native IPv6 peer cannot match an IPv4 list. + EXPECT_FALSE(allows_peer(list, "fe80::1")); +} + +TEST(Ipv4Allow, InstancesKeepIndependentLists) { + // One bridge per allow list; each instance points at its own entries. + static const Ipv4AllowEntry OTHER[] = {{htonl(0x0A000000), htonl(0xFF000000)}}; + Ipv4Allow first; + Ipv4Allow second; + first.set(ENTRIES, std::size(ENTRIES)); + second.set(OTHER, std::size(OTHER)); + EXPECT_TRUE(first.allows(htonl(0xC0A8AF14))); + EXPECT_FALSE(second.allows(htonl(0xC0A8AF14))); + EXPECT_TRUE(second.allows(htonl(0x0A00002A))); + EXPECT_FALSE(first.allows(htonl(0x0A00002A))); +} + +} // namespace esphome::socket::testing + +#endif diff --git a/tests/unit_tests/components/socket/test_socket_ipv4_allow.py b/tests/unit_tests/components/socket/test_socket_ipv4_allow.py new file mode 100644 index 0000000000..bc1185c26f --- /dev/null +++ b/tests/unit_tests/components/socket/test_socket_ipv4_allow.py @@ -0,0 +1,46 @@ +"""Tests for the socket component's IPv4 allow list codegen helper.""" + +from ipaddress import IPv4Address, IPv4Network +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components import socket +import esphome.config_validation as cv + + +def test_network_order_swaps_to_sockaddr_value() -> None: + """The emitted uint32 must equal s_addr on the little endian targets.""" + assert socket._network_order(IPv4Address("192.168.175.20")) == 0x14AFA8C0 + assert socket._network_order(IPv4Address("255.255.255.0")) == 0x00FFFFFF + assert socket._network_order(IPv4Address("0.0.0.0")) == 0 + + +def test_add_ipv4_allow_emits_nothing_for_an_empty_list() -> None: + setter = MagicMock() + with patch.object(socket.cg, "add") as add: + socket.add_ipv4_allow(setter, [], "bridge") + add.assert_not_called() + setter.assert_not_called() + + +def test_add_ipv4_allow_wires_the_setter_with_cleared_host_bits() -> None: + setter = MagicMock() + networks = [IPv4Network("192.168.175.33/24", strict=False)] + with ( + patch.object(socket.cg, "add") as add, + patch.object(socket.cg, "progmem_array") as array, + ): + socket.add_ipv4_allow(setter, networks, "bridge") + rendered = str(array.call_args.args[1]) + assert str(socket._network_order(IPv4Address("192.168.175.0"))) in rendered + assert str(socket._network_order(IPv4Address("255.255.255.0"))) in rendered + setter.assert_called_once_with(array.return_value, 1) + add.assert_called_once() + + +def test_schema_caps_the_list_length() -> None: + """The sanity cap rejects a list past 255 entries.""" + assert len(socket.IPV4_ALLOW_SCHEMA([f"10.0.{i}.0/24" for i in range(255)])) == 255 + with pytest.raises(cv.Invalid): + socket.IPV4_ALLOW_SCHEMA([f"10.0.{i}.0/24" for i in range(256)])