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
168pub 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}