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