Skip to main content

cellos_supervisor/sni_proxy/h2/hpack/
mod.rs

1//! HPACK decoder — stateful per-connection.
2//!
3//! Phase 3g extends P3c's stateless one-shot decoder to a full HPACK
4//! decoder with:
5//!
6//! 1. Dynamic table state (RFC 7541 §4.1, §4.2, §4.4).
7//! 2. Huffman literal decoding (RFC 7541 §5.2 + Appendix B).
8//! 3. CONTINUATION-fragmented header block reassembly (driven by the
9//!    parent `h2` module — the decoder itself works on already-reassembled
10//!    blocks).
11//!
12//! The public surface is a single struct [`HpackDecoder`] whose
13//! [`HpackDecoder::decode_block`] consumes a whole header-block fragment
14//! (the concatenation of HEADERS + 0..n CONTINUATION payloads, padding +
15//! priority already stripped by the frame layer) and returns the
16//! `:authority` if one was found.
17
18pub mod dynamic_table;
19pub mod huffman;
20pub mod integer;
21pub mod static_table;
22pub mod string;
23
24use super::error::H2ParseError;
25use dynamic_table::DynamicTable;
26use integer::decode_integer;
27use static_table::{STATIC_INDEX_AUTHORITY, STATIC_TABLE_MAX};
28use string::decode_string;
29
30/// RFC 7540 §6.5.2 default `SETTINGS_HEADER_TABLE_SIZE`.
31pub const DEFAULT_HEADER_TABLE_SIZE: usize = 4096;
32
33/// Stateful HPACK decoder with a per-connection dynamic table.
34///
35/// The same instance is fed all header-block fragments seen on a single
36/// h2c connection — both intra-frame (CONTINUATION reassembly) AND
37/// inter-stream (different request HEADERS on the same connection share
38/// the same dynamic table per RFC 7541 §2.3.1).
39pub struct HpackDecoder {
40    dynamic_table: DynamicTable,
41}
42
43impl Default for HpackDecoder {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl HpackDecoder {
50    /// Construct a fresh decoder with the RFC 7540 default table size.
51    pub fn new() -> Self {
52        Self {
53            // Cannot fail: DEFAULT_HEADER_TABLE_SIZE < MAX_TABLE_SIZE.
54            dynamic_table: DynamicTable::new(DEFAULT_HEADER_TABLE_SIZE)
55                .expect("default table size is within bound"),
56        }
57    }
58
59    /// Reset dynamic table to empty (e.g. on connection close — caller
60    /// dropping the decoder is equivalent).
61    #[allow(dead_code)] // the proxy drops the decoder per connection; provided for API completeness
62    pub fn reset(&mut self) {
63        self.dynamic_table = DynamicTable::new(DEFAULT_HEADER_TABLE_SIZE)
64            .expect("default table size is within bound");
65    }
66
67    /// Decode a single header block fragment. Returns the first
68    /// `:authority` value found in the block; `Ok(None)` if the block
69    /// parsed cleanly but contained no `:authority` (caller emits
70    /// `l7_h2_authority_missing`).
71    ///
72    /// The decoder also returns metadata about *how* the authority was
73    /// extracted so the caller can pick the right reason code:
74    /// [`AuthorityProvenance`].
75    pub fn decode_block(&mut self, block: &[u8]) -> Result<Option<DecodedAuthority>, H2ParseError> {
76        let mut cursor = block;
77        let mut found: Option<DecodedAuthority> = None;
78
79        while !cursor.is_empty() {
80            let first = cursor[0];
81
82            if first & 0b1000_0000 != 0 {
83                // 1xxxxxxx — Indexed Header Field (RFC 7541 §6.1).
84                let (index, rest) = decode_integer(cursor, 7)?;
85                cursor = rest;
86                let (name, value) = self
87                    .dynamic_table
88                    .lookup(index)
89                    .ok_or(H2ParseError::HpackInvalidIndex { index })?;
90                if found.is_none() && eq_authority(name.as_bytes()) && !value.is_empty() {
91                    let provenance = if index <= STATIC_TABLE_MAX {
92                        // Static index 1's value is empty (handled above);
93                        // any other static index that matches `:authority`
94                        // by name doesn't exist (only row 1 names it). So
95                        // a non-empty value here means the encoder used a
96                        // name-only static reference + value — but
97                        // Indexed-Header-Field carries no separate value.
98                        // Practically unreachable; classify as static.
99                        AuthorityProvenance::StaticIndexed
100                    } else {
101                        AuthorityProvenance::DynamicIndexed
102                    };
103                    found = Some(DecodedAuthority {
104                        value: normalise_authority(value.as_bytes()),
105                        provenance,
106                    });
107                }
108                continue;
109            }
110
111            if first & 0b1100_0000 == 0b0100_0000 {
112                // 01xxxxxx — Literal Header Field with Incremental Indexing
113                // (RFC 7541 §6.2.1). Adds an entry to the dynamic table.
114                let (name, value, name_index, value_was_huffman, after) =
115                    self.parse_literal(cursor, 6)?;
116                cursor = after;
117                let is_authority = if name_index == 0 {
118                    eq_authority(name.as_bytes())
119                } else {
120                    name_index == STATIC_INDEX_AUTHORITY
121                        || self
122                            .dynamic_table
123                            .lookup(name_index)
124                            .map(|(n, _)| eq_authority(n.as_bytes()))
125                            .unwrap_or(false)
126                };
127                if found.is_none() && is_authority && !value.is_empty() {
128                    let provenance = match (name_index, value_was_huffman) {
129                        (_, true) => AuthorityProvenance::Huffman,
130                        (0, false) => AuthorityProvenance::StaticLiteral,
131                        (i, false) if i <= STATIC_TABLE_MAX => AuthorityProvenance::StaticLiteral,
132                        (_, false) => AuthorityProvenance::DynamicIndexed,
133                    };
134                    found = Some(DecodedAuthority {
135                        value: normalise_authority(value.as_bytes()),
136                        provenance,
137                    });
138                }
139                // Insert into dynamic table per §6.2.1.
140                let stored_name = if name_index == 0 {
141                    name
142                } else {
143                    self.dynamic_table
144                        .lookup(name_index)
145                        .ok_or(H2ParseError::HpackInvalidIndex { index: name_index })?
146                        .0
147                        .to_string()
148                };
149                self.dynamic_table.insert(stored_name, value);
150                continue;
151            }
152
153            if first & 0b1111_0000 == 0b0000_0000 || first & 0b1111_0000 == 0b0001_0000 {
154                // 0000xxxx — Literal Header Field without Indexing (§6.2.2)
155                // 0001xxxx — Literal Header Field Never Indexed     (§6.2.3)
156                let (name, value, name_index, value_was_huffman, after) =
157                    self.parse_literal(cursor, 4)?;
158                cursor = after;
159                let is_authority = if name_index == 0 {
160                    eq_authority(name.as_bytes())
161                } else {
162                    name_index == STATIC_INDEX_AUTHORITY
163                        || self
164                            .dynamic_table
165                            .lookup(name_index)
166                            .map(|(n, _)| eq_authority(n.as_bytes()))
167                            .unwrap_or(false)
168                };
169                if found.is_none() && is_authority && !value.is_empty() {
170                    let provenance = if value_was_huffman {
171                        AuthorityProvenance::Huffman
172                    } else if name_index == 0 || name_index <= STATIC_TABLE_MAX {
173                        AuthorityProvenance::StaticLiteral
174                    } else {
175                        AuthorityProvenance::DynamicIndexed
176                    };
177                    found = Some(DecodedAuthority {
178                        value: normalise_authority(value.as_bytes()),
179                        provenance,
180                    });
181                }
182                // No table mutation for §6.2.2 / §6.2.3.
183                continue;
184            }
185
186            if first & 0b1110_0000 == 0b0010_0000 {
187                // 001xxxxx — Dynamic Table Size Update (RFC 7541 §6.3).
188                let (new_max, rest) = decode_integer(cursor, 5)?;
189                cursor = rest;
190                let new_max_usize =
191                    usize::try_from(new_max).map_err(|_| H2ParseError::MalformedHeaders)?;
192                self.dynamic_table.update_max_size(new_max_usize)?;
193                continue;
194            }
195
196            // Unrecognised top-bit pattern.
197            return Err(H2ParseError::MalformedHeaders);
198        }
199
200        Ok(found)
201    }
202
203    /// Parse a literal header field (representation classes §6.2.1, §6.2.2,
204    /// §6.2.3). `prefix_bits` is 6 (incremental-indexing) or 4 (no-indexing
205    /// / never-indexed). Returns the literal name (string — empty if
206    /// referenced by index), value, name-reference index (0 = literal),
207    /// whether the value was Huffman-coded, and the slice after.
208    fn parse_literal<'a>(
209        &mut self,
210        buf: &'a [u8],
211        prefix_bits: u32,
212    ) -> Result<(String, String, u64, bool, &'a [u8]), H2ParseError> {
213        let (name_index, rest) = decode_integer(buf, prefix_bits)?;
214        let (name, after_name) = if name_index == 0 {
215            let (n, r) = decode_string(rest)?;
216            (n, r)
217        } else {
218            (String::new(), rest)
219        };
220        let value_was_huffman = !after_name.is_empty() && (after_name[0] & 0x80) != 0;
221        let (value, after_value) = decode_string(after_name)?;
222        Ok((name, value, name_index, value_was_huffman, after_value))
223    }
224}
225
226/// Where the `:authority` value came from. Drives the proxy's reason-code
227/// selection so the audit trail differentiates static-table refs (P3c
228/// behaviour, attacker simplicity), dynamic-table refs (Phase 3g — adversary
229/// must establish prior context), and Huffman literals (Phase 3g — adversary
230/// uses encoder-side compression).
231#[derive(Debug, PartialEq, Eq, Clone, Copy)]
232pub enum AuthorityProvenance {
233    /// Decoded from a static-table index reference (HPACK indexed-header
234    /// representation pointing at index ≤ 61). Same path P3c covered.
235    StaticIndexed,
236    /// Decoded from a literal whose value was a raw (non-Huffman) octet
237    /// string AND whose name was either literal or referenced the static
238    /// table. Same path P3c covered.
239    StaticLiteral,
240    /// Decoded from a dynamic-table indexed reference (HPACK index ≥ 62)
241    /// OR a literal whose name pointed at the dynamic table. Phase 3g
242    /// extension — required prior incremental-indexing entries to populate
243    /// the dynamic table.
244    DynamicIndexed,
245    /// Decoded from a literal whose value was Huffman-coded. Phase 3g
246    /// extension — required RFC 7541 Appendix B static Huffman code.
247    Huffman,
248}
249
250/// `:authority` extracted from a header block, with provenance metadata.
251#[derive(Debug, Clone)]
252pub struct DecodedAuthority {
253    pub value: String,
254    pub provenance: AuthorityProvenance,
255}
256
257/// Case-insensitive `:authority` match.
258fn eq_authority(name: &[u8]) -> bool {
259    if name.len() != b":authority".len() {
260        return false;
261    }
262    name.iter()
263        .zip(b":authority".iter())
264        .all(|(a, b)| a.eq_ignore_ascii_case(b))
265}
266
267/// Produce the canonical RFC 3986 host form of an `:authority` value:
268/// strip the optional `:port` suffix (preserving IPv6 brackets), strip a
269/// trailing dot, and lowercase. Mirrors `http::normalise_host_value` so
270/// the allowlist matcher sees the same shape across protocols.
271pub(crate) fn normalise_authority(raw: &[u8]) -> String {
272    let trimmed = trim_ascii(raw);
273    let host = if trimmed.first() == Some(&b'[') {
274        if let Some(close) = trimmed.iter().position(|&b| b == b']') {
275            &trimmed[..=close]
276        } else {
277            trimmed
278        }
279    } else if let Some(colon) = trimmed.iter().position(|&b| b == b':') {
280        &trimmed[..colon]
281    } else {
282        trimmed
283    };
284    let mut s = String::from_utf8_lossy(host).to_string();
285    s.make_ascii_lowercase();
286    if s.ends_with('.') {
287        s.pop();
288    }
289    s
290}
291
292fn trim_ascii(s: &[u8]) -> &[u8] {
293    let mut start = 0;
294    while start < s.len() && (s[start] == b' ' || s[start] == b'\t') {
295        start += 1;
296    }
297    let mut end = s.len();
298    while end > start && (s[end - 1] == b' ' || s[end - 1] == b'\t') {
299        end -= 1;
300    }
301    &s[start..end]
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn lit_indexed_name(name_index: u8, value: &str) -> Vec<u8> {
309        let mut out = Vec::new();
310        out.push(0x40 | (name_index & 0x3F));
311        out.push(value.len() as u8); // 7-bit length, no Huffman
312        out.extend_from_slice(value.as_bytes());
313        out
314    }
315
316    fn lit_indexed_name_huffman(name_index: u8, value: &str) -> Vec<u8> {
317        let mut out = Vec::new();
318        out.push(0x40 | (name_index & 0x3F));
319        let payload = huffman::encode(value);
320        assert!(payload.len() < 0x7F);
321        out.push(0x80 | payload.len() as u8);
322        out.extend_from_slice(&payload);
323        out
324    }
325
326    #[test]
327    fn extracts_authority_via_static_literal() {
328        let mut d = HpackDecoder::new();
329        let block = lit_indexed_name(1, "api.example.com");
330        let result = d.decode_block(&block).unwrap().unwrap();
331        assert_eq!(result.value, "api.example.com");
332        assert_eq!(result.provenance, AuthorityProvenance::StaticLiteral);
333        // The literal-with-incremental-indexing path adds to the dynamic table.
334        assert_eq!(d.dynamic_table.entry_count(), 1);
335    }
336
337    #[test]
338    fn extracts_authority_via_huffman_literal() {
339        let mut d = HpackDecoder::new();
340        let block = lit_indexed_name_huffman(1, "api.example.com");
341        let result = d.decode_block(&block).unwrap().unwrap();
342        assert_eq!(result.value, "api.example.com");
343        assert_eq!(result.provenance, AuthorityProvenance::Huffman);
344    }
345
346    #[test]
347    fn extracts_authority_via_dynamic_table_reference() {
348        let mut d = HpackDecoder::new();
349        // First block: incremental-index :authority = "api.example.com".
350        // The decoder also returns the authority from this block.
351        let block1 = lit_indexed_name(1, "api.example.com");
352        let r1 = d.decode_block(&block1).unwrap().unwrap();
353        assert_eq!(r1.value, "api.example.com");
354        // Second block: indexed-header-field at dynamic index 62 (the
355        // entry we just added).
356        let block2 = vec![0x80 | 62];
357        let r2 = d.decode_block(&block2).unwrap().unwrap();
358        assert_eq!(r2.value, "api.example.com");
359        assert_eq!(r2.provenance, AuthorityProvenance::DynamicIndexed);
360    }
361
362    #[test]
363    fn rejects_invalid_dynamic_index() {
364        let mut d = HpackDecoder::new();
365        // Indexed-header at dynamic index 62 with empty dynamic table.
366        let block = vec![0x80 | 62];
367        let err = d.decode_block(&block).unwrap_err();
368        assert!(matches!(err, H2ParseError::HpackInvalidIndex { .. }));
369    }
370
371    #[test]
372    fn dynamic_table_size_update_is_honoured() {
373        let mut d = HpackDecoder::new();
374        // Update to 0 → table cleared.
375        let block = vec![0x20]; // 001 00000 → size 0
376        d.decode_block(&block).unwrap();
377        assert_eq!(d.dynamic_table.max_size(), 0);
378    }
379
380    #[test]
381    fn block_with_no_authority_returns_none() {
382        let mut d = HpackDecoder::new();
383        // Indexed-header-field at static index 2 (`:method GET`) — not
384        // :authority.
385        let block = vec![0x80 | 2];
386        let result = d.decode_block(&block).unwrap();
387        assert!(result.is_none());
388    }
389
390    #[test]
391    fn literal_with_incremental_indexing_persists_across_blocks() {
392        let mut d = HpackDecoder::new();
393        // Add entry via incremental indexing — name is literal "x-custom".
394        let mut block1 = Vec::new();
395        block1.push(0x40); // 01000000 — name literal follows
396        block1.push(8);
397        block1.extend_from_slice(b"x-custom");
398        block1.push(5);
399        block1.extend_from_slice(b"value");
400        d.decode_block(&block1).unwrap();
401        assert_eq!(d.dynamic_table.entry_count(), 1);
402        // Reference it as dynamic index 62.
403        let (name, value) = d.dynamic_table.lookup(62).unwrap();
404        assert_eq!(name, "x-custom");
405        assert_eq!(value, "value");
406    }
407}