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}