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