defmodule BreakGlass.CIDR do @moduledoc false # Private internal module — not part of the public API. # # Implements CIDR-based IP address matching for the break-glass IP whitelist. # Supports both IPv4 (dotted-decimal) and IPv6 addresses and CIDR ranges. # # Cross-family comparisons (IPv4 address vs IPv6 CIDR or vice-versa) always # return false. # # Requirements: 3.1, 3.3, 3.4, 3.5, 16.3 import Bitwise @doc """ Returns `true` if `ip` matches any entry in `allowed_ips`. Each entry in `allowed_ips` may be an exact IP address string or a CIDR notation string (e.g. `"10.0.0.0/8"` or `"fd00::/8"`). Exact IP addresses without a `/` prefix are treated as `/32` (IPv4) or `/128` (IPv6). """ @spec check(ip :: String.t(), allowed_ips :: [String.t()]) :: boolean() def check(ip, allowed_ips) when is_binary(ip) and is_list(allowed_ips) do Enum.any?(allowed_ips, &cidr_match?(ip, &1)) end def check(_ip, _allowed_ips), do: false # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- @spec cidr_match?(ip_string :: String.t(), cidr_or_ip_string :: String.t()) :: boolean() defp cidr_match?(ip_string, cidr_or_ip_string) do {addr_string, prefix} = parse_cidr_notation(cidr_or_ip_string) with {:ok, ip_tuple} <- parse_ip(ip_string), {:ok, net_tuple} <- parse_ip(addr_string), :same_family <- check_family(ip_tuple, net_tuple), {:ok, effective_prefix} <- effective_prefix(prefix, ip_bit_size(ip_tuple)) do match_prefix?(ip_tuple, net_tuple, effective_prefix) else _ -> false end end defp parse_cidr_notation(cidr_or_ip_string) do case String.split(cidr_or_ip_string, "/", parts: 2) do [addr, prefix_str] -> case Integer.parse(prefix_str) do {p, ""} -> {addr, p} _ -> {cidr_or_ip_string, nil} end [addr] -> {addr, nil} end end defp check_family(ip_tuple, net_tuple) do if ip_bit_size(ip_tuple) == ip_bit_size(net_tuple), do: :same_family, else: :different_family end defp effective_prefix(prefix, bit_size) do case prefix do nil -> {:ok, bit_size} p when p >= 0 and p <= bit_size -> {:ok, p} _ -> :error end end defp match_prefix?(_ip_tuple, _net_tuple, 0), do: true defp match_prefix?(ip_tuple, net_tuple, p) do bits = ip_bit_size(ip_tuple) mask = compute_mask(bits, p) (ip_to_int(ip_tuple) &&& mask) == (ip_to_int(net_tuple) &&& mask) end defp parse_ip(ip_string) when is_binary(ip_string) do charlist = String.to_charlist(ip_string) case :inet.parse_address(charlist) do {:ok, tuple} -> {:ok, tuple} {:error, _} -> :error end end # Returns the bit size of an IP address based on tuple arity # 4-tuple → 32-bit (IPv4); 8-tuple → 128-bit (IPv6) defp ip_bit_size(tuple) when tuple_size(tuple) == 4, do: 32 defp ip_bit_size(tuple) when tuple_size(tuple) == 8, do: 128 # Compute a bitmask of `bits` total bits with `prefix` leading 1s defp compute_mask(bits, prefix) do max_val = (1 <<< bits) - 1 -1 <<< (bits - prefix) &&& max_val end # Convert an IPv4 4-tuple to a 32-bit integer defp ip_to_int({a, b, c, d}) do a * 16_777_216 + b * 65_536 + c * 256 + d end # Convert an IPv6 8-tuple to a 128-bit integer using binary pattern matching defp ip_to_int({a, b, c, d, e, f, g, h}) do <> = <> n end end