1use serde::{Deserialize, Serialize};
10
11use crate::host::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
12use crate::policy::PolicyError;
13pub use crate::route::{MAX_ROUTES, METHOD_OVERRIDE_HEADERS, PathRefusal, RequestPath, Route};
14
15pub const SECRET_RULE_PREFIX: &str = "secrets.";
18
19const RESERVED_HEADERS: &[&str] = &[
22 "host",
23 "content-length",
24 "transfer-encoding",
25 "connection",
26 "keep-alive",
27 "proxy-connection",
28 "proxy-authenticate",
29 "proxy-authorization",
30 "te",
31 "trailer",
32 "upgrade",
33];
34
35#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Secret {
40 pub id: String,
43 pub host: String,
46 pub header: String,
48 pub source: Source,
50 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
54 pub plain_http: bool,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub port: Option<u16>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub tls: Option<Tls>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub allow: Vec<String>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "lowercase")]
76pub enum Tls {
77 Terminate,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84#[non_exhaustive]
85pub enum Permit {
86 Unrestricted,
88 Rule(String),
90 Refused(String),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "lowercase", deny_unknown_fields)]
98pub enum Source {
99 File(String),
101 Env(String),
103 Keychain {
105 service: String,
107 account: String,
109 },
110}
111
112impl std::fmt::Debug for Secret {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("Secret")
115 .field("id", &self.id)
116 .finish_non_exhaustive()
117 }
118}
119
120impl Secret {
121 #[must_use]
124 pub fn placeholder(&self) -> String {
125 format!("moat-secret:{}:placeholder", self.id)
126 }
127
128 #[must_use]
130 pub fn plain_http_port(&self) -> u16 {
131 self.port.unwrap_or(80)
132 }
133
134 #[must_use]
138 pub fn is_loopback(&self) -> bool {
139 let host = self.host.as_str();
140 host == "localhost"
141 || host.ends_with(".localhost")
142 || host
143 .parse::<core::net::Ipv4Addr>()
144 .is_ok_and(|ip| ip.is_loopback())
145 }
146
147 #[must_use]
149 pub fn terminates(&self) -> bool {
150 self.tls == Some(Tls::Terminate)
151 }
152
153 #[must_use]
160 pub fn permit(&self, method: &str, target: &str, header_names: &[&str]) -> Permit {
161 if self.allow.is_empty() {
162 return Permit::Unrestricted;
163 }
164 let refused = |why: String| {
165 Permit::Refused(format!(
166 "{method} request to {} is refused for secret `{}`: {why}",
167 self.host, self.id
168 ))
169 };
170 let path = match RequestPath::parse(target) {
171 Ok(path) => path,
172 Err(why) => return refused(why.to_string()),
173 };
174 if let Some(name) = header_names.iter().find(|name| {
175 METHOD_OVERRIDE_HEADERS
176 .iter()
177 .any(|h| name.eq_ignore_ascii_case(h))
178 }) {
179 return refused(format!("its `{name}` header asks for another method"));
180 }
181 if path.overrides_method() {
182 return refused("its query names a `_method` parameter".to_owned());
183 }
184 self.allow
186 .iter()
187 .find(|rule| Route::parse(rule).is_ok_and(|r| r.matches(method, &path)))
188 .map_or_else(
189 || refused("it matches none of the secret's `allow` rules".to_owned()),
190 |rule| Permit::Rule(rule.clone()),
191 )
192 }
193}
194
195pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
197 for (i, secret) in secrets.iter().enumerate() {
198 let fail = |problem: String| PolicyError::Rule {
199 rule: format!("{SECRET_RULE_PREFIX}{}", secret.id),
200 problem,
201 };
202 check_one(secret).map_err(fail)?;
203 if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
204 return Err(PolicyError::DuplicateId(format!(
205 "{SECRET_RULE_PREFIX}{}",
206 earlier.id
207 )));
208 }
209 if let Some(earlier) = secrets[..i]
210 .iter()
211 .find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
212 {
213 return Err(fail(format!(
214 "secret `{}` already sets {} for {}",
215 earlier.id, secret.header, secret.host
216 )));
217 }
218 }
219 Ok(())
220}
221
222fn check_one(secret: &Secret) -> Result<(), String> {
223 let id = &secret.id;
224 if id.is_empty()
225 || id.len() > 64
226 || !id
227 .bytes()
228 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
229 {
230 return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
231 }
232 if !is_host(&secret.host) {
233 return Err(format!(
234 "host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
235 secret.host
236 ));
237 }
238 let header = &secret.header;
239 if header.is_empty() || !header.bytes().all(is_token_byte) {
240 return Err(format!("header `{header}` is not an HTTP header name"));
241 }
242 if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
243 return Err(format!(
244 "header `{header}` frames the request or connection; it cannot carry a secret"
245 ));
246 }
247 match secret.port {
248 Some(0) => return Err("port must be 1 to 65535".into()),
249 Some(port) if !secret.plain_http => {
250 return Err(format!(
251 "port {port} names where plain-HTTP requests get the value; it needs \
252 `plain_http: true`"
253 ));
254 }
255 _ => {}
256 }
257 check_transport(secret)?;
258 match &secret.source {
259 Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
260 format!("source file `{path}` must be absolute or start with `~/`"),
261 ),
262 Source::Env(name) if !is_env_name(name) => {
263 Err(format!("source env `{name}` is not a variable name"))
264 }
265 Source::Keychain { service, account }
266 if [service, account]
267 .iter()
268 .any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
269 {
270 Err("source keychain needs a service and an account without control characters".into())
271 }
272 _ => Ok(()),
273 }
274}
275
276fn check_transport(secret: &Secret) -> Result<(), String> {
279 if secret.terminates() && secret.host.parse::<core::net::Ipv4Addr>().is_ok() {
280 return Err(format!(
281 "`tls: terminate` needs a DNS name; {} is an address, which the proxy's \
282 certificate authority may not sign for",
283 secret.host
284 ));
285 }
286 if secret.allow.is_empty() {
287 return Ok(());
288 }
289 if !secret.terminates() && !secret.plain_http {
290 return Err(
291 "`allow` rules need `tls: terminate` or `plain_http: true`: elsewhere the proxy \
292 cannot read the request"
293 .into(),
294 );
295 }
296 if secret.allow.len() > MAX_ROUTES {
297 return Err(format!("`allow` lists more than {MAX_ROUTES} rules"));
298 }
299 secret
300 .allow
301 .iter()
302 .try_for_each(|rule| Route::parse(rule).map(drop))
303}
304
305fn is_host(host: &str) -> bool {
307 host.len() <= MAX_DNS_NAME_LEN
308 && host.split('.').all(|label| {
309 !label.is_empty()
310 && label.len() <= MAX_DNS_LABEL_LEN
311 && label
312 .bytes()
313 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
314 })
315}
316
317fn is_token_byte(b: u8) -> bool {
319 b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
320}
321
322fn is_env_name(name: &str) -> bool {
323 let mut bytes = name.bytes();
324 bytes
325 .next()
326 .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
327 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
328}
329
330#[cfg(test)]
331mod tests;