dwara_core/dataplane/
proxy_proto.rs

1//! PROXY protocol acceptance, v1 and v2 (DW-030, feature analysis 4.20).
2//!
3//! A listener configured with `proxy_protocol: true` expects a PROXY
4//! protocol header (HAProxy specification; v1 text or v2 binary) as the
5//! FIRST bytes of every connection — BEFORE the TLS handshake when the
6//! listener terminates TLS, because the L4 load balancer in front wraps
7//! the whole stream. The header's source address then REPLACES the
8//! accepted socket peer for everything downstream that consumes the
9//! peer address: the authz IP ACL's effective-client-IP base,
10//! rate-limit keying, and the `X-Forwarded-For` / `X-Real-IP` values
11//! stamped on the forwarded request (the one `peer` argument of
12//! [`crate::dataplane::proxy::handle`]).
13//!
14//! Security posture (frozen):
15//!
16//! - **Opt-in per listener.** A listener without the flag never
17//!   interprets first bytes as a PROXY line; there is no sniffing. The
18//!   spoofing boundary is exactly the config: an address claim is
19//!   honored only on listeners explicitly declared to sit behind a
20//!   PROXY-protocol-speaking LB (the same trust model as
21//!   `gateway.trusted_proxies` for XFF).
22//! - **Fail closed.** A malformed header (bad signature, bad lengths,
23//!   Unix address family, datagram protocol on this stream listener,
24//!   trailing garbage) is answered with a `400` JSON error envelope and
25//!   the connection is closed — the bytes are NEVER handed to HTTP
26//!   parsing (a desynced half-header must not become half a request
27//!   line). A connection that stalls mid-header or drops is closed
28//!   without a response (nothing parseable to answer, same as a TLS
29//!   handshake timeout).
30//! - **Bounded.** The whole header must arrive within the caller's
31//!   deadline (the DW-023 slowloris header timeout — the same attack,
32//!   one layer earlier) and within the protocol's own size ceiling
33//!   (107 bytes for v1, 16 + 65535 for v2). A trickle that never
34//!   completes is cut by the deadline, not buffered forever.
35//! - **Spec fallbacks honored.** A v2 `LOCAL` command (the LB's own
36//!   health check, not a proxied client) and a v1 `UNKNOWN` line keep
37//!   the REAL peer address — the specification's fallback for
38//!   connections whose origin the LB declines to assert.
39//!
40//! Parsing is delegated to the `ppp` crate (v2 TLV/bounds safety);
41//! this module owns the async framing read, the deadline, the caps,
42//! and the fail-closed policy. Passthrough listeners cannot combine
43//! with PROXY acceptance (validation rejects it): a passthrough
44//! listener splices raw bytes and never runs the HTTP pipeline that
45//! would consume the address.
46
47use std::net::SocketAddr;
48use std::time::Duration;
49
50use ppp::{HeaderResult, PartialResult};
51use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
52
53/// Hard ceiling on a v1 header line (the specification's 107-byte worst
54/// case: long IPv6 literals at both ends).
55const V1_MAX: usize = 107;
56/// Hard ceiling on a v2 header: 16 fixed bytes + the u16 length field's
57/// payload maximum (TLVs included). A length field claiming more than
58/// this is malformed on its face (it cannot exist in a u16).
59const V2_MAX: usize = 16 + u16::MAX as usize;
60
61/// Why a connection was not admitted with a PROXY-protocol-derived
62/// address. `Malformed` is answerable (the gateway was given bytes that
63/// claimed to be a header and were not); the IO variants are silent
64/// closes.
65#[derive(Debug)]
66pub enum ProxyProtoError {
67    /// The header bytes were parseable as NEITHER a complete v1 nor v2
68    /// header (bad signature/lengths/families), carried a Unix address
69    /// family or a datagram protocol (this is a TCP stream listener), or
70    /// exceeded the protocol's size ceiling. Fail closed with 400.
71    Malformed(&'static str),
72    /// The whole header did not arrive within the deadline (a
73    /// slowloris-style trickle against the PROXY line). Close silently.
74    Incomplete,
75    /// The peer closed the stream or the read errored mid-header.
76    /// Close silently (nothing parseable to answer).
77    Io(std::io::Error),
78}
79
80impl std::fmt::Display for ProxyProtoError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            ProxyProtoError::Malformed(why) => write!(f, "malformed PROXY header: {why}"),
84            ProxyProtoError::Incomplete => {
85                write!(f, "PROXY header did not complete within the deadline")
86            }
87            ProxyProtoError::Io(e) => write!(f, "PROXY header read failed: {e}"),
88        }
89    }
90}
91
92impl std::error::Error for ProxyProtoError {}
93
94/// Read one PROXY protocol header (v1 or v2) from the stream and decide
95/// the connection's EFFECTIVE client address.
96///
97/// Returns the address the gateway must use as the peer: the header's
98/// source address for a `PROXY` command with a TCP family, or `real`
99/// for the specification's own fallback shapes (v2 `LOCAL`, v1
100/// `UNKNOWN`). `deadline` bounds the WHOLE header (not per-read: a
101/// partial PROXY line can never be handed to the HTTP parser, so
102/// unlike the DW-023 request-head sniff there is no fall-through).
103///
104/// Reads incrementally: ppp's `PartialResult` says whether more bytes
105/// could complete the header, so at most the header itself is buffered
106/// (never the request that follows it on well-behaved senders that
107/// coalesce; a sender that pipelines request bytes into the first read
108/// has them replayed by the caller's sniff/prefix stream — see
109/// [`ProxyHeader::consumed`]).
110pub async fn read_client_addr<S>(
111    stream: &mut S,
112    real: SocketAddr,
113    deadline: Duration,
114) -> Result<ProxyHeader, ProxyProtoError>
115where
116    S: AsyncRead + AsyncWrite + Unpin,
117{
118    // Buffer with headroom for one v2 header exactly; grow past V1_MAX
119    // only for v2-shaped input (the 12-byte binary signature).
120    let mut buf: Vec<u8> = Vec::with_capacity(V2_MAX);
121    let mut chunk = [0u8; 1024];
122    let read = async {
123        loop {
124            let parsed = HeaderResult::parse(&buf);
125            if !parsed.is_incomplete() {
126                let (client, consumed) = classify(parsed, real)?;
127                return Ok(ProxyHeader {
128                    client,
129                    consumed,
130                    prefix: buf[consumed..].to_vec(),
131                });
132            }
133            // Incomplete so far: the bytes read so far must still be
134            // CONSISTENT with one of the two version signatures (a
135            // prefix of "PROXY ", or a prefix of the 12-byte v2 binary
136            // signature followed by anything). Growth is then bounded
137            // by that version's own ceiling; anything else is malformed
138            // NOW — never fall through to HTTP parsing.
139            let v2_sig = ppp::v2::PROTOCOL_PREFIX;
140            let n2 = buf.len().min(v2_sig.len());
141            let v1_prefix: &[u8] = b"PROXY ";
142            let n1 = buf.len().min(v1_prefix.len());
143            let cap = if buf[..n2] == v2_sig[..n2] {
144                V2_MAX
145            } else if buf[..n1] == v1_prefix[..n1] {
146                V1_MAX
147            } else {
148                return Err(ProxyProtoError::Malformed(
149                    "first bytes are neither a v1 line nor a v2 signature",
150                ));
151            };
152            if buf.len() > cap {
153                return Err(ProxyProtoError::Malformed(
154                    "header exceeds the protocol ceiling",
155                ));
156            }
157            let n = match stream.read(&mut chunk).await {
158                Ok(0) => {
159                    return Err(ProxyProtoError::Io(std::io::Error::new(
160                        std::io::ErrorKind::UnexpectedEof,
161                        "peer closed mid-header",
162                    )))
163                }
164                Ok(n) => n,
165                Err(e) => return Err(ProxyProtoError::Io(e)),
166            };
167            buf.extend_from_slice(&chunk[..n]);
168        }
169    };
170    match tokio::time::timeout(deadline, read).await {
171        Ok(result) => result,
172        Err(_) => Err(ProxyProtoError::Incomplete),
173    }
174}
175
176/// What the listener learned from one accepted connection's header.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct ProxyHeader {
179    /// The address the gateway must treat as the connection's client
180    /// (header source, or the real peer for the spec's fallback shapes).
181    pub client: SocketAddr,
182    /// Bytes of the PROXY header itself.
183    pub consumed: usize,
184    /// Bytes read BEYOND the header (a sender pipelining the TLS
185    /// records or the HTTP head behind the PROXY line): replay them in
186    /// front of the stream handed onward
187    /// ([`crate::dataplane::hardening::PrefixedStream`]).
188    pub prefix: Vec<u8>,
189}
190
191/// Turn a complete `HeaderResult` into the effective address policy:
192/// the client address and how many bytes the header occupied.
193fn classify(
194    parsed: HeaderResult<'_>,
195    real: SocketAddr,
196) -> Result<(SocketAddr, usize), ProxyProtoError> {
197    match parsed {
198        HeaderResult::V2(Ok(h)) => {
199            let consumed = h.header.len();
200            use ppp::v2::{Addresses, Command, Protocol};
201            // A datagram protocol claim is misuse on a TCP stream
202            // listener — but only under the PROXY command: a LOCAL
203            // header's other fields are noise by specification (the
204            // connection is the LB's own), so LOCAL falls through to
205            // the real peer whatever they say.
206            if h.command == Command::Proxy && h.protocol == Protocol::Datagram {
207                return Err(ProxyProtoError::Malformed(
208                    "v2 header claims a datagram protocol on a stream listener",
209                ));
210            }
211            match (h.command, h.addresses) {
212                // The LB's own connection (health checks): keep the real
213                // peer, exactly as the specification prescribes.
214                (Command::Local, _) => Ok((real, consumed)),
215                // AF_UNSPEC with the PROXY command: the spec's explicit
216                // "use the local connection's addresses" fallback.
217                (Command::Proxy, Addresses::Unspecified) => Ok((real, consumed)),
218                (Command::Proxy, Addresses::IPv4(a)) => Ok((
219                    SocketAddr::from((a.source_address, a.source_port)),
220                    consumed,
221                )),
222                (Command::Proxy, Addresses::IPv6(a)) => Ok((
223                    SocketAddr::from((a.source_address, a.source_port)),
224                    consumed,
225                )),
226                // AF_UNIX addresses cannot feed the IP-keyed policies.
227                (Command::Proxy, Addresses::Unix(_)) => Err(ProxyProtoError::Malformed(
228                    "v2 header carries a Unix address family on a TCP listener",
229                )),
230            }
231        }
232        HeaderResult::V1(Ok(h)) => {
233            let consumed = h.header.len();
234            match h.addresses {
235                ppp::v1::Addresses::Unknown => Ok((real, consumed)),
236                ppp::v1::Addresses::Tcp4(a) => Ok((
237                    SocketAddr::from((a.source_address, a.source_port)),
238                    consumed,
239                )),
240                ppp::v1::Addresses::Tcp6(a) => Ok((
241                    SocketAddr::from((a.source_address, a.source_port)),
242                    consumed,
243                )),
244            }
245        }
246        // A terminal error from both parsers: garbage that claimed to be
247        // neither version. Fail closed.
248        HeaderResult::V2(Err(_)) | HeaderResult::V1(Err(_)) => Err(ProxyProtoError::Malformed(
249            "bytes are neither a valid v1 nor v2 header",
250        )),
251    }
252}
253
254/// Answer a malformed-header connection with the `400` error envelope
255/// and close it (the fail-closed half of the policy: never fall through
256/// to HTTP parsing). The envelope is the gateway's uniform error shape,
257/// with a freshly generated correlation ID (no request exists yet to
258/// inherit one from).
259pub async fn reject_malformed<S>(stream: &mut S) -> std::io::Result<()>
260where
261    S: AsyncWrite + Unpin,
262{
263    let rid = crate::observability::generate_request_id();
264    let body = crate::observability::envelope_body(
265        "proxy_protocol_malformed",
266        "connection opened with a malformed PROXY protocol header",
267        &rid,
268    );
269    let head = format!(
270        "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\nx-request-id: {}\r\n\r\n",
271        body.len(),
272        rid
273    );
274    stream.write_all(head.as_bytes()).await?;
275    stream.write_all(&body).await?;
276    stream.flush().await
277}