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