1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//! Brokered secrets: the policy's `secrets:` list (ADR-020, docs/POLICY.md ยง2.1).
//!
//! OpenMoat keeps the value; the agent holds a placeholder; `moat proxy` puts the
//! value into one header of requests to one host, and with `allow` rules only
//! of the requests they name (`route.rs`, ADR-043). This module is the schema,
//! its validation and the rules' matching: reading the source is the CLI's
//! job, using the value is the proxy's.
mod route;
use serde::{Deserialize, Serialize};
pub use self::route::{MAX_ROUTES, METHOD_OVERRIDE_HEADERS, PathRefusal, RequestPath, Route};
use crate::host::{MAX_DNS_LABEL_LEN, MAX_DNS_NAME_LEN};
use crate::policy::PolicyError;
/// What a brokered secret's id is prefixed with where it stands for a rule id
/// (`secrets.<id>`): lint warnings, policy errors and sandbox losses.
pub const SECRET_RULE_PREFIX: &str = "secrets.";
/// Headers a secret may not be put in: they frame the request or the
/// connection, so a secret there would change how the request is read.
const RESERVED_HEADERS: &[&str] = &[
"host",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"upgrade",
];
/// One brokered secret. Its `Debug` output names only the id: where the value
/// lives and where it goes say which secrets exist.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Secret {
/// Stable identifier: lowercase letters, digits and `-`. It names the
/// placeholder and appears in audit rows; the value never does.
pub id: String,
/// The one host (a lowercase DNS name or IPv4 literal, no pattern) whose
/// requests receive the secret.
pub host: String,
/// The request header the secret goes in (`Authorization`, `X-Api-Key`).
pub header: String,
/// Where OpenMoat reads the value, written `{ env: NAME }` and so on.
pub source: Source,
/// Also inject into plain-HTTP requests, which carry the value in clear
/// text. Off unless set: without it a plain-HTTP request keeps the
/// placeholder.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub plain_http: bool,
/// The port of the plain-HTTP requests that get the value, 1 to 65535;
/// 80 when unset. A request to another port of the host keeps the
/// placeholder. Only with `plain_http`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
/// `terminate`: `openmoat run`'s proxy decrypts HTTPS to `host` with its own
/// certificate authority and puts the value in there (ADR-043). Off unless
/// set: without it an HTTPS request keeps the placeholder.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<Tls>,
/// The only requests that may receive the value, `METHOD /path` each
/// ([`Route`]). Empty: every request to `host` does. With rules, any other
/// request to `host` that would get the value is refused before it is
/// added. Needs `tls: terminate` or `plain_http`, where requests can be read.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
}
/// How a brokered secret reaches HTTPS requests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tls {
/// The proxy ends the agent's TLS with a certificate it makes for the host,
/// reads each request, and opens its own TLS to the host.
Terminate,
}
/// What a secret's `allow` rules say about one request.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Permit {
/// The secret has no rules: every request to its host gets it.
Unrestricted,
/// The request matches this rule.
Rule(String),
/// The request matches no rule, or cannot be matched; why, without the path.
Refused(String),
}
/// Where OpenMoat reads a secret's value. The agent should not be able to read it
/// there; `moat policy lint` warns when no deny rule covers the source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum Source {
/// A file, absolute or under `~/`; its contents without the trailing newline.
File(String),
/// An environment variable of the `moat proxy` process.
Env(String),
/// An item in the operating system's keychain.
Keychain {
/// Service name of the item.
service: String,
/// Account name of the item.
account: String,
},
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("id", &self.id)
.finish_non_exhaustive()
}
}
impl Secret {
/// What the agent holds instead of the value: `moat-secret:<id>:placeholder`.
/// Ids cannot contain `:`, so no placeholder occurs inside another.
#[must_use]
pub fn placeholder(&self) -> String {
format!("moat-secret:{}:placeholder", self.id)
}
/// The port of the plain-HTTP requests the value goes into: `port`, or 80.
#[must_use]
pub fn plain_http_port(&self) -> u16 {
self.port.unwrap_or(80)
}
/// True when the host is this machine: `localhost`, a name under it, or
/// `127.0.0.0/8`. Hosts are validated lowercase DNS names or IPv4. The
/// address type comes from `core`, which parses and does no I/O.
#[must_use]
pub fn is_loopback(&self) -> bool {
let host = self.host.as_str();
host == "localhost"
|| host.ends_with(".localhost")
|| host
.parse::<core::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
/// Whether `openmoat run`'s proxy decrypts HTTPS to the host (`tls: terminate`).
#[must_use]
pub fn terminates(&self) -> bool {
self.tls == Some(Tls::Terminate)
}
/// What the `allow` rules say about a `method` request for `target` (the
/// request line's target) whose headers are named `header_names`. With
/// rules, a request is refused when its path could be read as another
/// ([`PathRefusal`]) or it asks for another method ([`METHOD_OVERRIDE_HEADERS`],
/// a `_method` query parameter). Reasons never quote the path: it may hold
/// what the request is about.
#[must_use]
pub fn permit(&self, method: &str, target: &str, header_names: &[&str]) -> Permit {
if self.allow.is_empty() {
return Permit::Unrestricted;
}
let refused = |why: String| {
Permit::Refused(format!(
"{method} request to {} is refused for secret `{}`: {why}",
self.host, self.id
))
};
let path = match RequestPath::parse(target) {
Ok(path) => path,
Err(why) => return refused(why.to_string()),
};
if let Some(name) = header_names.iter().find(|name| {
METHOD_OVERRIDE_HEADERS
.iter()
.any(|h| name.eq_ignore_ascii_case(h))
}) {
return refused(format!("its `{name}` header asks for another method"));
}
if path.overrides_method() {
return refused("its query names a `_method` parameter".to_owned());
}
// Validated when the policy loaded, so every rule parses.
self.allow
.iter()
.find(|rule| Route::parse(rule).is_ok_and(|r| r.matches(method, &path)))
.map_or_else(
|| refused("it matches none of the secret's `allow` rules".to_owned()),
|rule| Permit::Rule(rule.clone()),
)
}
}
/// Validate the `secrets:` list. Errors name the secret as `secrets.<id>`.
pub(crate) fn check(secrets: &[Secret]) -> Result<(), PolicyError> {
for (i, secret) in secrets.iter().enumerate() {
let fail = |problem: String| PolicyError::Rule {
rule: format!("{SECRET_RULE_PREFIX}{}", secret.id),
problem,
};
check_one(secret).map_err(fail)?;
if let Some(earlier) = secrets[..i].iter().find(|s| s.id == secret.id) {
return Err(PolicyError::DuplicateId(format!(
"{SECRET_RULE_PREFIX}{}",
earlier.id
)));
}
if let Some(earlier) = secrets[..i]
.iter()
.find(|s| s.host == secret.host && s.header.eq_ignore_ascii_case(&secret.header))
{
return Err(fail(format!(
"secret `{}` already sets {} for {}",
earlier.id, secret.header, secret.host
)));
}
}
Ok(())
}
fn check_one(secret: &Secret) -> Result<(), String> {
let id = &secret.id;
if id.is_empty()
|| id.len() > 64
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err("id must be 1 to 64 lowercase letters, digits or `-`".into());
}
if !is_host(&secret.host) {
return Err(format!(
"host `{}` must be one lowercase DNS name or IPv4 address, without port or pattern",
secret.host
));
}
let header = &secret.header;
if header.is_empty() || !header.bytes().all(is_token_byte) {
return Err(format!("header `{header}` is not an HTTP header name"));
}
if RESERVED_HEADERS.contains(&header.to_ascii_lowercase().as_str()) {
return Err(format!(
"header `{header}` frames the request or connection; it cannot carry a secret"
));
}
match secret.port {
Some(0) => return Err("port must be 1 to 65535".into()),
Some(port) if !secret.plain_http => {
return Err(format!(
"port {port} names where plain-HTTP requests get the value; it needs \
`plain_http: true`"
));
}
_ => {}
}
check_transport(secret)?;
match &secret.source {
Source::File(path) if !(crate::paths::is_absolute(path) || path.starts_with("~/")) => Err(
format!("source file `{path}` must be absolute or start with `~/`"),
),
Source::Env(name) if !is_env_name(name) => {
Err(format!("source env `{name}` is not a variable name"))
}
Source::Keychain { service, account }
if [service, account]
.iter()
.any(|s| s.is_empty() || s.chars().any(char::is_control)) =>
{
Err("source keychain needs a service and an account without control characters".into())
}
_ => Ok(()),
}
}
/// `tls` and `allow`: termination names a host a certificate can name, and
/// rules need requests the proxy can read.
fn check_transport(secret: &Secret) -> Result<(), String> {
if secret.terminates() && secret.host.parse::<core::net::Ipv4Addr>().is_ok() {
return Err(format!(
"`tls: terminate` needs a DNS name; {} is an address, which the proxy's \
certificate authority may not sign for",
secret.host
));
}
if secret.allow.is_empty() {
return Ok(());
}
if !secret.terminates() && !secret.plain_http {
return Err(
"`allow` rules need `tls: terminate` or `plain_http: true`: elsewhere the proxy \
cannot read the request"
.into(),
);
}
if secret.allow.len() > MAX_ROUTES {
return Err(format!("`allow` lists more than {MAX_ROUTES} rules"));
}
secret
.allow
.iter()
.try_for_each(|rule| Route::parse(rule).map(drop))
}
/// The form `moat proxy` reports hosts in, so the comparison is exact.
fn is_host(host: &str) -> bool {
host.len() <= MAX_DNS_NAME_LEN
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= MAX_DNS_LABEL_LEN
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
})
}
/// RFC 9110 `tchar`.
fn is_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
}
fn is_env_name(name: &str) -> bool {
let mut bytes = name.bytes();
bytes
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
#[cfg(test)]
mod tests;