Skip to main content

plecto_server/
proxy_protocol.rs

1//! PROXY protocol v2 reception (ADR 000057): restore the real client address behind an L4 load
2//! balancer, at the only possible point — after accept, before the TLS handshake / HTTP parse.
3//!
4//! Two layers, deliberately split:
5//! - [`parse_proxy_v2`] — a pure, I/O-free parser over a byte slice (the fuzz target). Written
6//!   from the public spec (`proxy-protocol.txt`, HAProxy Technologies) alone; v2 binary form
7//!   only, v1 text form is not accepted (ADR 000057).
8//! - [`resolve_peer`] — the listener-side I/O: bounded reads under a deadline for trusted peers
9//!   (the header is mandatory there), a non-consuming signature peek for untrusted peers (their
10//!   bytes belong to TLS/HTTP), every anomaly fail-closed as a typed fault.
11//!
12//! The module is `pub` so the out-of-workspace fuzz harness (`fuzz/`) can drive the parser; it
13//! is not a semver surface (the crate is `publish = false`).
14
15use std::net::SocketAddr;
16use std::time::Duration;
17
18use plecto_control::ProxyProtocolTrust;
19use tokio::net::TcpStream;
20
21/// The 12-byte v2 signature. Contains an interior NUL — never treat as a C string (spec §2.2).
22pub const SIGNATURE: [u8; 12] = *b"\r\n\r\n\0\r\nQUIT\n";
23/// The fixed prefix: signature + version/command + family/protocol + declared length.
24pub const PREFIX_LEN: usize = 16;
25/// Cap on the self-described length (address block + TLVs). The spec sizes the header to fit a
26/// 536-byte minimal TCP segment; 2 KiB is generous for every real sender while bounding the
27/// read (ADR 000057 — bounded reads on untrusted input).
28pub const MAX_DECLARED_LEN: usize = 2048;
29
30/// A complete, valid v2 header, reduced to what the listener consumes.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ProxyV2 {
33    /// `LOCAL`: a proxy-originated connection (health checks). Use the connection's real
34    /// endpoints; the declared bytes were skipped as the spec requires.
35    Local,
36    /// `PROXY` over TCP/IPv4 or TCP/IPv6: the restored original source. The destination
37    /// address and any TLVs are parsed past and dropped — nothing downstream consumes them.
38    Proxy { src: SocketAddr },
39}
40
41/// The parser's verdict over the bytes seen so far.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Parsed {
44    /// A complete, valid header occupying the first `consumed` bytes.
45    Complete { decision: ProxyV2, consumed: usize },
46    /// Consistent with a v2 header so far, but the buffer holds fewer than `needed` bytes.
47    Incomplete { needed: usize },
48}
49
50/// Why a byte sequence is not a valid v2 header. Every variant is a connection-fatal fault
51/// code (fail-closed, ADR 000057) — there is no "tolerate and pass through".
52#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
53pub enum ProxyV2Error {
54    #[error("signature mismatch")]
55    BadSignature,
56    #[error("unsupported version in version/command byte {0:#04x}")]
57    BadVersion(u8),
58    #[error("unsupported command in version/command byte {0:#04x}")]
59    BadCommand(u8),
60    /// `PROXY` with anything but TCP over IPv4/IPv6 (AF_UNSPEC / AF_UNIX / DGRAM / reserved):
61    /// rejected per ADR 000057 — the listener is a TCP fast path; an unspecified peer identity
62    /// must not silently degrade to the LB's address.
63    #[error("unsupported family/protocol byte {0:#04x} (only TCP over IPv4/IPv6 is accepted)")]
64    UnsupportedFamilyProtocol(u8),
65    #[error("declared length {declared} exceeds the {MAX_DECLARED_LEN}-byte cap")]
66    DeclaredLenTooLarge { declared: usize },
67    #[error("declared length {declared} is shorter than the {need}-byte address block")]
68    AddressBlockTooShort { declared: usize, need: usize },
69}
70
71/// Parse a PROXY protocol v2 header from the start of `buf` — pure and total: no I/O, no
72/// panics on any input (the fuzz target's invariant, P11). Errors take precedence over
73/// `Incomplete` as soon as the offending byte is visible, so a bad peer is cut without
74/// waiting for bytes that would change nothing.
75pub fn parse_proxy_v2(buf: &[u8]) -> Result<Parsed, ProxyV2Error> {
76    // Compare only the bytes present: a mismatch anywhere is final, a strict prefix is not.
77    let sig_seen = buf.len().min(SIGNATURE.len());
78    if buf.get(..sig_seen) != SIGNATURE.get(..sig_seen) {
79        return Err(ProxyV2Error::BadSignature);
80    }
81    let (Some(&ver_cmd), Some(&fam_proto), Some(len_hi), Some(len_lo)) = (
82        buf.get(12),
83        buf.get(13),
84        buf.get(14).copied(),
85        buf.get(15).copied(),
86    ) else {
87        return Ok(Parsed::Incomplete { needed: PREFIX_LEN });
88    };
89    if ver_cmd >> 4 != 0x2 {
90        return Err(ProxyV2Error::BadVersion(ver_cmd));
91    }
92    let declared = usize::from(u16::from_be_bytes([len_hi, len_lo]));
93    if declared > MAX_DECLARED_LEN {
94        return Err(ProxyV2Error::DeclaredLenTooLarge { declared });
95    }
96    let needed = PREFIX_LEN + declared;
97    match ver_cmd & 0x0F {
98        // LOCAL: use the real endpoints; family is ignored, but the declared bytes must still
99        // be consumed (spec: "must not assume zero is presented for LOCAL connections").
100        0x0 => {
101            if buf.len() < needed {
102                return Ok(Parsed::Incomplete { needed });
103            }
104            Ok(Parsed::Complete {
105                decision: ProxyV2::Local,
106                consumed: needed,
107            })
108        }
109        // PROXY: only TCP over IPv4/IPv6 (ADR 000057); anything else is a fault, not a fallback.
110        0x1 => {
111            let need = match fam_proto {
112                0x11 => 12, // AF_INET,  STREAM: src(4) dst(4) src-port(2) dst-port(2)
113                0x21 => 36, // AF_INET6, STREAM: src(16) dst(16) src-port(2) dst-port(2)
114                other => return Err(ProxyV2Error::UnsupportedFamilyProtocol(other)),
115            };
116            if declared < need {
117                return Err(ProxyV2Error::AddressBlockTooShort { declared, need });
118            }
119            if buf.len() < needed {
120                return Ok(Parsed::Incomplete { needed });
121            }
122            let block = buf.get(PREFIX_LEN..PREFIX_LEN + need).unwrap_or(&[]);
123            let src = match fam_proto {
124                0x11 => parse_src_v4(block),
125                _ => parse_src_v6(block),
126            };
127            // `block` is exactly `need` bytes here, so the sub-slices below always exist; the
128            // defensive `None` arm keeps the function total without a data-plane panic (P11).
129            let Some(src) = src else {
130                return Err(ProxyV2Error::AddressBlockTooShort { declared, need });
131            };
132            Ok(Parsed::Complete {
133                decision: ProxyV2::Proxy { src },
134                consumed: needed,
135            })
136        }
137        _ => Err(ProxyV2Error::BadCommand(ver_cmd)),
138    }
139}
140
141/// Source `SocketAddr` out of a 12-byte IPv4 address block (addresses then ports, all
142/// network byte order).
143fn parse_src_v4(block: &[u8]) -> Option<SocketAddr> {
144    let ip: [u8; 4] = block.get(0..4)?.try_into().ok()?;
145    let port: [u8; 2] = block.get(8..10)?.try_into().ok()?;
146    Some(SocketAddr::from((ip, u16::from_be_bytes(port))))
147}
148
149/// Source `SocketAddr` out of a 36-byte IPv6 address block.
150fn parse_src_v6(block: &[u8]) -> Option<SocketAddr> {
151    let ip: [u8; 16] = block.get(0..16)?.try_into().ok()?;
152    let port: [u8; 2] = block.get(32..34)?.try_into().ok()?;
153    Some(SocketAddr::from((ip, u16::from_be_bytes(port))))
154}
155
156/// Why a connection was cut at the PROXY layer — the fault codes ADR 000057 requires logged.
157#[derive(Debug, thiserror::Error)]
158pub(crate) enum ProxyFault {
159    #[error("malformed PROXY v2 header: {0}")]
160    Header(#[from] ProxyV2Error),
161    #[error("PROXY v2 signature from a peer outside the trusted CIDRs")]
162    UntrustedHeader,
163    #[error("connection ended before a complete PROXY v2 header: {0}")]
164    Truncated(std::io::Error),
165    #[error("deadline exceeded before a complete PROXY v2 header")]
166    Deadline,
167}
168
169/// Resolve the peer the rest of the connection should see, per the ADR 000057 receipt rules:
170/// a trusted peer MUST present a valid header (`PROXY` → the restored source, `LOCAL` → the
171/// real peer); an untrusted peer must NOT (signature detected → cut) — its bytes are only
172/// peeked, never consumed, because they belong to the TLS/HTTP stack. Every read is bounded
173/// and under `deadline`; every anomaly is a fault (fail-closed), never a pass-through.
174pub(crate) async fn resolve_peer(
175    stream: &mut TcpStream,
176    peer: SocketAddr,
177    trusted: &ProxyProtocolTrust,
178    deadline: Duration,
179) -> Result<SocketAddr, ProxyFault> {
180    let work = async {
181        if trusted.contains(peer.ip()) {
182            read_trusted_header(stream, peer).await
183        } else {
184            detect_untrusted_header(stream, peer).await
185        }
186    };
187    (tokio::time::timeout(deadline, work).await).unwrap_or(Err(ProxyFault::Deadline))
188}
189
190/// A trusted peer MUST open with a valid v2 header: read exactly the fixed prefix, then
191/// exactly the self-described remainder — never more, so the bytes after the header (TLS
192/// ClientHello / HTTP request) stay in the socket for the next layer.
193async fn read_trusted_header(
194    stream: &mut TcpStream,
195    peer: SocketAddr,
196) -> Result<SocketAddr, ProxyFault> {
197    use tokio::io::AsyncReadExt;
198    let mut buf = vec![0u8; PREFIX_LEN];
199    stream
200        .read_exact(&mut buf)
201        .await
202        .map_err(ProxyFault::Truncated)?;
203    loop {
204        match parse_proxy_v2(&buf)? {
205            Parsed::Complete { decision, .. } => {
206                return Ok(match decision {
207                    ProxyV2::Local => peer,
208                    ProxyV2::Proxy { src } => src,
209                });
210            }
211            Parsed::Incomplete { needed } => {
212                // The parser only reports `Incomplete` with `needed` beyond what it saw, so
213                // the loop strictly grows the buffer and ends on the second pass; the guard
214                // keeps a (hypothetical) parser bug from spinning instead of failing closed.
215                let have = buf.len();
216                if needed <= have {
217                    return Err(ProxyFault::Truncated(
218                        std::io::ErrorKind::UnexpectedEof.into(),
219                    ));
220                }
221                buf.resize(needed, 0);
222                if let Some(tail) = buf.get_mut(have..) {
223                    stream
224                        .read_exact(tail)
225                        .await
226                        .map_err(ProxyFault::Truncated)?;
227                }
228            }
229        }
230    }
231}
232
233/// An untrusted peer must NOT speak PROXY v2 — but its bytes belong to the TLS/HTTP stack, so
234/// only peek (non-consuming): a signature mismatch at any position clears the connection to
235/// proceed with its real peer; a full signature is a fault (ADR 000057 receipt rules).
236async fn detect_untrusted_header(
237    stream: &TcpStream,
238    peer: SocketAddr,
239) -> Result<SocketAddr, ProxyFault> {
240    let mut probe = [0u8; SIGNATURE.len()];
241    loop {
242        let n = stream
243            .peek(&mut probe)
244            .await
245            .map_err(ProxyFault::Truncated)?;
246        if n == 0 {
247            return Err(ProxyFault::Truncated(
248                std::io::ErrorKind::UnexpectedEof.into(),
249            ));
250        }
251        if probe.get(..n) != SIGNATURE.get(..n) {
252            return Ok(peer);
253        }
254        if n >= SIGNATURE.len() {
255            return Err(ProxyFault::UntrustedHeader);
256        }
257        // A strict signature prefix: only more bytes can decide. `peek` keeps returning the
258        // same data immediately while nothing new arrives, so pace the loop rather than spin;
259        // the caller's deadline bounds the wait.
260        tokio::time::sleep(Duration::from_millis(10)).await;
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
268
269    /// Build a v2 header: signature + `ver_cmd` + `fam_proto` + declared length + `payload`.
270    fn header(ver_cmd: u8, fam_proto: u8, declared: u16, payload: &[u8]) -> Vec<u8> {
271        let mut h = Vec::with_capacity(PREFIX_LEN + payload.len());
272        h.extend_from_slice(&SIGNATURE);
273        h.push(ver_cmd);
274        h.push(fam_proto);
275        h.extend_from_slice(&declared.to_be_bytes());
276        h.extend_from_slice(payload);
277        h
278    }
279
280    fn v4_block(src: SocketAddrV4, dst: SocketAddrV4) -> Vec<u8> {
281        let mut b = Vec::with_capacity(12);
282        b.extend_from_slice(&src.ip().octets());
283        b.extend_from_slice(&dst.ip().octets());
284        b.extend_from_slice(&src.port().to_be_bytes());
285        b.extend_from_slice(&dst.port().to_be_bytes());
286        b
287    }
288
289    fn v6_block(src: SocketAddrV6, dst: SocketAddrV6) -> Vec<u8> {
290        let mut b = Vec::with_capacity(36);
291        b.extend_from_slice(&src.ip().octets());
292        b.extend_from_slice(&dst.ip().octets());
293        b.extend_from_slice(&src.port().to_be_bytes());
294        b.extend_from_slice(&dst.port().to_be_bytes());
295        b
296    }
297
298    #[test]
299    fn parse_table() {
300        let src4 = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 9), 51234);
301        let dst4 = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 443);
302        let src6 = SocketAddrV6::new("2001:db8::9".parse::<Ipv6Addr>().unwrap(), 51234, 0, 0);
303        let dst6 = SocketAddrV6::new("2001:db8::1".parse::<Ipv6Addr>().unwrap(), 443, 0, 0);
304
305        struct Case {
306            name: &'static str,
307            input: Vec<u8>,
308            want: Result<Parsed, ProxyV2Error>,
309        }
310        let cases = vec![
311            Case {
312                name: "proxy over tcp/ipv4 restores the source",
313                input: header(0x21, 0x11, 12, &v4_block(src4, dst4)),
314                want: Ok(Parsed::Complete {
315                    decision: ProxyV2::Proxy { src: src4.into() },
316                    consumed: 28,
317                }),
318            },
319            Case {
320                name: "proxy over tcp/ipv6 restores the source",
321                input: header(0x21, 0x21, 36, &v6_block(src6, dst6)),
322                want: Ok(Parsed::Complete {
323                    decision: ProxyV2::Proxy { src: src6.into() },
324                    consumed: 52,
325                }),
326            },
327            Case {
328                name: "tlv bytes after the address block are skipped",
329                input: header(0x21, 0x11, 12 + 5, &{
330                    let mut p = v4_block(src4, dst4);
331                    p.extend_from_slice(&[0x04, 0x00, 0x02, 0xAA, 0xBB]); // PP2_TYPE_NOOP
332                    p
333                }),
334                want: Ok(Parsed::Complete {
335                    decision: ProxyV2::Proxy { src: src4.into() },
336                    consumed: 33,
337                }),
338            },
339            Case {
340                name: "local with zero length uses the real endpoints",
341                input: header(0x20, 0x00, 0, &[]),
342                want: Ok(Parsed::Complete {
343                    decision: ProxyV2::Local,
344                    consumed: 16,
345                }),
346            },
347            Case {
348                name: "local must still skip its declared bytes (spec: never assume zero)",
349                input: header(0x20, 0x11, 12, &v4_block(src4, dst4)),
350                want: Ok(Parsed::Complete {
351                    decision: ProxyV2::Local,
352                    consumed: 28,
353                }),
354            },
355            Case {
356                name: "empty buffer needs the fixed prefix",
357                input: Vec::new(),
358                want: Ok(Parsed::Incomplete { needed: PREFIX_LEN }),
359            },
360            Case {
361                name: "a strict signature prefix is incomplete, not a mismatch",
362                input: b"\r\n\r".to_vec(),
363                want: Ok(Parsed::Incomplete { needed: PREFIX_LEN }),
364            },
365            Case {
366                name: "prefix complete but address block still in flight",
367                input: header(0x21, 0x11, 12, &v4_block(src4, dst4)[..4]),
368                want: Ok(Parsed::Incomplete { needed: 28 }),
369            },
370            Case {
371                name: "signature mismatch",
372                input: b"GET /api HTTP/1.1\r\n".to_vec(),
373                want: Err(ProxyV2Error::BadSignature),
374            },
375            Case {
376                name: "v1 text form is not accepted (ADR 000057)",
377                input: b"PROXY TCP4 203.0.113.9 10.0.0.1 51234 443\r\n".to_vec(),
378                want: Err(ProxyV2Error::BadSignature),
379            },
380            Case {
381                name: "version nibble other than 2 is rejected",
382                input: header(0x11, 0x11, 12, &v4_block(src4, dst4)),
383                want: Err(ProxyV2Error::BadVersion(0x11)),
384            },
385            Case {
386                name: "command nibble beyond LOCAL/PROXY is rejected",
387                input: header(0x22, 0x11, 12, &v4_block(src4, dst4)),
388                want: Err(ProxyV2Error::BadCommand(0x22)),
389            },
390            Case {
391                name: "proxy with AF_UNSPEC is cut, not degraded to the LB address",
392                input: header(0x21, 0x00, 0, &[]),
393                want: Err(ProxyV2Error::UnsupportedFamilyProtocol(0x00)),
394            },
395            Case {
396                name: "proxy over AF_UNIX is rejected",
397                input: header(0x21, 0x31, 216, &[0u8; 216]),
398                want: Err(ProxyV2Error::UnsupportedFamilyProtocol(0x31)),
399            },
400            Case {
401                name: "proxy over DGRAM is rejected",
402                input: header(0x21, 0x12, 12, &v4_block(src4, dst4)),
403                want: Err(ProxyV2Error::UnsupportedFamilyProtocol(0x12)),
404            },
405            Case {
406                name: "declared length above the 2 KiB cap is rejected before any read",
407                input: header(0x21, 0x11, 2049, &[]),
408                want: Err(ProxyV2Error::DeclaredLenTooLarge { declared: 2049 }),
409            },
410            Case {
411                name: "declared length shorter than the ipv4 address block",
412                input: header(0x21, 0x11, 11, &[0u8; 11]),
413                want: Err(ProxyV2Error::AddressBlockTooShort {
414                    declared: 11,
415                    need: 12,
416                }),
417            },
418            Case {
419                name: "declared length shorter than the ipv6 address block",
420                input: header(0x21, 0x21, 12, &v4_block(src4, dst4)),
421                want: Err(ProxyV2Error::AddressBlockTooShort {
422                    declared: 12,
423                    need: 36,
424                }),
425            },
426        ];
427        for case in cases {
428            let got = parse_proxy_v2(&case.input);
429            assert_eq!(got, case.want, "case: {}", case.name);
430        }
431    }
432}