[socket] Add an IPv4 allow list (#20025)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Bascht74
2026-10-02 11:22:21 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent a531ea07a2
commit 5def4d46ca
6 changed files with 239 additions and 23 deletions
+36 -1
View File
@@ -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")
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include "headers.h"
#include "socket.h"
#include "esphome/core/hal.h"
#include <cstddef>
#include <cstdint>
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
+22 -22
View File
@@ -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<const struct sockaddr_in *>(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<const struct sockaddr_in6 *>(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<char, SOCKADDR_STR_LEN> 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<const struct sockaddr_in6 *>(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());
}
+4
View File
@@ -137,6 +137,10 @@ inline std::unique_ptr<ListenSocket> 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);
@@ -0,0 +1,75 @@
#include <gtest/gtest.h>
#include <iterator>
#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<struct sockaddr *>(&peer), sizeof(peer), ip, 0), 0);
return list.allows(reinterpret_cast<const struct sockaddr *>(&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
@@ -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)])