Skip to main content

lib/break_glass/cidr.ex

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::128>> = <<a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>>
    n
  end
end