Skip to main content

camel_function/
config.rs

1#[derive(Debug, Clone)]
2pub struct FunctionConfig {
3    pub default_timeout_ms: u64,
4    pub health_interval: std::time::Duration,
5    pub boot_timeout: std::time::Duration,
6    /// Outbound network (egress) allowlist for the Deno runner, as
7    /// `host[:port]` entries. Empty (the default) denies all outbound
8    /// connections: the runner may only bind its HTTP server, which is the
9    /// pre-allowlist behavior.
10    ///
11    /// Semantics follow Deno `--allow-net` exact-host matching:
12    /// - `host` allows any port on that host
13    /// - `host:port` allows only that host and port
14    /// - IPv6 literals must be bracketed: `[::1]` or `[::1]:443`
15    pub egress_allowlist: Vec<String>,
16}
17
18impl Default for FunctionConfig {
19    fn default() -> Self {
20        Self {
21            default_timeout_ms: 5000,
22            health_interval: std::time::Duration::from_secs(5),
23            boot_timeout: std::time::Duration::from_secs(10),
24            egress_allowlist: Vec::new(),
25        }
26    }
27}
28
29impl FunctionConfig {
30    pub fn validate(&self) -> Result<(), camel_api::CamelError> {
31        if self.default_timeout_ms == 0 {
32            return Err(camel_api::CamelError::Config(
33                "default_timeout_ms must be > 0".to_string(),
34            ));
35        }
36        if self.health_interval == std::time::Duration::ZERO {
37            return Err(camel_api::CamelError::Config(
38                "health_interval must be > 0".to_string(),
39            ));
40        }
41        if self.boot_timeout == std::time::Duration::ZERO {
42            return Err(camel_api::CamelError::Config(
43                "boot_timeout must be > 0".to_string(),
44            ));
45        }
46        for entry in &self.egress_allowlist {
47            validate_egress_allowlist_entry(entry)?;
48        }
49        Ok(())
50    }
51}
52
53/// Validate one `egress_allowlist` entry (`host[:port]`).
54///
55/// Fail-closed: any malformed entry is rejected, so a bad config can never
56/// widen the runner's network permissions. The charset whitelist (no
57/// commas, whitespace, schemes, wildcards, or paths) also prevents
58/// injection into the comma-joined Deno `--allow-net` value.
59pub fn validate_egress_allowlist_entry(entry: &str) -> Result<(), camel_api::CamelError> {
60    let err = |reason: &str| {
61        camel_api::CamelError::Config(format!("egress_allowlist entry '{entry}': {reason}"))
62    };
63    if entry.is_empty() {
64        return Err(err("must not be empty"));
65    }
66    if entry.chars().any(char::is_whitespace) {
67        return Err(err("must not contain whitespace"));
68    }
69
70    let (host, port) = if let Some(rest) = entry.strip_prefix('[') {
71        // Bracketed IPv6 literal: `[addr]` or `[addr]:port`.
72        let Some(close) = rest.find(']') else {
73            return Err(err("unterminated IPv6 bracket"));
74        };
75        let inner = &rest[..close];
76        let tail = &rest[close + 1..];
77        if inner.is_empty() {
78            return Err(err("empty IPv6 address"));
79        }
80        if !inner.chars().all(|c| c.is_ascii_hexdigit() || c == ':') {
81            return Err(err("IPv6 literal may contain only hex digits and colons"));
82        }
83        let port = match tail {
84            "" => None,
85            t => {
86                let Some(p) = t.strip_prefix(':') else {
87                    return Err(err("unexpected characters after IPv6 bracket"));
88                };
89                Some(p)
90            }
91        };
92        (None, port)
93    } else {
94        match entry.rsplit_once(':') {
95            Some((h, p)) => {
96                if h.is_empty() {
97                    return Err(err("empty host"));
98                }
99                if p.is_empty() {
100                    return Err(err("empty port"));
101                }
102                (Some(h), Some(p))
103            }
104            None => (Some(entry), None),
105        }
106    };
107
108    if let Some(h) = host {
109        if h.is_empty() {
110            return Err(err("empty host"));
111        }
112        if !h
113            .chars()
114            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
115        {
116            return Err(err(
117                "host may contain only letters, digits, dots, and hyphens \
118                 (bracket IPv6 literals for IPv6 addresses)",
119            ));
120        }
121        if h.starts_with('.') || h.ends_with('.') || h.contains("..") {
122            return Err(err("malformed host"));
123        }
124    }
125    if let Some(p) = port {
126        // Canonical digits only: `+443` parses as u16 but is not a form any
127        // resolver grants — reject so the validator charset stays the exact
128        // grant set.
129        if !p.chars().all(|c| c.is_ascii_digit()) {
130            return Err(err("port must be an integer in the range 1..=65535"));
131        }
132        match p.parse::<u16>() {
133            Ok(n) if n != 0 => {}
134            _ => return Err(err("port must be an integer in the range 1..=65535")),
135        }
136    }
137    Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_config_valid_default() {
146        let config = FunctionConfig::default();
147        assert!(config.validate().is_ok());
148    }
149
150    #[test]
151    fn test_config_zero_timeout_rejected() {
152        let config = FunctionConfig {
153            default_timeout_ms: 0,
154            ..Default::default()
155        };
156        assert!(config.validate().is_err());
157    }
158
159    #[test]
160    fn test_config_zero_health_interval_rejected() {
161        let config = FunctionConfig {
162            health_interval: std::time::Duration::ZERO,
163            ..Default::default()
164        };
165        assert!(config.validate().is_err());
166    }
167
168    #[test]
169    fn test_config_zero_boot_timeout_rejected() {
170        let config = FunctionConfig {
171            boot_timeout: std::time::Duration::ZERO,
172            ..Default::default()
173        };
174        assert!(config.validate().is_err());
175    }
176
177    #[test]
178    fn test_config_empty_allowlist_is_default_deny() {
179        // Default (absent) allowlist must stay valid: deny-all egress is
180        // the pinned pre-allowlist behavior.
181        let config = FunctionConfig::default();
182        assert!(config.egress_allowlist.is_empty());
183        assert!(config.validate().is_ok());
184    }
185
186    #[test]
187    fn test_egress_entry_valid_forms() {
188        for entry in [
189            "api.example.com",
190            "api.example.com:443",
191            "internal",
192            "sub.domain-2.io:8080",
193            "10.0.0.5",
194            "10.0.0.5:5432",
195            "[::1]",
196            "[::1]:443",
197            "[2001:db8::1]:8443",
198        ] {
199            validate_egress_allowlist_entry(entry)
200                .unwrap_or_else(|e| panic!("'{entry}' should be valid: {e}"));
201        }
202    }
203
204    #[test]
205    fn test_egress_entry_malformed_rejected() {
206        for entry in [
207            "",
208            "   ",
209            "host:",
210            ":443",
211            "bad host",
212            "http://api.example.com",
213            "api.example.com/path",
214            "user@api.example.com",
215            "api.example.com,evil.com",
216            "*",
217            "*.example.com",
218            "host:0",
219            "host:65536",
220            "host:abc",
221            "host:+443",
222            "[::1",
223            "[zz::1]",
224            "[]",
225            "[::1]junk",
226            "..malformed..host",
227        ] {
228            let err = validate_egress_allowlist_entry(entry)
229                .err()
230                .unwrap_or_else(|| panic!("'{entry}' should be rejected"));
231            assert!(
232                err.to_string().contains("egress_allowlist"),
233                "error for '{entry}' must name egress_allowlist, got: {err}"
234            );
235        }
236    }
237
238    #[test]
239    fn test_config_validate_rejects_malformed_allowlist_entry() {
240        let config = FunctionConfig {
241            egress_allowlist: vec!["api.example.com:443".to_string(), "bad host".to_string()],
242            ..Default::default()
243        };
244        // Pre-existing 1.98 clippy `err_expect` hit (test-only,
245        // behavior-identical) — fixed in passing to keep the rc-3j4mq
246        // verify gate green.
247        let err = config.validate().expect_err("must be rejected"); // allow-unwrap(test)
248        assert!(err.to_string().contains("egress_allowlist"));
249    }
250}