bindy/
bind9_acl.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Strict validator for BIND9 `address_match_list` entries used in
5//! `allow-query`, `allow-transfer`, and related ACL directives.
6//!
7//! CRD-supplied values flow directly into `named.conf`. Without validation a
8//! malicious or compromised CRD author could close the enclosing `{ … }` block
9//! and append arbitrary BIND9 directives. This module implements a strict
10//! whitelist of the address-match-list forms bindy supports, rejecting
11//! anything else with a structured error that reconcilers propagate to the
12//! resource status.
13//!
14//! Accepted forms (optionally prefixed with `!` for negation):
15//! - keywords: `any`, `none`, `localhost`, `localnets`
16//! - IPv4 address with optional `/prefix` (0..=32)
17//! - IPv6 address with optional `/prefix` (0..=128)
18//! - `key <name>` where `<name>` matches `[A-Za-z0-9._-]{1,253}`
19
20use std::net::IpAddr;
21use thiserror::Error;
22
23/// Maximum accepted length of a single ACL entry, in bytes. Any reasonable
24/// address-match token is well under this; the cap is defensive against
25/// pathologically large CRD inputs.
26pub const MAX_ACL_ENTRY_LEN: usize = 256;
27
28/// Maximum accepted length of a TSIG/RNDC key name in an `key <name>` entry.
29/// Matches the DNS name length limit from RFC 1035.
30const MAX_KEY_NAME_LEN: usize = 253;
31
32const ACL_KEYWORDS: [&str; 4] = ["any", "none", "localhost", "localnets"];
33
34const IPV4_MAX_PREFIX: u8 = 32;
35const IPV6_MAX_PREFIX: u8 = 128;
36
37#[derive(Debug, Error, PartialEq, Eq)]
38pub enum AclError {
39    #[error("ACL entry must not be empty")]
40    Empty,
41
42    #[error("ACL entry exceeds {MAX_ACL_ENTRY_LEN}-byte limit: {0:?}")]
43    TooLong(String),
44
45    #[error(
46        "ACL entry {0:?} is not a recognized address-match token. \
47         Accepted: any, none, localhost, localnets, an IPv4 or IPv6 address \
48         (optionally with /prefix), or \"key <name>\" — each optionally \
49         prefixed with '!'"
50    )]
51    InvalidToken(String),
52}
53
54/// Validate a single `address_match_list` entry.
55///
56/// # Errors
57/// Returns [`AclError`] if the entry is empty, exceeds
58/// [`MAX_ACL_ENTRY_LEN`], or does not match one of the accepted forms.
59pub fn validate_acl_entry(entry: &str) -> Result<(), AclError> {
60    let trimmed = entry.trim();
61    if trimmed.is_empty() {
62        return Err(AclError::Empty);
63    }
64    if trimmed.len() > MAX_ACL_ENTRY_LEN {
65        return Err(AclError::TooLong(trimmed.to_string()));
66    }
67
68    let core = trimmed.strip_prefix('!').unwrap_or(trimmed).trim_start();
69    if core.is_empty() {
70        return Err(AclError::InvalidToken(trimmed.to_string()));
71    }
72
73    if ACL_KEYWORDS.contains(&core) {
74        return Ok(());
75    }
76
77    if let Some(key_name) = core.strip_prefix("key ") {
78        if is_valid_key_name(key_name.trim()) {
79            return Ok(());
80        }
81        return Err(AclError::InvalidToken(trimmed.to_string()));
82    }
83
84    if is_valid_ip_or_cidr(core) {
85        return Ok(());
86    }
87
88    Err(AclError::InvalidToken(trimmed.to_string()))
89}
90
91/// Validate each entry in `entries` and return the `; `-joined payload that
92/// goes between the `{ }` of an `allow-query` / `allow-transfer` block.
93///
94/// # Errors
95/// Returns [`AclError`] on the first invalid entry; the index is encoded in
96/// the message via the entry itself so operators can fix the offending CRD.
97pub fn build_acl_list(entries: &[String]) -> Result<String, AclError> {
98    let mut pieces = Vec::with_capacity(entries.len());
99    for entry in entries {
100        validate_acl_entry(entry)?;
101        pieces.push(entry.trim().to_string());
102    }
103    Ok(pieces.join("; "))
104}
105
106fn is_valid_key_name(name: &str) -> bool {
107    let unquoted = name
108        .strip_prefix('"')
109        .and_then(|n| n.strip_suffix('"'))
110        .unwrap_or(name);
111    if unquoted.is_empty() || unquoted.len() > MAX_KEY_NAME_LEN {
112        return false;
113    }
114    unquoted
115        .chars()
116        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
117}
118
119fn is_valid_ip_or_cidr(s: &str) -> bool {
120    let Some((addr_str, prefix_str)) = s.split_once('/') else {
121        return s.parse::<IpAddr>().is_ok();
122    };
123    let Ok(addr) = addr_str.parse::<IpAddr>() else {
124        return false;
125    };
126    let Ok(prefix) = prefix_str.parse::<u8>() else {
127        return false;
128    };
129    let max = if addr.is_ipv4() {
130        IPV4_MAX_PREFIX
131    } else {
132        IPV6_MAX_PREFIX
133    };
134    prefix <= max
135}