Skip to main content

pitchfork_cli/proxy/
sni.rs

1//! TLS ClientHello parsing, used to route a connection before any TLS
2//! handshake takes place.
3//!
4//! The proxy peeks at the bytes a client sent without consuming them, reads the
5//! Server Name Indication (SNI) extension, and uses the hostname to decide
6//! whether to terminate TLS itself or splice the raw stream to a daemon that
7//! terminates TLS on its own. Because the bytes are only peeked, the same
8//! ClientHello is still there for whichever path is chosen.
9//!
10//! Only the parts of RFC 8446 §4.1.2 / RFC 6066 §3 needed for that decision are
11//! parsed. Nothing here validates the handshake; rustls does that on the
12//! terminate path, and the daemon does it on the passthrough path.
13
14/// TLS record content type for handshake records.
15const CONTENT_TYPE_HANDSHAKE: u8 = 0x16;
16
17/// Handshake message type for a ClientHello.
18const HANDSHAKE_TYPE_CLIENT_HELLO: u8 = 0x01;
19
20/// Extension type for `server_name` (RFC 6066 §3).
21const EXTENSION_SERVER_NAME: u16 = 0x0000;
22
23/// `NameType.host_name` inside the `server_name` extension.
24const NAME_TYPE_HOST_NAME: u8 = 0x00;
25
26/// The outcome of looking for an SNI hostname in the bytes read so far.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SniPeek {
29    /// The ClientHello carried a `server_name` extension with this hostname.
30    /// Lowercased, since host names are case-insensitive (RFC 4343).
31    Found(String),
32    /// A complete ClientHello was parsed and it carried no usable SNI
33    /// hostname — an IP-address connection, or a client that omits the
34    /// extension.
35    Absent,
36    /// The bytes end mid-message: a complete ClientHello may still arrive.
37    /// The caller should read more and try again.
38    Incomplete,
39    /// The bytes cannot be the start of a TLS handshake, so no amount of
40    /// waiting will produce a ClientHello.
41    NotTls,
42}
43
44/// A cursor over a byte slice that yields `None` instead of panicking when the
45/// slice runs out, so a truncated ClientHello reads as [`SniPeek::Incomplete`]
46/// rather than an index-out-of-bounds.
47struct Reader<'a> {
48    buf: &'a [u8],
49    pos: usize,
50}
51
52impl<'a> Reader<'a> {
53    fn new(buf: &'a [u8]) -> Self {
54        Self { buf, pos: 0 }
55    }
56
57    fn remaining(&self) -> usize {
58        self.buf.len().saturating_sub(self.pos)
59    }
60
61    fn u8(&mut self) -> Option<u8> {
62        let b = *self.buf.get(self.pos)?;
63        self.pos += 1;
64        Some(b)
65    }
66
67    fn u16(&mut self) -> Option<u16> {
68        let hi = self.u8()? as u16;
69        let lo = self.u8()? as u16;
70        Some((hi << 8) | lo)
71    }
72
73    fn u24(&mut self) -> Option<u32> {
74        let a = self.u8()? as u32;
75        let b = self.u8()? as u32;
76        let c = self.u8()? as u32;
77        Some((a << 16) | (b << 8) | c)
78    }
79
80    fn bytes(&mut self, n: usize) -> Option<&'a [u8]> {
81        let end = self.pos.checked_add(n)?;
82        let slice = self.buf.get(self.pos..end)?;
83        self.pos = end;
84        Some(slice)
85    }
86
87    /// Skip a length-prefixed block whose length is `len_bytes` wide.
88    fn skip_prefixed(&mut self, len_bytes: usize) -> Option<()> {
89        let len = match len_bytes {
90            1 => self.u8()? as usize,
91            2 => self.u16()? as usize,
92            _ => return None,
93        };
94        self.bytes(len).map(|_| ())
95    }
96}
97
98/// Look for an SNI hostname in `buf`, which holds the first bytes a client
99/// sent on a connection.
100///
101/// A ClientHello may be split across several TLS records, so the handshake
102/// bytes are reassembled from every complete handshake record in `buf` before
103/// being parsed.
104///
105/// Reassembly stops at the first record of another content type rather than
106/// rejecting the connection: a TLS 1.3 client may follow its hello with a
107/// compatibility ChangeCipherSpec or early data in the same flight, and the
108/// hello that arrived before it is still the thing to route on. Only a
109/// connection whose *first* record is not a handshake is ruled out.
110pub fn parse_sni(buf: &[u8]) -> SniPeek {
111    let mut records = Reader::new(buf);
112    let mut handshake: Vec<u8> = Vec::new();
113
114    loop {
115        if records.remaining() == 0 {
116            break;
117        }
118        // A record header is 5 bytes: type (1), legacy version (2), length (2).
119        if records.remaining() < 5 {
120            // Not even a full header. With handshake bytes already in hand the
121            // partial record cannot add to them, so parse what arrived;
122            // otherwise the first byte is enough to rule out a non-TLS client
123            // such as a plain HTTP request.
124            if !handshake.is_empty() {
125                break;
126            }
127            return match records.u8() {
128                Some(CONTENT_TYPE_HANDSHAKE) => SniPeek::Incomplete,
129                Some(_) => SniPeek::NotTls,
130                None => SniPeek::Incomplete,
131            };
132        }
133        let Some(content_type) = records.u8() else {
134            return SniPeek::Incomplete;
135        };
136        if content_type != CONTENT_TYPE_HANDSHAKE {
137            // Another content type ends the handshake bytes. Whatever hello
138            // already arrived is what this connection is routed on; with none,
139            // the connection never started a handshake at all.
140            if !handshake.is_empty() {
141                break;
142            }
143            return SniPeek::NotTls;
144        }
145        // Legacy record version: not checked, a ClientHello advertises its
146        // real version inside the handshake body and via extensions.
147        if records.u16().is_none() {
148            return SniPeek::Incomplete;
149        }
150        let Some(len) = records.u16() else {
151            return SniPeek::Incomplete;
152        };
153        match records.bytes(len as usize) {
154            Some(payload) => handshake.extend_from_slice(payload),
155            // The record is announced but has not fully arrived.
156            None => break,
157        }
158    }
159
160    parse_handshake(&handshake)
161}
162
163/// Parse reassembled handshake bytes and return the SNI hostname.
164fn parse_handshake(handshake: &[u8]) -> SniPeek {
165    let mut r = Reader::new(handshake);
166
167    match r.u8() {
168        Some(HANDSHAKE_TYPE_CLIENT_HELLO) => {}
169        // A handshake that opens with anything else is not a new client
170        // connection (e.g. a mid-session renegotiation reaching us by
171        // mistake); there is no hostname to route on.
172        Some(_) => return SniPeek::NotTls,
173        None => return SniPeek::Incomplete,
174    }
175
176    let Some(body_len) = r.u24() else {
177        return SniPeek::Incomplete;
178    };
179    if r.remaining() < body_len as usize {
180        return SniPeek::Incomplete;
181    }
182
183    // client_version (2) + random (32)
184    if r.bytes(34).is_none() {
185        return SniPeek::Incomplete;
186    }
187    // legacy_session_id, cipher_suites, legacy_compression_methods
188    if r.skip_prefixed(1).is_none() || r.skip_prefixed(2).is_none() || r.skip_prefixed(1).is_none()
189    {
190        return SniPeek::Incomplete;
191    }
192
193    // Extensions are optional in the wire format (SSLv3-era hellos omit them).
194    if r.remaining() == 0 {
195        return SniPeek::Absent;
196    }
197    let Some(ext_total) = r.u16() else {
198        return SniPeek::Incomplete;
199    };
200    let Some(ext_bytes) = r.bytes(ext_total as usize) else {
201        return SniPeek::Incomplete;
202    };
203
204    let mut ext = Reader::new(ext_bytes);
205    while ext.remaining() > 0 {
206        let (Some(ext_type), Some(ext_len)) = (ext.u16(), ext.u16()) else {
207            return SniPeek::Incomplete;
208        };
209        let Some(body) = ext.bytes(ext_len as usize) else {
210            return SniPeek::Incomplete;
211        };
212        if ext_type == EXTENSION_SERVER_NAME {
213            return parse_server_name_list(body);
214        }
215    }
216
217    SniPeek::Absent
218}
219
220/// Parse the `ServerNameList` body of a `server_name` extension.
221fn parse_server_name_list(body: &[u8]) -> SniPeek {
222    let mut r = Reader::new(body);
223    let Some(list_len) = r.u16() else {
224        return SniPeek::Incomplete;
225    };
226    let Some(list) = r.bytes(list_len as usize) else {
227        return SniPeek::Incomplete;
228    };
229
230    let mut names = Reader::new(list);
231    while names.remaining() > 0 {
232        let (Some(name_type), Some(name_len)) = (names.u8(), names.u16()) else {
233            return SniPeek::Incomplete;
234        };
235        let Some(name) = names.bytes(name_len as usize) else {
236            return SniPeek::Incomplete;
237        };
238        if name_type != NAME_TYPE_HOST_NAME {
239            continue;
240        }
241        // A host name is ASCII on the wire (an internationalized name is sent
242        // A-label encoded), so anything else is not routable. The character
243        // set is narrowed to what a host name can contain, which keeps a name
244        // carrying control bytes — a newline to forge a log line, a NUL to
245        // truncate one — from being routed on or logged at all.
246        return match std::str::from_utf8(name) {
247            Ok(host) if is_routable_host_name(host) => {
248                SniPeek::Found(host.trim_end_matches('.').to_ascii_lowercase())
249            }
250            _ => SniPeek::Absent,
251        };
252    }
253
254    SniPeek::Absent
255}
256
257/// Whether a name from the wire can be routed on, and logged, as a host name.
258///
259/// Accepts the presentation form of a DNS name: ASCII letters, digits, `-`,
260/// `.`, and `_`, which appears in service names and in some development
261/// hostnames. Everything else — a control byte, a space, a quote, an escape —
262/// is not a host name pitchfork can serve, and is refused here rather than
263/// reaching a routing table or a log line.
264fn is_routable_host_name(host: &str) -> bool {
265    !host.is_empty()
266        && host.len() <= 253
267        && host
268            .bytes()
269            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_'))
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    /// Build a `server_name` extension body for one host name.
277    fn server_name_ext(host: &str) -> Vec<u8> {
278        let mut entry = vec![NAME_TYPE_HOST_NAME];
279        entry.extend_from_slice(&(host.len() as u16).to_be_bytes());
280        entry.extend_from_slice(host.as_bytes());
281
282        let mut body = (entry.len() as u16).to_be_bytes().to_vec();
283        body.extend_from_slice(&entry);
284        body
285    }
286
287    /// Wrap an extension body with its type and length.
288    fn extension(ext_type: u16, body: &[u8]) -> Vec<u8> {
289        let mut out = ext_type.to_be_bytes().to_vec();
290        out.extend_from_slice(&(body.len() as u16).to_be_bytes());
291        out.extend_from_slice(body);
292        out
293    }
294
295    /// A ClientHello handshake message carrying `extensions`.
296    fn client_hello(extensions: Vec<Vec<u8>>) -> Vec<u8> {
297        let mut body = Vec::new();
298        body.extend_from_slice(&[0x03, 0x03]); // client_version TLS 1.2
299        body.extend_from_slice(&[0x11; 32]); // random
300        body.push(0); // empty legacy_session_id
301        body.extend_from_slice(&[0x00, 0x02, 0x13, 0x01]); // one cipher suite
302        body.extend_from_slice(&[0x01, 0x00]); // compression: null
303
304        let ext_bytes: Vec<u8> = extensions.concat();
305        body.extend_from_slice(&(ext_bytes.len() as u16).to_be_bytes());
306        body.extend_from_slice(&ext_bytes);
307
308        let mut msg = vec![HANDSHAKE_TYPE_CLIENT_HELLO];
309        let len = body.len() as u32;
310        msg.extend_from_slice(&[(len >> 16) as u8, (len >> 8) as u8, len as u8]);
311        msg.extend_from_slice(&body);
312        msg
313    }
314
315    /// Wrap handshake bytes in a single TLS record.
316    fn record(payload: &[u8]) -> Vec<u8> {
317        let mut out = vec![CONTENT_TYPE_HANDSHAKE, 0x03, 0x01];
318        out.extend_from_slice(&(payload.len() as u16).to_be_bytes());
319        out.extend_from_slice(payload);
320        out
321    }
322
323    /// Split handshake bytes across several records of `chunk` bytes each, the
324    /// shape a fragmenting client produces.
325    fn records_of(payload: &[u8], chunk: usize) -> Vec<u8> {
326        payload.chunks(chunk).flat_map(record).collect()
327    }
328
329    #[test]
330    fn test_parse_sni_finds_hostname() {
331        let hello = client_hello(vec![extension(
332            EXTENSION_SERVER_NAME,
333            &server_name_ext("api.localhost"),
334        )]);
335        assert_eq!(
336            parse_sni(&record(&hello)),
337            SniPeek::Found("api.localhost".to_string())
338        );
339    }
340
341    #[test]
342    fn test_parse_sni_lowercases_and_strips_root_dot() {
343        // Host names are case-insensitive, and a trailing dot names the same
344        // host, so both spellings must resolve to one routing key.
345        let hello = client_hello(vec![extension(
346            EXTENSION_SERVER_NAME,
347            &server_name_ext("API.LocalHost."),
348        )]);
349        assert_eq!(
350            parse_sni(&record(&hello)),
351            SniPeek::Found("api.localhost".to_string())
352        );
353    }
354
355    #[test]
356    fn test_parse_sni_skips_other_extensions() {
357        let hello = client_hello(vec![
358            extension(0x002b, &[0x02, 0x03, 0x04]), // supported_versions
359            extension(EXTENSION_SERVER_NAME, &server_name_ext("app.localhost")),
360            extension(0x0010, &[0x00, 0x03, 0x02, b'h', b'2']), // ALPN
361        ]);
362        assert_eq!(
363            parse_sni(&record(&hello)),
364            SniPeek::Found("app.localhost".to_string())
365        );
366    }
367
368    #[test]
369    fn test_parse_sni_absent_without_extension() {
370        // A client connecting by IP address sends no server_name extension.
371        let hello = client_hello(vec![extension(0x002b, &[0x02, 0x03, 0x04])]);
372        assert_eq!(parse_sni(&record(&hello)), SniPeek::Absent);
373    }
374
375    #[test]
376    fn test_parse_sni_absent_with_empty_extension_list() {
377        let hello = client_hello(vec![]);
378        assert_eq!(parse_sni(&record(&hello)), SniPeek::Absent);
379    }
380
381    #[test]
382    fn test_parse_sni_absent_when_extensions_omitted_entirely() {
383        // An SSLv3-era hello ends after the compression list.
384        let full = client_hello(vec![]);
385        // Drop the 2-byte extensions length and the (empty) extension block.
386        let mut body = full[4..].to_vec();
387        body.truncate(body.len() - 2);
388        let mut msg = vec![HANDSHAKE_TYPE_CLIENT_HELLO];
389        let len = body.len() as u32;
390        msg.extend_from_slice(&[(len >> 16) as u8, (len >> 8) as u8, len as u8]);
391        msg.extend_from_slice(&body);
392        assert_eq!(parse_sni(&record(&msg)), SniPeek::Absent);
393    }
394
395    #[test]
396    fn test_parse_sni_across_fragmented_records() {
397        // A ClientHello split over several records still yields its hostname
398        // once every fragment has arrived.
399        let hello = client_hello(vec![extension(
400            EXTENSION_SERVER_NAME,
401            &server_name_ext("split.localhost"),
402        )]);
403        for chunk in [1usize, 5, 17, 64] {
404            assert_eq!(
405                parse_sni(&records_of(&hello, chunk)),
406                SniPeek::Found("split.localhost".to_string()),
407                "fragmented into {chunk}-byte records"
408            );
409        }
410    }
411
412    #[test]
413    fn test_parse_sni_incomplete_prefixes_of_a_fragmented_hello() {
414        // Every strict prefix of a fragmented ClientHello is incomplete: the
415        // caller must wait rather than route on a half-read hello.
416        let hello = client_hello(vec![extension(
417            EXTENSION_SERVER_NAME,
418            &server_name_ext("split.localhost"),
419        )]);
420        let wire = records_of(&hello, 7);
421        for n in 1..wire.len() {
422            assert_eq!(
423                parse_sni(&wire[..n]),
424                SniPeek::Incomplete,
425                "prefix of {n} bytes should be incomplete"
426            );
427        }
428        assert_eq!(
429            parse_sni(&wire),
430            SniPeek::Found("split.localhost".to_string())
431        );
432    }
433
434    #[test]
435    fn test_parse_sni_incomplete_on_truncated_record() {
436        let hello = client_hello(vec![extension(
437            EXTENSION_SERVER_NAME,
438            &server_name_ext("api.localhost"),
439        )]);
440        let wire = record(&hello);
441        assert_eq!(parse_sni(&wire[..wire.len() - 10]), SniPeek::Incomplete);
442        assert_eq!(parse_sni(&[]), SniPeek::Incomplete);
443        assert_eq!(parse_sni(&[CONTENT_TYPE_HANDSHAKE]), SniPeek::Incomplete);
444    }
445
446    /// A TLS 1.3 client may send a compatibility ChangeCipherSpec, or early
447    /// data, right behind its hello. The hostname must still be found, or the
448    /// connection would be terminated with the proxy's certificate instead of
449    /// spliced to the daemon.
450    #[test]
451    fn test_parse_sni_finds_hostname_before_a_non_handshake_record() {
452        let hello = client_hello(vec![extension(
453            EXTENSION_SERVER_NAME,
454            &server_name_ext("api.localhost"),
455        )]);
456
457        // ClientHello followed by a dummy ChangeCipherSpec record.
458        let mut wire = record(&hello);
459        wire.extend_from_slice(&[0x14, 0x03, 0x03, 0x00, 0x01, 0x01]);
460        assert_eq!(
461            parse_sni(&wire),
462            SniPeek::Found("api.localhost".to_string())
463        );
464
465        // …and then application data, as an 0-RTT client sends.
466        wire.extend_from_slice(&[0x17, 0x03, 0x03, 0x00, 0x03, 0xaa, 0xbb, 0xcc]);
467        assert_eq!(
468            parse_sni(&wire),
469            SniPeek::Found("api.localhost".to_string())
470        );
471
472        // A fragmented hello followed by the same trailing record.
473        let mut fragmented = records_of(&hello, 9);
474        fragmented.extend_from_slice(&[0x14, 0x03, 0x03, 0x00, 0x01, 0x01]);
475        assert_eq!(
476            parse_sni(&fragmented),
477            SniPeek::Found("api.localhost".to_string())
478        );
479    }
480
481    /// A truncated record header behind a complete hello is not a reason to
482    /// keep waiting: the hostname is already known.
483    #[test]
484    fn test_parse_sni_ignores_a_partial_trailing_record_header() {
485        let hello = client_hello(vec![extension(
486            EXTENSION_SERVER_NAME,
487            &server_name_ext("api.localhost"),
488        )]);
489        let mut wire = record(&hello);
490        wire.extend_from_slice(&[0x14, 0x03]);
491        assert_eq!(
492            parse_sni(&wire),
493            SniPeek::Found("api.localhost".to_string())
494        );
495    }
496
497    #[test]
498    fn test_parse_sni_rejects_non_tls() {
499        // A plain HTTP request on the TLS port, and an alert record, are both
500        // ruled out immediately rather than waited on.
501        assert_eq!(parse_sni(b"GET / HTTP/1.1\r\n"), SniPeek::NotTls);
502        assert_eq!(
503            parse_sni(&[0x15, 0x03, 0x01, 0x00, 0x02, 0x01, 0x00]),
504            SniPeek::NotTls
505        );
506        assert_eq!(parse_sni(b"G"), SniPeek::NotTls);
507    }
508
509    #[test]
510    fn test_parse_sni_rejects_non_client_hello_handshake() {
511        // A ServerHello (type 2) is not a routable client connection.
512        let mut msg = vec![0x02, 0x00, 0x00, 0x02, 0x03, 0x03];
513        msg = record(&msg);
514        assert_eq!(parse_sni(&msg), SniPeek::NotTls);
515    }
516
517    #[test]
518    fn test_parse_sni_absent_for_non_ascii_hostname() {
519        // Internationalized names travel A-label encoded; raw UTF-8 is not a
520        // routable host name.
521        let hello = client_hello(vec![extension(
522            EXTENSION_SERVER_NAME,
523            &server_name_ext("café.localhost"),
524        )]);
525        assert_eq!(parse_sni(&record(&hello)), SniPeek::Absent);
526    }
527
528    /// A name carrying control bytes is not routed on. It would otherwise
529    /// reach a routing lookup and a log line, where a newline forges an entry.
530    #[test]
531    fn test_parse_sni_absent_for_control_characters() {
532        for host in [
533            "api.localhost\n",
534            "api\r\nlocalhost",
535            "api.localhost\u{0}",
536            "api\tlocalhost",
537            "api localhost",
538            "api.localhost\u{7f}",
539            "\u{1b}[31mapi.localhost",
540        ] {
541            let hello = client_hello(vec![extension(
542                EXTENSION_SERVER_NAME,
543                &server_name_ext(host),
544            )]);
545            assert_eq!(
546                parse_sni(&record(&hello)),
547                SniPeek::Absent,
548                "{host:?} must not be routable"
549            );
550        }
551    }
552
553    /// The characters a host name is actually made of stay routable, including
554    /// the underscore that service names and some dev hostnames use.
555    #[test]
556    fn test_parse_sni_accepts_host_name_characters() {
557        for host in [
558            "api.localhost",
559            "api-2.my_project.localhost",
560            "API.LOCALHOST",
561        ] {
562            let hello = client_hello(vec![extension(
563                EXTENSION_SERVER_NAME,
564                &server_name_ext(host),
565            )]);
566            assert_eq!(
567                parse_sni(&record(&hello)),
568                SniPeek::Found(host.to_ascii_lowercase()),
569                "{host:?} must stay routable"
570            );
571        }
572    }
573
574    /// A name longer than DNS allows is refused rather than carried around.
575    #[test]
576    fn test_parse_sni_absent_for_oversized_hostname() {
577        let host = format!("{}.localhost", "a".repeat(250));
578        let hello = client_hello(vec![extension(
579            EXTENSION_SERVER_NAME,
580            &server_name_ext(&host),
581        )]);
582        assert_eq!(parse_sni(&record(&hello)), SniPeek::Absent);
583    }
584
585    #[test]
586    fn test_parse_sni_absent_for_empty_hostname() {
587        let hello = client_hello(vec![extension(EXTENSION_SERVER_NAME, &server_name_ext(""))]);
588        assert_eq!(parse_sni(&record(&hello)), SniPeek::Absent);
589    }
590
591    #[test]
592    fn test_parse_sni_skips_unknown_name_types() {
593        // An entry with an unknown NameType is skipped, and a following
594        // host_name entry still resolves.
595        let mut entries = vec![0x7f, 0x00, 0x02, 0xaa, 0xbb];
596        let host = "second.localhost";
597        entries.push(NAME_TYPE_HOST_NAME);
598        entries.extend_from_slice(&(host.len() as u16).to_be_bytes());
599        entries.extend_from_slice(host.as_bytes());
600        let mut body = (entries.len() as u16).to_be_bytes().to_vec();
601        body.extend_from_slice(&entries);
602
603        let hello = client_hello(vec![extension(EXTENSION_SERVER_NAME, &body)]);
604        assert_eq!(
605            parse_sni(&record(&hello)),
606            SniPeek::Found("second.localhost".to_string())
607        );
608    }
609
610    #[test]
611    fn test_parse_sni_does_not_panic_on_arbitrary_bytes() {
612        // Length fields are attacker-controlled; every path must return a
613        // verdict rather than index out of bounds.
614        let hello = client_hello(vec![extension(
615            EXTENSION_SERVER_NAME,
616            &server_name_ext("api.localhost"),
617        )]);
618        let wire = record(&hello);
619        for i in 0..wire.len() {
620            for corruption in [0x00u8, 0xff, 0x7f] {
621                let mut bad = wire.clone();
622                bad[i] = corruption;
623                let _ = parse_sni(&bad);
624            }
625        }
626    }
627}