1use std::borrow::Cow;
11
12mod body;
13mod diagnostics;
14mod headers;
15mod url_rules;
16
17use http::{
18 HeaderMap,
19 HeaderValue,
20};
21use url::Url;
22
23use crate::policy::OutputCharge;
24use crate::{
25 LogSafeText,
26 RedactionPolicy,
27 RedactionSession,
28 Sensitivity,
29};
30
31use super::{
32 BodyBudget,
33 BodyCapture,
34 BodyRedaction,
35 BodyRedactionReason,
36 BodyRedactionStatus,
37 FieldRedactor,
38 RedactedHeaders,
39 TextBodyPolicy,
40 UrlPathPolicy,
41 internal::{
42 BoundedLogWriter,
43 ParsedBody,
44 content_type,
45 diagnostic_text,
46 form,
47 json,
48 markers,
49 multipart,
50 nested_url::{
51 self,
52 NestedUrl,
53 },
54 },
55};
56
57#[must_use = "use the redactor to produce safe HTTP diagnostics"]
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HttpRedactor {
61 policy: RedactionPolicy,
63}
64
65impl HttpRedactor {
66 #[inline]
76 pub fn new(policy: RedactionPolicy) -> Self {
77 Self { policy }
78 }
79
80 #[inline]
85 pub fn strict() -> Self {
86 Self::new(RedactionPolicy::strict())
87 }
88
89 #[inline(always)]
95 pub const fn policy(&self) -> &RedactionPolicy {
96 &self.policy
97 }
98
99 fn header_field_redactor(&self) -> FieldRedactor<'_> {
101 FieldRedactor::new(
102 self.policy.rules(),
103 self.policy.header_rules(),
104 self.policy.masking(),
105 )
106 }
107
108 fn query_field_redactor(&self) -> FieldRedactor<'_> {
110 FieldRedactor::new(
111 self.policy.rules(),
112 self.policy.query_rules(),
113 self.policy.masking(),
114 )
115 }
116
117 fn body_field_redactor(&self) -> FieldRedactor<'_> {
120 FieldRedactor::new(
121 self.policy.rules(),
122 self.policy.body_rules(),
123 self.policy.masking(),
124 )
125 }
126
127 #[inline]
143 pub fn redact_url(&self, url: &Url) -> LogSafeText<'static> {
144 if self.diagnostic_input_exceeded(url.as_str().len()) {
145 return Self::diagnostic_limit_exceeded();
146 }
147 self.finish_diagnostic(self.redact_url_text(url))
148 }
149
150 #[inline]
164 pub fn redact_urls_in_text(&self, text: &str) -> LogSafeText<'static> {
165 if self.diagnostic_input_exceeded(text.len()) {
166 return Self::diagnostic_limit_exceeded();
167 }
168 let redacted =
169 diagnostic_text::redact(text, |url| self.redact_url_text(url));
170 self.finish_diagnostic(redacted)
171 }
172
173 #[inline]
183 pub fn redact_url_str(&self, input: &str) -> LogSafeText<'static> {
184 if self.diagnostic_input_exceeded(input.len()) {
185 return Self::diagnostic_limit_exceeded();
186 }
187 Url::parse(input).map_or_else(
188 |_| self.finish_diagnostic(markers::INVALID_URL.to_string()),
189 |url| self.finish_diagnostic(self.redact_url_text(&url)),
190 )
191 }
192
193 #[inline]
203 pub fn redact_form(&self, input: &str) -> LogSafeText<'static> {
204 if self.diagnostic_input_exceeded(input.len()) {
205 return Self::diagnostic_limit_exceeded();
206 }
207 let output_limit =
208 self.policy.limits().diagnostic_event().max_output_bytes();
209 let text = if form::is_valid(input.as_bytes()) {
210 form::redact_bounded(
211 &FieldRedactor::new(
212 self.policy.rules(),
213 self.policy.query_rules(),
214 self.policy.masking(),
215 ),
216 input.as_bytes(),
217 output_limit,
218 )
219 } else {
220 markers::INVALID_FORM.to_string()
221 };
222 self.finish_diagnostic(text)
223 }
224
225 pub fn redact_headers(&self, headers: &HeaderMap) -> RedactedHeaders {
238 if !self.headers_fit_input_budget(headers) {
239 return RedactedHeaders::new(Self::diagnostic_limit_exceeded());
240 }
241
242 let mut writer = BoundedLogWriter::new(
243 self.policy.limits().diagnostic_event().max_output_bytes(),
244 false,
245 );
246 let values = headers::group_values(headers);
247 self.write_grouped_headers(&mut writer, values);
248 let (rendered, _) = writer.finish();
249 RedactedHeaders::new(LogSafeText::from_escaped(Cow::Owned(rendered)))
250 }
251
252 pub fn redact_body(
267 &self,
268 capture: BodyCapture<'_>,
269 content_type: Option<&HeaderValue>,
270 ) -> BodyRedaction {
271 let content_type_limit =
272 self.policy.limits().diagnostic_event().max_input_bytes();
273 let (content_type, invalid_content_type) = match content_type {
274 Some(value) if value.as_bytes().len() > content_type_limit => {
275 (None, true)
276 }
277 Some(value) => match value.to_str() {
278 Ok(value) => (Some(value), false),
279 Err(_) => (None, true),
280 },
281 None => (None, false),
282 };
283 self.redact_body_with_content_type(
284 capture,
285 content_type,
286 invalid_content_type,
287 )
288 }
289
290 pub fn redact_body_with_content_type_text(
304 &self,
305 capture: BodyCapture<'_>,
306 content_type: Option<&str>,
307 ) -> BodyRedaction {
308 let invalid_content_type = content_type.is_some_and(|value| {
309 value.len()
310 > self.policy.limits().diagnostic_event().max_input_bytes()
311 });
312 self.redact_body_with_content_type(
313 capture,
314 content_type,
315 invalid_content_type,
316 )
317 }
318
319 #[must_use = "use the session-bounded URL result"]
321 pub fn redact_url_with_session(
322 &self,
323 url: &Url,
324 session: &RedactionSession<'_>,
325 ) -> LogSafeText<'static> {
326 if !session.consume_input(url.as_str().len()) {
327 return session_diagnostic_limit_exceeded(session);
328 }
329 charge_session_output(session, self.redact_url(url))
330 }
331
332 #[must_use = "use the session-bounded URL result"]
334 pub fn redact_urls_in_text_with_session(
335 &self,
336 text: &str,
337 session: &RedactionSession<'_>,
338 ) -> LogSafeText<'static> {
339 if !session.consume_input(text.len()) {
340 return session_diagnostic_limit_exceeded(session);
341 }
342 charge_session_output(session, self.redact_urls_in_text(text))
343 }
344
345 #[must_use = "use the session-bounded URL result"]
347 pub fn redact_url_str_with_session(
348 &self,
349 input: &str,
350 session: &RedactionSession<'_>,
351 ) -> LogSafeText<'static> {
352 if !session.consume_input(input.len()) {
353 return session_diagnostic_limit_exceeded(session);
354 }
355 charge_session_output(session, self.redact_url_str(input))
356 }
357
358 #[must_use = "use the session-bounded form result"]
360 pub fn redact_form_with_session(
361 &self,
362 input: &str,
363 session: &RedactionSession<'_>,
364 ) -> LogSafeText<'static> {
365 if !session.consume_input(input.len()) {
366 return session_diagnostic_limit_exceeded(session);
367 }
368 charge_session_output(session, self.redact_form(input))
369 }
370
371 #[must_use = "use the session-bounded header result"]
373 pub fn redact_headers_with_session(
374 &self,
375 headers: &HeaderMap,
376 session: &RedactionSession<'_>,
377 ) -> RedactedHeaders {
378 let input_bytes = headers
379 .iter()
380 .map(|(name, value)| {
381 name.as_str().len().saturating_add(value.as_bytes().len())
382 })
383 .fold(0_usize, usize::saturating_add);
384 if !session.consume_input(input_bytes) {
385 return RedactedHeaders::new(session_diagnostic_limit_exceeded(
386 session,
387 ));
388 }
389 charge_header_output(session, self.redact_headers(headers))
390 }
391
392 #[must_use = "use the session-bounded body result"]
394 pub fn redact_body_with_session(
395 &self,
396 capture: BodyCapture<'_>,
397 content_type: Option<&HeaderValue>,
398 session: &RedactionSession<'_>,
399 ) -> BodyRedaction {
400 let input_bytes = capture.bytes().len().saturating_add(
401 content_type.map_or(0, |value| value.as_bytes().len()),
402 );
403 if !session.consume_input(input_bytes) {
404 let text = session_diagnostic_limit_exceeded(session).into_owned();
405 return session_limited_body(capture, text);
406 }
407 charge_body_output(session, self.redact_body(capture, content_type))
408 }
409
410 fn redact_body_with_content_type(
424 &self,
425 capture: BodyCapture<'_>,
426 content_type: Option<&str>,
427 invalid_content_type: bool,
428 ) -> BodyRedaction {
429 let input_len = capture
430 .bytes()
431 .len()
432 .min(self.policy.body_budget().max_input_bytes());
433 let bounded = &capture.bytes()[..input_len];
434 let budget_truncated = input_len < capture.bytes().len();
435
436 let truncated = capture.is_source_truncated() || budget_truncated;
437 let parsed = if invalid_content_type {
438 Self::invalid_content_type_body()
439 } else {
440 self.redact_body_inner(bounded, content_type, truncated)
441 };
442 Self::finish_body_redaction(
443 parsed,
444 capture,
445 input_len,
446 budget_truncated,
447 self.policy.body_budget(),
448 )
449 }
450
451 fn redact_url_text(&self, url: &Url) -> String {
461 self.redact_url_text_at_depth(url, 0)
462 }
463
464 fn redact_url_text_at_depth(&self, url: &Url, depth: usize) -> String {
475 let output_limit =
476 self.policy.limits().diagnostic_event().max_output_bytes();
477 let mut output = url.clone();
478 if self.policy.url_path_policy() == UrlPathPolicy::Redact
479 && output.path() != "/"
480 {
481 output.set_path("/<redacted>");
482 }
483 if !output.username().is_empty() {
484 let masked = self
485 .query_field_redactor()
486 .mask_bounded(
487 Sensitivity::High,
488 output.username(),
489 output_limit,
490 )
491 .into_owned();
492 let _ = output.set_username(&masked);
493 }
494 if let Some(password) = output.password() {
495 let masked = self
496 .query_field_redactor()
497 .mask_bounded(Sensitivity::Secret, password, output_limit)
498 .into_owned();
499 let _ = output.set_password(Some(&masked));
500 }
501 if let Some(fragment) = output.fragment() {
502 let masked = self
503 .query_field_redactor()
504 .mask_bounded(Sensitivity::High, fragment, output_limit)
505 .into_owned();
506 output.set_fragment(Some(&masked));
507 }
508 if let Some(query) = url.query() {
509 if form::is_valid(query.as_bytes()) {
510 let query_limit = output_limit.saturating_add(1);
511 let mut redacted_query = String::new();
512 for (key, value) in url.query_pairs() {
513 let remaining =
514 query_limit.saturating_sub(redacted_query.len());
515 let value = self
516 .query_field_redactor()
517 .redact_bounded(&key, &value, remaining)
518 .into_inner();
519 let value = self.redact_nested_url_value(value, depth);
520 if !form::append_pair_bounded(
521 &mut redacted_query,
522 &key,
523 value.as_ref(),
524 query_limit,
525 ) {
526 break;
527 }
528 }
529 output.set_query(Some(&redacted_query));
530 } else {
531 output.set_query(Some(markers::INVALID_QUERY));
532 }
533 }
534 output.to_string()
535 }
536
537 fn redact_nested_url_value<'a>(
553 &self,
554 value: Cow<'a, str>,
555 depth: usize,
556 ) -> Cow<'a, str> {
557 let raw = match value {
558 Cow::Borrowed(raw) => raw,
559 Cow::Owned(masked) => return Cow::Owned(masked),
560 };
561 match nested_url::detect(raw) {
562 NestedUrl::NotUrl => Cow::Borrowed(raw),
563 NestedUrl::Parsed(url)
564 if depth < url_rules::MAX_NESTED_URL_DEPTH =>
565 {
566 Cow::Owned(self.redact_url_text_at_depth(&url, depth + 1))
567 }
568 NestedUrl::Parsed(_) | NestedUrl::LimitExceeded => {
569 Cow::Borrowed(markers::NESTED_URL_LIMIT)
570 }
571 NestedUrl::Invalid => Cow::Borrowed(markers::INVALID_URL),
572 }
573 }
574
575 #[must_use = "redacted body text and its status must be handled together"]
589 fn redact_body_inner(
590 &self,
591 bounded: &[u8],
592 content_type: Option<&str>,
593 truncated: bool,
594 ) -> ParsedBody {
595 if bounded.is_empty() {
596 return ParsedBody::new(
597 String::new(),
598 BodyRedactionStatus::Empty,
599 false,
600 );
601 }
602 let content_type = match content_type {
603 Some(value) => match content_type::parse(value) {
604 Some(value) => Some(value),
605 None => return Self::invalid_content_type_body(),
606 },
607 None => None,
608 };
609 if let Some(content_type::ContentType::Multipart {
610 boundary,
611 require_form_data,
612 }) = &content_type
613 {
614 if truncated {
615 return ParsedBody::new(
616 markers::MULTIPART_BODY.to_string(),
617 BodyRedactionStatus::Redacted(
618 BodyRedactionReason::TruncatedMultipart,
619 ),
620 false,
621 );
622 }
623 if let Some(boundary) = boundary.as_deref()
624 && let Some((text, passed, rendered_truncated)) =
625 multipart::redact(
626 &self.body_field_redactor(),
627 boundary,
628 *require_form_data,
629 bounded,
630 &self.policy,
631 )
632 {
633 return ParsedBody::new(
634 text,
635 if passed {
636 BodyRedactionStatus::PassedThrough
637 } else {
638 BodyRedactionStatus::Structured
639 },
640 rendered_truncated,
641 );
642 }
643 return ParsedBody::new(
644 markers::MULTIPART_BODY.to_string(),
645 BodyRedactionStatus::Redacted(
646 BodyRedactionReason::InvalidMultipart,
647 ),
648 false,
649 );
650 }
651 if matches!(&content_type, Some(content_type::ContentType::Ndjson)) {
652 return self.redact_ndjson(bounded, truncated);
653 }
654 let trimmed = body::trim_ascii_whitespace(bounded);
655 if matches!(&content_type, Some(content_type::ContentType::Json))
656 || (content_type.is_none()
657 && matches!(trimmed.first(), Some(b'{') | Some(b'[')))
658 {
659 return self.redact_json(bounded, truncated);
660 }
661 if matches!(&content_type, Some(content_type::ContentType::Form)) {
662 return self.redact_body_form(bounded, truncated);
663 }
664 self.redact_fallback(
665 bounded,
666 matches!(&content_type, Some(content_type::ContentType::Text)),
667 )
668 }
669
670 #[must_use = "redacted JSON text and its status must be handled together"]
682 fn redact_json(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
683 if truncated {
684 return ParsedBody::new(
685 markers::INVALID_OR_TRUNCATED_JSON.to_string(),
686 BodyRedactionStatus::Redacted(
687 BodyRedactionReason::InvalidOrTruncatedJson,
688 ),
689 false,
690 );
691 }
692 let Ok(mut value) = serde_json::from_slice(bounded) else {
693 return ParsedBody::new(
694 markers::INVALID_JSON.to_string(),
695 BodyRedactionStatus::Redacted(BodyRedactionReason::InvalidJson),
696 false,
697 );
698 };
699 let passed = json::redact(
700 &self.body_field_redactor(),
701 &mut value,
702 self.policy.json_depth_budget(),
703 self.policy.unkeyed_json_value_policy(),
704 self.policy.body_budget().max_output_bytes(),
705 );
706 match json::serialize_bounded(
707 &value,
708 self.policy.body_budget().max_output_bytes(),
709 ) {
710 Some((text, rendered_truncated)) => ParsedBody::new(
711 text,
712 if passed {
713 BodyRedactionStatus::PassedThrough
714 } else {
715 BodyRedactionStatus::Structured
716 },
717 rendered_truncated,
718 ),
719 None => ParsedBody::new(
720 markers::INVALID_JSON.to_string(),
721 BodyRedactionStatus::Redacted(BodyRedactionReason::InvalidJson),
722 false,
723 ),
724 }
725 }
726
727 fn invalid_content_type_body() -> ParsedBody {
733 ParsedBody::new(
734 markers::INVALID_CONTENT_TYPE.to_string(),
735 BodyRedactionStatus::Redacted(
736 BodyRedactionReason::InvalidContentType,
737 ),
738 false,
739 )
740 }
741
742 #[must_use = "redacted NDJSON text and its status must be handled together"]
754 fn redact_ndjson(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
755 if truncated {
756 return ParsedBody::new(
757 markers::INVALID_OR_TRUNCATED_NDJSON.to_string(),
758 BodyRedactionStatus::Redacted(
759 BodyRedactionReason::InvalidOrTruncatedNdjson,
760 ),
761 false,
762 );
763 }
764 match json::redact_ndjson(
765 &self.body_field_redactor(),
766 bounded,
767 self.policy.json_depth_budget(),
768 self.policy.unkeyed_json_value_policy(),
769 self.policy.body_budget().max_output_bytes(),
770 ) {
771 Some((output, passed, rendered_truncated)) => ParsedBody::new(
772 output,
773 if passed {
774 BodyRedactionStatus::PassedThrough
775 } else {
776 BodyRedactionStatus::Structured
777 },
778 rendered_truncated,
779 ),
780 None => ParsedBody::new(
781 markers::INVALID_NDJSON.to_string(),
782 BodyRedactionStatus::Redacted(
783 BodyRedactionReason::InvalidNdjson,
784 ),
785 false,
786 ),
787 }
788 }
789
790 #[must_use = "redacted form text and its status must be handled together"]
802 fn redact_body_form(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
803 if truncated {
804 return ParsedBody::new(
805 markers::INVALID_OR_TRUNCATED_FORM.to_string(),
806 BodyRedactionStatus::Redacted(
807 BodyRedactionReason::InvalidOrTruncatedFormUrlEncoded,
808 ),
809 false,
810 );
811 }
812 if !form::is_valid(bounded) {
813 return ParsedBody::new(
814 markers::INVALID_FORM.to_string(),
815 BodyRedactionStatus::Redacted(
816 BodyRedactionReason::InvalidFormUrlEncoded,
817 ),
818 false,
819 );
820 }
821 ParsedBody::new(
822 form::redact_bounded(
823 &self.body_field_redactor(),
824 bounded,
825 self.policy.body_budget().max_output_bytes(),
826 ),
827 BodyRedactionStatus::Structured,
828 false,
829 )
830 }
831
832 #[must_use = "fallback text and its status must be handled together"]
844 fn redact_fallback(&self, bounded: &[u8], is_text: bool) -> ParsedBody {
845 match std::str::from_utf8(bounded) {
846 Err(_) => ParsedBody::new(
847 format!("<binary {} bytes>", bounded.len()),
848 BodyRedactionStatus::Binary,
849 false,
850 ),
851 Ok(text) if is_text => match self.policy.text_body_policy() {
852 TextBodyPolicy::Redact => ParsedBody::new(
853 markers::TEXT_BODY.to_string(),
854 BodyRedactionStatus::Redacted(
855 BodyRedactionReason::OpaqueText,
856 ),
857 false,
858 ),
859 TextBodyPolicy::PassThrough => ParsedBody::new(
860 text.to_string(),
861 BodyRedactionStatus::PassedThrough,
862 false,
863 ),
864 },
865 Ok(_) => ParsedBody::new(
866 markers::UNSUPPORTED_BODY.to_string(),
867 BodyRedactionStatus::Redacted(
868 BodyRedactionReason::UnsupportedMediaType,
869 ),
870 false,
871 ),
872 }
873 }
874
875 fn finish_body_redaction(
889 parsed: ParsedBody,
890 capture: BodyCapture<'_>,
891 captured_len: usize,
892 budget_truncated: bool,
893 budget: BodyBudget,
894 ) -> BodyRedaction {
895 let (parsed_text, status, rendered_truncated) = parsed.into_parts();
896 let source_truncated = capture.is_source_truncated()
897 || budget_truncated
898 || rendered_truncated;
899 let mut writer =
900 BoundedLogWriter::new(budget.max_output_bytes(), source_truncated);
901 let _ = writer.write_str(&parsed_text);
902 let (text, truncated) = writer.finish();
903 let source_len = capture.total_len();
904 let omitted_len =
905 source_len.map(|total| total.saturating_sub(captured_len));
906 BodyRedaction::new(
907 text,
908 status,
909 captured_len,
910 source_len,
911 omitted_len,
912 truncated,
913 )
914 }
915}
916
917fn charge_session_output(
920 session: &RedactionSession<'_>,
921 value: LogSafeText<'static>,
922) -> LogSafeText<'static> {
923 match session.charge_output_or_fallback(
924 value.as_str().len(),
925 markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
926 ) {
927 OutputCharge::Complete => value,
928 OutputCharge::Fallback => HttpRedactor::diagnostic_limit_exceeded(),
929 OutputCharge::Exhausted => empty_log_safe_text(),
930 }
931}
932
933fn session_diagnostic_limit_exceeded(
936 session: &RedactionSession<'_>,
937) -> LogSafeText<'static> {
938 match session.charge_output_or_fallback(
939 markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
940 markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
941 ) {
942 OutputCharge::Complete => HttpRedactor::diagnostic_limit_exceeded(),
943 OutputCharge::Fallback | OutputCharge::Exhausted => {
944 empty_log_safe_text()
945 }
946 }
947}
948
949fn empty_log_safe_text() -> LogSafeText<'static> {
951 LogSafeText::from_escaped(Cow::Borrowed(""))
952}
953
954fn charge_header_output(
956 session: &RedactionSession<'_>,
957 value: RedactedHeaders,
958) -> RedactedHeaders {
959 match session.charge_output_or_fallback(
960 value.log_safe_text().as_str().len(),
961 markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
962 ) {
963 OutputCharge::Complete => value,
964 OutputCharge::Fallback => {
965 RedactedHeaders::new(HttpRedactor::diagnostic_limit_exceeded())
966 }
967 OutputCharge::Exhausted => RedactedHeaders::new(empty_log_safe_text()),
968 }
969}
970
971fn charge_body_output(
973 session: &RedactionSession<'_>,
974 value: BodyRedaction,
975) -> BodyRedaction {
976 match session.charge_output_or_fallback(
977 value.log_safe_text().as_str().len(),
978 markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
979 ) {
980 OutputCharge::Complete => value,
981 OutputCharge::Fallback => session_limited_body(
982 BodyCapture::complete(b""),
983 markers::DIAGNOSTIC_LIMIT_EXCEEDED.to_owned(),
984 ),
985 OutputCharge::Exhausted => {
986 session_limited_body(BodyCapture::complete(b""), String::new())
987 }
988 }
989}
990
991fn session_limited_body(
994 capture: BodyCapture<'_>,
995 text: String,
996) -> BodyRedaction {
997 BodyRedaction::new(
998 text,
999 BodyRedactionStatus::Redacted(
1000 BodyRedactionReason::DiagnosticBudgetExceeded,
1001 ),
1002 0,
1003 capture.total_len(),
1004 capture.total_len(),
1005 true,
1006 )
1007}
1008
1009impl Default for HttpRedactor {
1010 fn default() -> Self {
1016 Self::new(RedactionPolicy::default())
1017 }
1018}