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