1use serde::{Deserialize, Serialize};
9
10use crate::host::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
11use crate::policy::PolicyError;
12
13pub const SECRET_RULE_PREFIX: &str = "secrets.";
16
17const RESERVED_HEADERS: &[&str] = &[
20 "host",
21 "content-length",
22 "transfer-encoding",
23 "connection",
24 "keep-alive",
25 "proxy-connection",
26 "proxy-authenticate",
27 "proxy-authorization",
28 "te",
29 "trailer",
30 "upgrade",
31];
32
33#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Secret {
38 pub id: String,
41 pub host: String,
44 pub header: String,
46 pub source: Source,
48 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
52 pub plain_http: bool,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub port: Option<u16>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase", deny_unknown_fields)]
64pub enum Source {
65 File(String),
67 Env(String),
69 Keychain {
71 service: String,
73 account: String,
75 },
76}
77
78impl std::fmt::Debug for Secret {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("Secret")
81 .field("id", &self.id)
82 .finish_non_exhaustive()
83 }
84}
85
86impl Secret {
87 #[must_use]
90 pub fn placeholder(&self) -> String {
91 format!("moat-secret:{}:placeholder", self.id)
92 }
93
94 #[must_use]
96 pub fn plain_http_port(&self) -> u16 {
97 self.port.unwrap_or(80)
98 }
99
100 #[must_use]
104 pub fn is_loopback(&self) -> bool {
105 let host = self.host.as_str();
106 host == "localhost"
107 || host.ends_with(".localhost")
108 || host
109 .parse::<core::net::Ipv4Addr>()
110 .is_ok_and(|ip| ip.is_loopback())
111 }
112}
113
114pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
116 for (i, secret) in secrets.iter().enumerate() {
117 let fail = |problem: String| PolicyError::Rule {
118 rule: format!("{SECRET_RULE_PREFIX}{}", secret.id),
119 problem,
120 };
121 check_one(secret).map_err(fail)?;
122 if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
123 return Err(PolicyError::DuplicateId(format!(
124 "{SECRET_RULE_PREFIX}{}",
125 earlier.id
126 )));
127 }
128 if let Some(earlier) = secrets[..i]
129 .iter()
130 .find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
131 {
132 return Err(fail(format!(
133 "secret `{}` already sets {} for {}",
134 earlier.id, secret.header, secret.host
135 )));
136 }
137 }
138 Ok(())
139}
140
141fn check_one(secret: &Secret) -> Result<(), String> {
142 let id = &secret.id;
143 if id.is_empty()
144 || id.len() > 64
145 || !id
146 .bytes()
147 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
148 {
149 return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
150 }
151 if !is_host(&secret.host) {
152 return Err(format!(
153 "host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
154 secret.host
155 ));
156 }
157 let header = &secret.header;
158 if header.is_empty() || !header.bytes().all(is_token_byte) {
159 return Err(format!("header `{header}` is not an HTTP header name"));
160 }
161 if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
162 return Err(format!(
163 "header `{header}` frames the request or connection; it cannot carry a secret"
164 ));
165 }
166 match secret.port {
167 Some(0) => return Err("port must be 1 to 65535".into()),
168 Some(port) if !secret.plain_http => {
169 return Err(format!(
170 "port {port} names where plain-HTTP requests get the value; it needs \
171 `plain_http: true`"
172 ));
173 }
174 _ => {}
175 }
176 match &secret.source {
177 Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
178 format!("source file `{path}` must be absolute or start with `~/`"),
179 ),
180 Source::Env(name) if !is_env_name(name) => {
181 Err(format!("source env `{name}` is not a variable name"))
182 }
183 Source::Keychain { service, account }
184 if [service, account]
185 .iter()
186 .any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
187 {
188 Err("source keychain needs a service and an account without control characters".into())
189 }
190 _ => Ok(()),
191 }
192}
193
194fn is_host(host: &str) -> bool {
196 host.len() <= MAX_DNS_NAME_LEN
197 && host.split('.').all(|label| {
198 !label.is_empty()
199 && label.len() <= MAX_DNS_LABEL_LEN
200 && label
201 .bytes()
202 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
203 })
204}
205
206fn is_token_byte(b: u8) -> bool {
208 b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
209}
210
211fn is_env_name(name: &str) -> bool {
212 let mut bytes = name.bytes();
213 bytes
214 .next()
215 .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
216 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
217}
218
219#[cfg(test)]
220mod tests {
221 use crate::policy::{Policy, PolicyError};
222
223 use super::Source;
224
225 fn parse(secrets: &str) -> Result<Policy, PolicyError> {
226 Policy::parse(&format!("version: 1\nsecrets:\n{secrets}"))
227 }
228
229 fn problem(secrets: &str) -> String {
230 parse(secrets).unwrap_err().to_string()
231 }
232
233 const GH: &str = " - id: gh\n host: api.github.com\n header: Authorization\n";
234
235 #[test]
236 fn parses_every_source() {
237 let p = parse(&format!(
238 "{GH} source: {{ env: GITHUB_TOKEN }}\n \
239 - id: npm\n host: registry.npmjs.org\n header: Authorization\n \
240 source: {{ file: ~/.config/moat/npm }}\n \
241 - id: k\n host: example.com\n header: X-Api-Key\n \
242 source: {{ keychain: {{ service: moat, account: k }} }}\n"
243 ))
244 .unwrap();
245 assert_eq!(p.secrets.len(), 3);
246 assert_eq!(p.secrets[0].source, Source::Env("GITHUB_TOKEN".into()));
247 assert_eq!(
248 p.secrets[1].source,
249 Source::File("~/.config/moat/npm".into())
250 );
251 assert!(
252 matches!(&p.secrets[2].source, Source::Keychain { service, .. } if service == "moat")
253 );
254 assert_eq!(p.secrets[0].placeholder(), "moat-secret:gh:placeholder");
255 assert!(Policy::parse("version: 1\n").unwrap().secrets.is_empty());
256 }
257
258 #[test]
259 fn debug_output_names_only_the_id() {
260 let policy = parse(&format!("{GH} source: {{ file: ~/.config/moat/gh }}\n")).unwrap();
261 let secret = &policy.secrets[0];
262 let brokered = crate::ir::BrokeredSecret {
263 id: secret.id.clone(),
264 host: secret.host.clone(),
265 header: secret.header.clone(),
266 source: secret.source.clone(),
267 plain_http: false,
268 port: None,
269 };
270 for shown in [format!("{policy:?}"), format!("{brokered:?}")] {
271 assert!(shown.contains("\"gh\""), "{shown}");
272 for hidden in ["~/.config/moat/gh", "api.github.com", "Authorization"] {
273 assert!(!shown.contains(hidden), "{hidden} in {shown}");
274 }
275 }
276 }
277
278 #[test]
279 fn plain_http_is_off_unless_set() {
280 let off = parse(&format!("{GH} source: {{ env: T }}\n")).unwrap();
281 assert!(!off.secrets[0].plain_http);
282 let on = parse(&format!(
283 "{GH} source: {{ env: T }}\n plain_http: true\n"
284 ))
285 .unwrap();
286 assert!(on.secrets[0].plain_http);
287 assert!(!on.secrets[0].is_loopback());
288 for (host, loopback) in [
289 ("localhost", true),
290 ("api.localhost", true),
291 ("127.0.0.2", true),
292 ("10.0.0.1", false),
293 ("localhost.example", false),
294 ] {
295 let mut s = on.secrets[0].clone();
296 s.host = host.into();
297 assert_eq!(s.is_loopback(), loopback, "{host}");
298 }
299 }
300
301 #[test]
302 fn a_plain_http_secret_goes_to_port_80_unless_it_names_one() {
303 let plain = format!("{GH} source: {{ env: T }}\n plain_http: true\n");
304 assert_eq!(parse(&plain).unwrap().secrets[0].plain_http_port(), 80);
305 let on = parse(&format!("{plain} port: 8080\n")).unwrap();
306 assert_eq!(on.secrets[0].port, Some(8080));
307 assert_eq!(on.secrets[0].plain_http_port(), 8080);
308 assert!(problem(&format!("{plain} port: 0\n")).contains("1 to 65535"));
309 for bad in ["65536", "-1", "80.5", "http", "[80, 81]"] {
310 assert!(
311 parse(&format!("{plain} port: {bad}\n")).is_err(),
312 "{bad}"
313 );
314 }
315 let without = format!("{GH} source: {{ env: T }}\n port: 8080\n");
316 assert!(problem(&without).contains("needs `plain_http: true`"));
317 }
318
319 #[test]
320 fn rejects_bad_fields() {
321 let src = " source: { env: T }\n";
322 for (yaml, want) in [
323 (format!(" - id: Gh\n host: a.com\n header: A\n{src}"), "id must be"),
324 (format!(" - id: g:h\n host: a.com\n header: A\n{src}"), "id must be"),
325 (format!(" - id: g\n host: '*.a.com'\n header: A\n{src}"), "host `*.a.com`"),
326 (format!(" - id: g\n host: A.com\n header: A\n{src}"), "host `A.com`"),
327 (format!(" - id: g\n host: a.com:443\n header: A\n{src}"), "host `a.com:443`"),
328 (format!(" - id: g\n host: a.com\n header: 'A B'\n{src}"), "not an HTTP header"),
329 (format!(" - id: g\n host: a.com\n header: Host\n{src}"), "frames the request"),
330 (format!(" - id: g\n host: a.com\n header: content-length\n{src}"), "frames"),
331 (" - id: g\n host: a.com\n header: A\n source: { file: rel/t }\n".into(), "absolute"),
332 (" - id: g\n host: a.com\n header: A\n source: { env: 'A-B' }\n".into(), "variable name"),
333 (
334 " - id: g\n host: a.com\n header: A\n source: { keychain: { service: '', account: a } }\n".into(),
335 "keychain",
336 ),
337 ] {
338 let got = problem(&yaml);
339 assert!(got.contains(want), "{yaml}: {got}");
340 }
341 assert!(parse(&format!("{GH} source: {{ vault: x }}\n")).is_err());
342 assert!(parse(&format!("{GH} source: {{ env: T }}\n extra: 1\n")).is_err());
343 }
344
345 #[test]
346 fn rejects_duplicates() {
347 let two = format!("{GH} source: {{ env: A }}\n{GH} source: {{ env: B }}\n");
348 assert!(matches!(parse(&two), Err(PolicyError::DuplicateId(id)) if id == "secrets.gh"));
349 let same_header = format!(
350 "{GH} source: {{ env: A }}\n - id: gh2\n host: api.github.com\n header: authorization\n source: {{ env: B }}\n"
351 );
352 assert!(problem(&same_header).contains("already sets"));
353 }
354}