Skip to main content

openmoat_proxy/
broker.rs

1//! The secrets broker (ADR-020): values the proxy holds for the policy's
2//! `secrets:` list, and where they may go.
3//!
4//! A brokered secret's placeholder and value may be sent to its own host only.
5//! A plain-HTTP request to that host (`plain_http`), or one read inside a
6//! terminated tunnel (`tls: terminate`, ADR-043), gets the value in the
7//! secret's header ([`Broker::inject`]); the agent never needs it. A secret
8//! with `allow` rules goes only into the requests they name; any other request
9//! that would get it is refused before anything is sent (`route.rs`). Any
10//! other request that carries the placeholder or the value, in its head or a
11//! body the proxy reads, is refused and recorded. The check matches exact bytes: it catches a token
12//! sent by mistake or by a naive script, not one that was encoded or split
13//! across requests (`docs/THREAT_MODEL.md`). What keeps a secret safe is that
14//! the agent never holds the value.
15//!
16//! Values are zeroed when dropped and never formatted: [`Debug`] and every
17//! reason this module writes name the secret's id and host only.
18
19use openmoat_core::{Decision, Secret, Verdict};
20use zeroize::Zeroizing;
21
22/// Rule id for a request refused because it carries a brokered secret to a
23/// host that is not the secret's own.
24pub const RULE_SECRET: &str = "proxy-secret";
25
26/// Where a request to a secret's host travels, which decides whether the
27/// secret goes in.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum Channel {
30    /// Plain HTTP to this port: secrets with `plain_http` on it.
31    Plain(u16),
32    /// Read inside a tunnel the proxy terminated: secrets with `tls: terminate`.
33    Terminated,
34}
35
36/// One secret and its value.
37struct Held {
38    secret: Secret,
39    placeholder: String,
40    value: Zeroizing<String>,
41}
42
43impl Held {
44    /// Whether a request to `host` over `channel` gets this secret.
45    fn injects(&self, host: &str, channel: Channel) -> bool {
46        self.secret.host == host
47            && match channel {
48                Channel::Plain(port) => {
49                    self.secret.plain_http && self.secret.plain_http_port() == port
50                }
51                Channel::Terminated => self.secret.terminates(),
52            }
53    }
54}
55
56/// The brokered secrets of one proxy run. [`Broker::default`] holds none.
57#[derive(Default)]
58pub struct Broker {
59    held: Vec<Held>,
60}
61
62impl std::fmt::Debug for Broker {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_list()
65            .entries(self.held.iter().map(|h| (&h.secret.id, &h.secret.host)))
66            .finish()
67    }
68}
69
70/// A secret value the broker cannot use.
71#[derive(Debug, thiserror::Error)]
72#[non_exhaustive]
73pub enum BrokerError {
74    /// The value is empty.
75    #[error("secret `{0}` is empty")]
76    Empty(String),
77    /// The value holds a control character, which could end the header it is
78    /// put in and start another.
79    #[error("secret `{0}` contains a control character")]
80    Control(String),
81}
82
83/// Which form of a secret a request carried.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Carried {
86    /// The placeholder the agent holds.
87    Placeholder,
88    /// The value itself.
89    Value,
90}
91
92/// A brokered secret found in a request to another host.
93#[derive(Debug, Clone, Copy)]
94pub struct Leak<'a> {
95    /// The secret's id.
96    pub id: &'a str,
97    /// The only host that may receive it.
98    pub owner: &'a str,
99    /// Placeholder or value.
100    pub carried: Carried,
101}
102
103impl Leak<'_> {
104    /// The deny recorded for a request to `host` that carried this secret.
105    #[must_use]
106    pub fn decision(&self, host: &str) -> Decision {
107        let what = match self.carried {
108            Carried::Placeholder => "placeholder",
109            Carried::Value => "value",
110        };
111        Decision::single(
112            Verdict::Deny,
113            RULE_SECRET,
114            format!(
115                "request to {host} carries the {what} of secret `{}`, which only {} may receive",
116                self.id, self.owner
117            ),
118        )
119    }
120}
121
122impl Broker {
123    /// A broker for `secrets`, each with its value. The policy has already
124    /// validated the entries; this checks the values.
125    pub fn new(secrets: Vec<(Secret, Zeroizing<String>)>) -> Result<Self, BrokerError> {
126        let mut held = Vec::with_capacity(secrets.len());
127        for (secret, value) in secrets {
128            if value.is_empty() {
129                return Err(BrokerError::Empty(secret.id));
130            }
131            if value.chars().any(char::is_control) {
132                return Err(BrokerError::Control(secret.id));
133            }
134            held.push(Held {
135                placeholder: secret.placeholder(),
136                secret,
137                value,
138            });
139        }
140        Ok(Self { held })
141    }
142
143    /// True when no secret is brokered.
144    #[must_use]
145    pub fn is_empty(&self) -> bool {
146        self.held.is_empty()
147    }
148
149    /// Whether a secret of `host` says `tls: terminate`.
150    pub(crate) fn terminates(&self, host: &str) -> bool {
151        self.held
152            .iter()
153            .any(|h| h.secret.host == host && h.secret.terminates())
154    }
155
156    /// The first secret whose placeholder or value occurs in `bytes`, sent
157    /// towards `host`, that `host` may not receive.
158    #[must_use]
159    pub fn leak(&self, host: &str, bytes: &[u8]) -> Option<Leak<'_>> {
160        self.held
161            .iter()
162            .filter(|h| h.secret.host != host)
163            .find_map(|h| {
164                let carried = if contains(bytes, h.placeholder.as_bytes()) {
165                    Carried::Placeholder
166                } else if contains(bytes, h.value.as_bytes()) {
167                    Carried::Value
168                } else {
169                    return None;
170                };
171                Some(Leak {
172                    id: &h.secret.id,
173                    owner: &h.secret.host,
174                    carried,
175                })
176            })
177    }
178
179    /// Why each secret `host` owns is not put into a plain-HTTP request to
180    /// `port`: its policy entry does not set `plain_http`, or names another
181    /// port (80 when it names none).
182    pub(crate) fn withheld(&self, host: &str, port: u16) -> Vec<String> {
183        self.held
184            .iter()
185            .filter(|h| h.secret.host == host)
186            .filter_map(|h| {
187                let id = &h.secret.id;
188                if !h.secret.plain_http {
189                    Some(format!(
190                        "secret `{id}` not injected: plain HTTP needs `plain_http: true`"
191                    ))
192                } else if h.secret.plain_http_port() != port {
193                    Some(format!(
194                        "secret `{id}` not injected: it goes to port {} only, not {port}",
195                        h.secret.plain_http_port()
196                    ))
197                } else {
198                    None
199                }
200            })
201            .collect()
202    }
203
204    /// `head` (a request head the proxy wrote, lines ending in CRLF) to
205    /// `host` over `channel`, with the secrets of `host` that channel carries
206    /// ([`Channel`]) put in: in each header a secret names, the placeholder
207    /// becomes the value; a secret whose header is absent, or holds no
208    /// placeholder, gets its own header line. `None` when there is no such
209    /// secret. The caller has checked the request against their `allow` rules
210    /// ([`Broker::admit`]).
211    pub(crate) fn inject(
212        &self,
213        host: &str,
214        channel: Channel,
215        head: &[u8],
216    ) -> Option<Zeroizing<Vec<u8>>> {
217        let owned: Vec<&Held> = self
218            .held
219            .iter()
220            .filter(|h| h.injects(host, channel))
221            .collect();
222        // Each line keeps its `\r`; only the `\n`s are split on and re-added.
223        let lines = head.strip_suffix(b"\n\r\n")?;
224        if owned.is_empty() || echoed(head) {
225            return None;
226        }
227        let mut out = Zeroizing::new(Vec::with_capacity(head.len() + 256));
228        let mut placed = vec![false; owned.len()];
229        // The first line is the request line; the rest are `Name: value`.
230        for (n, line) in lines.split(|b| *b == b'\n').enumerate() {
231            let named = header_name(line).filter(|_| n > 0).and_then(|name| {
232                owned
233                    .iter()
234                    .position(|h| name.eq_ignore_ascii_case(h.secret.header.as_bytes()))
235            });
236            match named {
237                None => out.extend_from_slice(line),
238                Some(i) if contains(line, owned[i].placeholder.as_bytes()) => {
239                    placed[i] = true;
240                    let h = owned[i];
241                    replace(line, h.placeholder.as_bytes(), h.value.as_bytes(), &mut out);
242                }
243                // A value the client chose would reach the owner in place of
244                // the secret: it is dropped, and the secret's line added below.
245                Some(_) => continue,
246            }
247            out.extend_from_slice(b"\n");
248        }
249        for (h, _) in owned.iter().zip(&placed).filter(|(_, p)| !**p) {
250            out.extend_from_slice(format!("{}: ", h.secret.header).as_bytes());
251            out.extend_from_slice(h.value.as_bytes());
252            out.extend_from_slice(b"\r\n");
253        }
254        out.extend_from_slice(b"\r\n");
255        Some(out)
256    }
257
258    /// Length of the longest placeholder or value: a stream watched in chunks
259    /// keeps one byte less than this from the previous chunk, so a secret
260    /// split across two reads is still found.
261    fn longest(&self) -> usize {
262        self.held
263            .iter()
264            .map(|h| h.placeholder.len().max(h.value.len()))
265            .max()
266            .unwrap_or(0)
267    }
268}
269
270/// Watches one client stream towards `host`, chunk by chunk.
271pub(crate) struct Watch<'a> {
272    broker: &'a Broker,
273    host: &'a str,
274    /// The end of the bytes seen so far, kept to find a secret split across
275    /// chunks. It may hold part of a value the client sent, so it is zeroed.
276    tail: Zeroizing<Vec<u8>>,
277}
278
279impl<'a> Watch<'a> {
280    pub(crate) fn new(broker: &'a Broker, host: &'a str) -> Self {
281        Self {
282            broker,
283            host,
284            tail: Zeroizing::new(Vec::new()),
285        }
286    }
287
288    /// Look at the next `chunk` of the stream, with the end of the previous one.
289    pub(crate) fn next(&mut self, chunk: &[u8]) -> Option<Leak<'a>> {
290        if self.broker.is_empty() {
291            return None;
292        }
293        let mut window = Zeroizing::new(Vec::with_capacity(self.tail.len() + chunk.len()));
294        window.extend_from_slice(&self.tail);
295        window.extend_from_slice(chunk);
296        let keep = self.broker.longest().saturating_sub(1).min(window.len());
297        self.tail.clear();
298        self.tail.extend_from_slice(&window[window.len() - keep..]);
299        self.broker.leak(self.host, &window)
300    }
301}
302
303/// Append `line` to `out` with every `from` replaced by `to`.
304fn replace(mut line: &[u8], from: &[u8], to: &[u8], out: &mut Vec<u8>) {
305    while let Some(at) = line.windows(from.len()).position(|w| w == from) {
306        out.extend_from_slice(&line[..at]);
307        out.extend_from_slice(to);
308        line = &line[at + from.len()..];
309    }
310    out.extend_from_slice(line);
311}
312
313/// Whether `head` is a `TRACE` (or IIS's `TRACK`): the answer is the request as
314/// the server got it, so a value put in would come back to the agent.
315/// Compared without regard to case, as some servers read methods.
316fn echoed(head: &[u8]) -> bool {
317    head.get(..6)
318        .is_some_and(|m| m.eq_ignore_ascii_case(b"TRACE ") || m.eq_ignore_ascii_case(b"TRACK "))
319}
320
321/// The name of a `Name: value` header line.
322fn header_name(line: &[u8]) -> Option<&[u8]> {
323    line.iter().position(|b| *b == b':').map(|at| &line[..at])
324}
325
326fn contains(haystack: &[u8], needle: &[u8]) -> bool {
327    !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
328}
329
330mod echo;
331mod route;
332
333pub(crate) use self::echo::Holdback;
334pub use self::route::RULE_SECRET_ROUTE;
335
336#[cfg(test)]
337mod tests;