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