Skip to main content

hns_browser_loopback_proxy/chromium/
response.rs

1use std::collections::HashSet;
2use std::fmt;
3use thiserror::Error;
4
5const MAX_RESPONSE_HEADERS: usize = 256;
6const MAX_RESPONSE_HEAD_BYTES: usize = 64 * 1024;
7
8#[derive(Clone, Default, Eq, PartialEq)]
9pub struct InternalResponseMetadata {
10    headers: Vec<(String, String)>,
11}
12
13impl fmt::Debug for InternalResponseMetadata {
14    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
15        formatter
16            .debug_struct("InternalResponseMetadata")
17            .field("header_count", &self.headers.len())
18            .finish()
19    }
20}
21
22impl InternalResponseMetadata {
23    pub fn get(&self, name: &str) -> Option<&str> {
24        self.headers
25            .iter()
26            .rev()
27            .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
28            .map(|(_, value)| value.as_str())
29    }
30
31    pub fn is_empty(&self) -> bool {
32        self.headers.is_empty()
33    }
34}
35
36#[derive(Clone, Eq, PartialEq)]
37pub struct SanitizedResponseHeaders {
38    forwarded: Vec<(String, String)>,
39    metadata: InternalResponseMetadata,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct EncodedResponseHead {
44    bytes: Vec<u8>,
45    body_allowed: bool,
46}
47
48impl EncodedResponseHead {
49    pub fn as_bytes(&self) -> &[u8] {
50        &self.bytes
51    }
52
53    pub fn into_bytes(self) -> Vec<u8> {
54        self.bytes
55    }
56
57    pub fn body_allowed(&self) -> bool {
58        self.body_allowed
59    }
60}
61
62impl fmt::Debug for SanitizedResponseHeaders {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        formatter
65            .debug_struct("SanitizedResponseHeaders")
66            .field("forwarded_count", &self.forwarded.len())
67            .field("metadata", &self.metadata)
68            .finish()
69    }
70}
71
72impl SanitizedResponseHeaders {
73    pub fn forwarded(&self) -> &[(String, String)] {
74        &self.forwarded
75    }
76
77    pub fn metadata(&self) -> &InternalResponseMetadata {
78        &self.metadata
79    }
80}
81
82#[derive(Clone, Debug, Error, Eq, PartialEq)]
83pub enum ResponseError {
84    #[error("invalid HTTP response status")]
85    InvalidStatus,
86    #[error("invalid HTTP response reason")]
87    InvalidReason,
88    #[error("invalid HTTP response header")]
89    InvalidHeader,
90    #[error("HTTP response headers exceed proxy limits")]
91    HeadersTooLarge,
92    #[error("HTTP response status does not permit a message body")]
93    BodyNotAllowed,
94}
95
96pub fn sanitize_response_headers(
97    headers: &[(String, String)],
98) -> Result<SanitizedResponseHeaders, ResponseError> {
99    let connection_fields = validate_response_headers(headers)?;
100
101    let mut forwarded = Vec::with_capacity(headers.len());
102    let mut metadata = Vec::new();
103    let mut metadata_names = HashSet::new();
104    for (name, value) in headers {
105        let lower = name.to_ascii_lowercase();
106        if connection_fields.contains(&lower) {
107            continue;
108        }
109        if is_internal_header(name) {
110            if is_trusted_metadata_header(&lower) {
111                if !metadata_names.insert(lower) {
112                    return Err(ResponseError::InvalidHeader);
113                }
114                metadata.push((name.clone(), value.clone()));
115            }
116            continue;
117        }
118        if is_proxy_managed_response_header(&lower) {
119            continue;
120        }
121        forwarded.push((name.clone(), value.clone()));
122    }
123    Ok(SanitizedResponseHeaders {
124        forwarded,
125        metadata: InternalResponseMetadata { headers: metadata },
126    })
127}
128
129pub fn encode_response_head(
130    request_method: &str,
131    status: u16,
132    reason: &str,
133    headers: &SanitizedResponseHeaders,
134    body_len: u64,
135) -> Result<EncodedResponseHead, ResponseError> {
136    if !(200..=599).contains(&status) {
137        return Err(ResponseError::InvalidStatus);
138    }
139    validate_reason(reason)?;
140    let status_forbids_body = matches!(status, 204 | 205 | 304);
141    if status_forbids_body && body_len != 0 {
142        return Err(ResponseError::BodyNotAllowed);
143    }
144    let body_allowed = !request_method.eq_ignore_ascii_case("HEAD") && !status_forbids_body;
145    let mut encoded = format!("HTTP/1.1 {status} {reason}\r\n").into_bytes();
146    for (name, value) in headers.forwarded() {
147        encoded.extend_from_slice(name.as_bytes());
148        encoded.extend_from_slice(b": ");
149        encoded.extend_from_slice(value.as_bytes());
150        encoded.extend_from_slice(b"\r\n");
151    }
152    encoded.extend_from_slice(b"Connection: close\r\n");
153    if !status_forbids_body {
154        encoded.extend_from_slice(b"Content-Length: ");
155        encoded.extend_from_slice(body_len.to_string().as_bytes());
156        encoded.extend_from_slice(b"\r\n");
157    }
158    encoded.extend_from_slice(b"\r\n");
159    if encoded.len() > MAX_RESPONSE_HEAD_BYTES {
160        return Err(ResponseError::HeadersTooLarge);
161    }
162    Ok(EncodedResponseHead {
163        bytes: encoded,
164        body_allowed,
165    })
166}
167
168/// Validates and reconstructs a WebSocket Upgrade response. Origin-controlled
169/// framing, proxy, internal, and connection-nominated fields are never copied;
170/// the two mandatory hop-by-hop fields are emitted canonically by the proxy.
171pub fn encode_upgrade_response_head(
172    status: u16,
173    reason: &str,
174    headers: &[(String, String)],
175) -> Result<EncodedResponseHead, ResponseError> {
176    if status != 101 {
177        return Err(ResponseError::InvalidStatus);
178    }
179    validate_reason(reason)?;
180    let connection_fields = validate_response_headers(headers)?;
181    let connection_upgrade = headers.iter().any(|(name, value)| {
182        name.eq_ignore_ascii_case("connection") && has_header_token(value, "upgrade")
183    });
184    let upgrade_values: Vec<_> = headers
185        .iter()
186        .filter(|(name, _)| name.eq_ignore_ascii_case("upgrade"))
187        .map(|(_, value)| value.as_str())
188        .collect();
189    if !connection_upgrade
190        || upgrade_values.len() != 1
191        || !upgrade_values[0].eq_ignore_ascii_case("websocket")
192    {
193        return Err(ResponseError::InvalidHeader);
194    }
195
196    let mut encoded = format!("HTTP/1.1 {status} {reason}\r\n").into_bytes();
197    for (name, value) in headers {
198        let lower = name.to_ascii_lowercase();
199        if connection_fields.contains(&lower)
200            || is_internal_header(name)
201            || is_proxy_managed_response_header(&lower)
202        {
203            continue;
204        }
205        encoded.extend_from_slice(name.as_bytes());
206        encoded.extend_from_slice(b": ");
207        encoded.extend_from_slice(value.as_bytes());
208        encoded.extend_from_slice(b"\r\n");
209    }
210    encoded.extend_from_slice(b"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n");
211    if encoded.len() > MAX_RESPONSE_HEAD_BYTES {
212        return Err(ResponseError::HeadersTooLarge);
213    }
214    Ok(EncodedResponseHead {
215        bytes: encoded,
216        body_allowed: false,
217    })
218}
219
220fn validate_response_headers(
221    headers: &[(String, String)],
222) -> Result<HashSet<String>, ResponseError> {
223    if headers.len() > MAX_RESPONSE_HEADERS {
224        return Err(ResponseError::HeadersTooLarge);
225    }
226    let mut head_bytes = 0usize;
227    let mut connection_fields = HashSet::new();
228    for (name, value) in headers {
229        if !is_http_token(name) || !is_http_field_value(value) {
230            return Err(ResponseError::InvalidHeader);
231        }
232        head_bytes = head_bytes
233            .checked_add(name.len())
234            .and_then(|total| total.checked_add(value.len()))
235            .and_then(|total| total.checked_add(4))
236            .ok_or(ResponseError::HeadersTooLarge)?;
237        if name.eq_ignore_ascii_case("connection") {
238            for token in value.split(',').map(str::trim) {
239                if !is_http_token(token) {
240                    return Err(ResponseError::InvalidHeader);
241                }
242                connection_fields.insert(token.to_ascii_lowercase());
243            }
244        }
245    }
246    if head_bytes > MAX_RESPONSE_HEAD_BYTES {
247        return Err(ResponseError::HeadersTooLarge);
248    }
249    Ok(connection_fields)
250}
251
252fn validate_reason(reason: &str) -> Result<(), ResponseError> {
253    if reason.len() > MAX_RESPONSE_HEAD_BYTES {
254        return Err(ResponseError::HeadersTooLarge);
255    }
256    if reason.is_empty()
257        || reason
258            .bytes()
259            .any(|byte| byte < b' ' || byte == 0x7f || byte >= 0x80)
260    {
261        return Err(ResponseError::InvalidReason);
262    }
263    Ok(())
264}
265
266fn has_header_token(value: &str, expected: &str) -> bool {
267    value
268        .split(',')
269        .map(str::trim)
270        .any(|token| token.eq_ignore_ascii_case(expected))
271}
272
273fn is_internal_header(name: &str) -> bool {
274    name.get(..6)
275        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("X-HNS-"))
276}
277
278fn is_trusted_metadata_header(lower_name: &str) -> bool {
279    matches!(
280        lower_name,
281        "x-hns-doh-fallback"
282            | "x-hns-resolution-trace"
283            | "x-hns-resolver-mode"
284            | "x-hns-resolver-policy"
285            | "x-hns-security-path"
286            | "x-hns-tls-policy"
287    )
288}
289
290fn is_proxy_managed_response_header(lower_name: &str) -> bool {
291    lower_name.starts_with("proxy-")
292        || matches!(
293            lower_name,
294            "connection"
295                | "alt-svc"
296                | "content-length"
297                | "keep-alive"
298                | "te"
299                | "trailer"
300                | "transfer-encoding"
301                | "upgrade"
302        )
303}
304
305fn is_http_token(value: &str) -> bool {
306    !value.is_empty()
307        && value.bytes().all(|byte| {
308            byte.is_ascii_alphanumeric()
309                || matches!(
310                    byte,
311                    b'!' | b'#'
312                        | b'$'
313                        | b'%'
314                        | b'&'
315                        | b'\''
316                        | b'*'
317                        | b'+'
318                        | b'-'
319                        | b'.'
320                        | b'^'
321                        | b'_'
322                        | b'`'
323                        | b'|'
324                        | b'~'
325                )
326        })
327}
328
329fn is_http_field_value(value: &str) -> bool {
330    value
331        .bytes()
332        .all(|byte| byte == b'\t' || (byte >= b' ' && byte != 0x7f))
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn strips_every_internal_and_hop_by_hop_response_header() {
341        let headers = vec![
342            ("Content-Type".to_owned(), "text/plain".to_owned()),
343            ("X-HNS-Security-Path".to_owned(), "dane".to_owned()),
344            ("x-hns-future-metadata".to_owned(), "secret".to_owned()),
345            (
346                "Connection".to_owned(),
347                "Keep-Alive, X-Origin-Hop".to_owned(),
348            ),
349            ("X-Origin-Hop".to_owned(), "remove".to_owned()),
350            ("Transfer-Encoding".to_owned(), "chunked".to_owned()),
351            ("Content-Length".to_owned(), "999".to_owned()),
352            ("Proxy-Future".to_owned(), "secret".to_owned()),
353        ];
354
355        let sanitized = sanitize_response_headers(&headers).unwrap();
356
357        assert_eq!(
358            sanitized.forwarded(),
359            &[("Content-Type".to_owned(), "text/plain".to_owned())]
360        );
361        assert_eq!(
362            sanitized.metadata().get("X-HNS-Security-Path"),
363            Some("dane")
364        );
365        assert_eq!(sanitized.metadata().get("X-HNS-Future-Metadata"), None);
366    }
367
368    #[test]
369    fn rejects_header_injection_even_for_internal_metadata() {
370        let error = sanitize_response_headers(&[(
371            "X-HNS-Trace".to_owned(),
372            "safe\r\nSet-Cookie: injected=1".to_owned(),
373        )])
374        .unwrap_err();
375
376        assert_eq!(error, ResponseError::InvalidHeader);
377    }
378
379    #[test]
380    fn response_head_owns_framing_and_connection_lifecycle() {
381        let headers = sanitize_response_headers(&[(
382            "Content-Type".to_owned(),
383            "application/octet-stream".to_owned(),
384        )])
385        .unwrap();
386
387        let encoded = encode_response_head("GET", 206, "Partial Content", &headers, 42).unwrap();
388        assert!(encoded.body_allowed());
389        let text = String::from_utf8(encoded.into_bytes()).unwrap();
390
391        assert!(text.starts_with("HTTP/1.1 206 Partial Content\r\n"));
392        assert!(text.contains("Content-Type: application/octet-stream\r\n"));
393        assert!(text.contains("Connection: close\r\nContent-Length: 42\r\n\r\n"));
394    }
395
396    #[test]
397    fn rejects_informational_finals_and_nonempty_bodyless_statuses() {
398        let headers = sanitize_response_headers(&[]).unwrap();
399
400        assert_eq!(
401            encode_response_head("GET", 101, "Switching Protocols", &headers, 0),
402            Err(ResponseError::InvalidStatus)
403        );
404        assert_eq!(
405            encode_response_head("GET", 204, "No Content", &headers, 1),
406            Err(ResponseError::BodyNotAllowed)
407        );
408        let head = encode_response_head("HEAD", 200, "OK", &headers, 12).unwrap();
409        assert!(!head.body_allowed());
410        assert!(
411            String::from_utf8(head.into_bytes())
412                .unwrap()
413                .contains("Content-Length: 12\r\n")
414        );
415        assert_eq!(
416            encode_response_head(
417                "GET",
418                200,
419                &"x".repeat(MAX_RESPONSE_HEAD_BYTES + 1),
420                &headers,
421                0,
422            ),
423            Err(ResponseError::HeadersTooLarge)
424        );
425    }
426
427    #[test]
428    fn rejects_duplicate_trusted_metadata_and_discards_connection_nominated_metadata() {
429        let duplicate = vec![
430            ("X-HNS-TLS-Policy".to_owned(), "dane".to_owned()),
431            ("x-hns-tls-policy".to_owned(), "webpki".to_owned()),
432        ];
433        assert_eq!(
434            sanitize_response_headers(&duplicate),
435            Err(ResponseError::InvalidHeader)
436        );
437
438        let nominated = vec![
439            ("Connection".to_owned(), "X-HNS-Security-Path".to_owned()),
440            ("X-HNS-Security-Path".to_owned(), "spoofed".to_owned()),
441        ];
442        let sanitized = sanitize_response_headers(&nominated).unwrap();
443        assert!(sanitized.metadata().is_empty());
444    }
445
446    #[test]
447    fn upgrade_head_is_canonical_and_strips_internal_and_nominated_fields() {
448        let headers = vec![
449            (
450                "Connection".to_owned(),
451                "keep-alive, Upgrade, X-Hop".to_owned(),
452            ),
453            ("Upgrade".to_owned(), "websocket".to_owned()),
454            ("Sec-WebSocket-Accept".to_owned(), "accepted".to_owned()),
455            ("X-Hop".to_owned(), "secret".to_owned()),
456            ("X-HNS-Security-Path".to_owned(), "dane".to_owned()),
457            ("Alt-Svc".to_owned(), "h3=\":443\"".to_owned()),
458            ("Content-Length".to_owned(), "999".to_owned()),
459        ];
460
461        let encoded = encode_upgrade_response_head(101, "Switching Protocols", &headers).unwrap();
462        let text = String::from_utf8(encoded.into_bytes()).unwrap();
463
464        assert!(text.starts_with("HTTP/1.1 101 Switching Protocols\r\n"));
465        assert!(text.contains("Sec-WebSocket-Accept: accepted\r\n"));
466        assert!(text.ends_with("Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n"));
467        assert!(!text.contains("keep-alive"));
468        assert!(!text.contains("X-Hop"));
469        assert!(!text.contains("X-HNS-"));
470        assert!(!text.contains("Alt-Svc"));
471        assert!(!text.contains("Content-Length"));
472    }
473
474    #[test]
475    fn upgrade_head_requires_exact_websocket_switching_protocols() {
476        let valid = vec![
477            ("Connection".to_owned(), "Upgrade".to_owned()),
478            ("Upgrade".to_owned(), "websocket".to_owned()),
479        ];
480        assert_eq!(
481            encode_upgrade_response_head(200, "OK", &valid),
482            Err(ResponseError::InvalidStatus)
483        );
484        assert_eq!(
485            encode_upgrade_response_head(
486                101,
487                "Switching Protocols",
488                &[("Upgrade".to_owned(), "websocket".to_owned())],
489            ),
490            Err(ResponseError::InvalidHeader)
491        );
492        assert_eq!(
493            encode_upgrade_response_head(
494                101,
495                "Switching Protocols",
496                &[
497                    ("Connection".to_owned(), "Upgrade".to_owned()),
498                    ("Upgrade".to_owned(), "h2c".to_owned()),
499                ],
500            ),
501            Err(ResponseError::InvalidHeader)
502        );
503    }
504}