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