1use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet};
29use std::fmt;
30
31pub const PLACEHOLDER_PREFIX: &str = "SMOL_PLACEHOLDER_";
36
37pub const GUEST_CA_DIR: &str = "/run/smol/credentials";
39pub const GUEST_CA_FILE: &str = "ca.pem";
41pub const GUEST_CA_BUNDLE: &str = "/run/smolvm/ca-bundle.pem";
45
46pub const GUEST_TRUST_ENV: &[(&str, &str)] = &[
48 ("SSL_CERT_FILE", GUEST_CA_BUNDLE),
49 ("CURL_CA_BUNDLE", GUEST_CA_BUNDLE),
50 ("REQUESTS_CA_BUNDLE", GUEST_CA_BUNDLE),
51 ("GIT_SSL_CAINFO", GUEST_CA_BUNDLE),
52 ("NODE_EXTRA_CA_CERTS", "/run/smol/credentials/ca.pem"),
53 ("DENO_CERT", "/run/smol/credentials/ca.pem"),
54];
55
56pub const DEFAULT_METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
58
59const SUPPORTED_METHODS: &[&str] = DEFAULT_METHODS;
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct InjectionLocation {
65 #[serde(default = "yes")]
68 pub header: bool,
69}
70
71fn yes() -> bool {
72 true
73}
74
75impl Default for InjectionLocation {
76 fn default() -> Self {
77 Self { header: true }
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct CredentialBinding {
85 pub name: String,
88 pub environment_variable: String,
90 pub allowed_hosts: Vec<String>,
93 #[serde(default)]
95 pub injection_location: InjectionLocation,
96 #[serde(default = "default_methods")]
99 pub methods: Vec<String>,
100}
101
102fn default_methods() -> Vec<String> {
103 DEFAULT_METHODS.iter().map(|m| m.to_string()).collect()
104}
105
106impl CredentialBinding {
107 pub fn allows_host(&self, host: &str) -> bool {
109 let host = host.trim_end_matches('.');
110 self.allowed_hosts
111 .iter()
112 .any(|allowed| allowed.eq_ignore_ascii_case(host))
113 }
114
115 pub fn allows_method(&self, method: &str) -> bool {
117 self.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
118 }
119}
120
121#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields, default)]
124pub struct CredentialPolicy {
125 pub credentials: Vec<CredentialBinding>,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct PolicyError(pub String);
132
133impl fmt::Display for PolicyError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 f.write_str(&self.0)
136 }
137}
138
139impl std::error::Error for PolicyError {}
140
141fn refuse(message: String) -> Result<(), PolicyError> {
142 Err(PolicyError(message))
143}
144
145impl CredentialPolicy {
146 pub fn is_empty(&self) -> bool {
148 self.credentials.is_empty()
149 }
150
151 pub fn validate(&self, network_allowed_hosts: Option<&[String]>) -> Result<(), PolicyError> {
156 let mut names = BTreeSet::new();
157 let mut env_vars = BTreeSet::new();
158 for binding in &self.credentials {
159 let name = &binding.name;
160 let env_var = &binding.environment_variable;
161 if !valid_binding_name(name) {
162 return refuse(format!(
163 "credential name {name:?} is not a valid binding name"
164 ));
165 }
166 if !valid_env_name(env_var) {
167 return refuse(format!(
168 "credential environment variable {env_var:?} is not a valid name"
169 ));
170 }
171 if !names.insert(name.as_str()) {
172 return refuse(format!("credential {name:?} is declared twice"));
173 }
174 if !env_vars.insert(env_var.as_str()) {
175 return refuse(format!(
176 "environment variable {env_var:?} is bound to more than one credential"
177 ));
178 }
179 if binding.allowed_hosts.is_empty() {
180 return refuse(format!(
181 "credential {name:?} lists no allowed_hosts; a credential is never sent anywhere by default"
182 ));
183 }
184 for host in &binding.allowed_hosts {
185 if !valid_exact_host(host) {
186 return refuse(format!(
187 "credential {name:?} host {host:?} must be an exact lowercase DNS name (no wildcard, scheme, port or IP)"
188 ));
189 }
190 if network_allowed_hosts
191 .is_some_and(|allowed| !covered_by_allow_list(host, allowed))
192 {
193 return refuse(format!(
194 "credential {name:?} host {host:?} is not reachable under the machine's network allow_hosts"
195 ));
196 }
197 }
198 if binding.methods.is_empty() {
199 return refuse(format!("credential {name:?} allows no HTTP methods"));
200 }
201 if let Some(method) = binding.methods.iter().find(|method| {
202 !SUPPORTED_METHODS
203 .iter()
204 .any(|m| m.eq_ignore_ascii_case(method))
205 }) {
206 return refuse(format!(
207 "credential {name:?} method {method:?} is not supported"
208 ));
209 }
210 if !binding.injection_location.header {
211 return refuse(format!("credential {name:?} enables no injection location"));
212 }
213 }
214 Ok(())
215 }
216
217 pub fn bindings_for_host<'a>(
219 &'a self,
220 host: &'a str,
221 ) -> impl Iterator<Item = &'a CredentialBinding> + 'a {
222 self.credentials.iter().filter(move |b| b.allows_host(host))
223 }
224
225 pub fn binding(&self, name: &str) -> Option<&CredentialBinding> {
227 self.credentials.iter().find(|b| b.name == name)
228 }
229
230 pub fn guest_env<'a>(
234 &'a self,
235 placeholders: &'a BTreeMap<String, String>,
236 ) -> impl Iterator<Item = (String, String)> + 'a {
237 self.credentials.iter().filter_map(move |b| {
238 placeholders
239 .get(&b.name)
240 .map(|p| (b.environment_variable.clone(), p.clone()))
241 })
242 }
243}
244
245pub fn covered_by_allow_list(host: &str, allowed: &[String]) -> bool {
247 allowed
248 .iter()
249 .any(|pattern| crate::host_pattern::matches(host, pattern))
250}
251
252fn valid_binding_name(name: &str) -> bool {
253 !name.is_empty()
254 && name.len() <= 64
255 && name
256 .bytes()
257 .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
258}
259
260fn valid_env_name(name: &str) -> bool {
261 let mut bytes = name.bytes();
262 bytes
263 .next()
264 .is_some_and(|c| c.is_ascii_alphabetic() || c == b'_')
265 && bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_')
266}
267
268fn valid_exact_host(host: &str) -> bool {
269 !host.is_empty()
270 && host.len() <= 253
271 && host.parse::<std::net::IpAddr>().is_err()
272 && !host.contains(['/', ':', '*', '@', '?', '#'])
273 && host.split('.').all(|label| {
274 !label.is_empty()
275 && label.len() <= 63
276 && !label.starts_with('-')
277 && !label.ends_with('-')
278 && label
279 .bytes()
280 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
281 })
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn notion() -> CredentialBinding {
289 CredentialBinding {
290 name: "notion".into(),
291 environment_variable: "NOTION_API_KEY".into(),
292 allowed_hosts: vec!["api.notion.com".into()],
293 injection_location: InjectionLocation::default(),
294 methods: default_methods(),
295 }
296 }
297
298 #[test]
299 fn parses_the_harness_shape() {
300 let policy: CredentialPolicy = serde_json::from_str(
301 r#"{"credentials":[{"name":"notion","environment_variable":"NOTION_API_KEY",
302 "allowed_hosts":["api.notion.com"],"injection_location":{"header":true}}]}"#,
303 )
304 .unwrap();
305 assert_eq!(policy.credentials, vec![notion()]);
306 policy.validate(None).unwrap();
307 }
308
309 #[test]
310 fn credential_hosts_must_sit_inside_the_network_allow_list() {
311 let policy = CredentialPolicy {
312 credentials: vec![notion()],
313 };
314 policy.validate(Some(&["notion.com".to_string()])).unwrap();
315 policy
316 .validate(Some(&["*.notion.com".to_string()]))
317 .unwrap();
318 policy
319 .validate(Some(&["api.notion.com".to_string()]))
320 .unwrap();
321 assert!(policy.validate(Some(&["=notion.com".to_string()])).is_err());
322 let err = policy
323 .validate(Some(&["api.github.com".to_string()]))
324 .unwrap_err();
325 assert!(err.0.contains("network allow_hosts"), "{err}");
326 }
327
328 #[test]
329 fn rejects_empty_wildcard_and_ip_hosts() {
330 for host in [
331 "",
332 "*.notion.com",
333 "10.0.0.1",
334 "API.notion.com",
335 "notion.com:443",
336 ] {
337 let mut b = notion();
338 b.allowed_hosts = vec![host.to_string()];
339 let policy = CredentialPolicy {
340 credentials: vec![b],
341 };
342 assert!(policy.validate(None).is_err(), "{host:?} accepted");
343 }
344 let mut b = notion();
345 b.allowed_hosts.clear();
346 let policy = CredentialPolicy {
347 credentials: vec![b],
348 };
349 assert!(policy
350 .validate(None)
351 .unwrap_err()
352 .0
353 .contains("no allowed_hosts"));
354 }
355
356 #[test]
357 fn rejects_duplicates_and_bad_names() {
358 let policy = CredentialPolicy {
359 credentials: vec![notion(), notion()],
360 };
361 assert!(policy
362 .validate(None)
363 .unwrap_err()
364 .0
365 .contains("declared twice"));
366 let mut other = notion();
367 other.name = "notion-2".into();
368 let policy = CredentialPolicy {
369 credentials: vec![notion(), other],
370 };
371 assert!(policy
372 .validate(None)
373 .unwrap_err()
374 .0
375 .contains("more than one credential"));
376 let mut bad = notion();
377 bad.environment_variable = "1BAD".into();
378 let policy = CredentialPolicy {
379 credentials: vec![bad],
380 };
381 assert!(policy
382 .validate(None)
383 .unwrap_err()
384 .0
385 .contains("not a valid name"));
386 }
387
388 #[test]
389 fn guest_env_maps_placeholders_onto_bound_variables() {
390 let mut github = notion();
391 github.name = "github".into();
392 github.environment_variable = "GITHUB_TOKEN".into();
393 github.allowed_hosts = vec!["api.github.com".into()];
394 let policy = CredentialPolicy {
395 credentials: vec![notion(), github],
396 };
397 let placeholders: BTreeMap<String, String> = [
398 (
399 "notion".to_string(),
400 "SMOL_PLACEHOLDER_NOTION_1".to_string(),
401 ),
402 (
403 "github".to_string(),
404 "SMOL_PLACEHOLDER_GITHUB_2".to_string(),
405 ),
406 ]
407 .into_iter()
408 .collect();
409 let env: Vec<_> = policy.guest_env(&placeholders).collect();
410 assert_eq!(
411 env,
412 vec![
413 (
414 "NOTION_API_KEY".to_string(),
415 "SMOL_PLACEHOLDER_NOTION_1".to_string()
416 ),
417 (
418 "GITHUB_TOKEN".to_string(),
419 "SMOL_PLACEHOLDER_GITHUB_2".to_string()
420 ),
421 ]
422 );
423 assert!(policy
424 .bindings_for_host("api.github.com")
425 .all(|b| b.name == "github"));
426 }
427}