qubit_redact/formats/http/
http_redaction_writer.rs1use http::HeaderMap;
11use http::HeaderValue;
12use url::Url;
13
14use super::BodyCapture;
15use super::admitted_body::AdmittedBody;
16use super::internal::nested_url;
17use super::internal::nested_url::NestedUrl;
18use super::redaction::url_rules;
19use crate::runtime::OperationSink;
20use crate::runtime::TextSession;
21use crate::runtime::runtime_session::RuntimeSession;
22
23pub struct HttpRedactionWriter<'session> {
25 pub(super) session: &'session mut TextSession,
27}
28
29impl<'session> HttpRedactionWriter<'session> {
30 pub(crate) const fn new(session: &'session mut TextSession) -> Self {
32 Self { session }
33 }
34
35 pub fn url(&mut self, value: &str) -> &mut Self {
37 if self.session.skip_aggregate_for_exhausted_output() || !self.session.admit_format_node(1) {
38 return self;
39 }
40 let input_was_empty = value.is_empty();
41 let value = self.session.admit_input_prefix(value);
42 if value.is_empty() && !input_was_empty {
43 return self;
44 }
45 if !admit_url_structure(self.session, value) {
46 self.session.append_rendered_operation(
47 OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
48 );
49 return self;
50 }
51 let result = self.redact_url_str_direct(value);
52 self.session.append_rendered_operation(result.into_operation());
53 self
54 }
55
56 pub fn headers(&mut self, headers: &HeaderMap) -> &mut Self {
58 let Some(headers) = collect_admitted_headers(self.session, headers) else {
59 return self;
60 };
61 let result = self.redact_headers_direct(&headers);
62 self.session.append_rendered_operation(result.into_operation());
63 self
64 }
65}
66
67pub(crate) fn admit_url_structure(session: &mut dyn RuntimeSession, text: &str) -> bool {
69 let Ok(url) = Url::parse(text) else {
70 return true;
71 };
72 admit_url_structure_at_depth(session, &url, 1)
73}
74
75fn admit_url_structure_at_depth(session: &mut dyn RuntimeSession, url: &Url, url_depth: usize) -> bool {
77 let Some(query) = url.query() else {
78 return true;
79 };
80 if !super::internal::form::is_valid(query.as_bytes()) {
81 return true;
82 }
83 for (_, value) in url.query_pairs() {
84 if !session.admit_format_collection_item() || !session.admit_format_node(url_depth.saturating_add(1)) {
85 return false;
86 }
87 match nested_url::detect(value.as_ref()) {
88 NestedUrl::Parsed(nested) if url_depth < url_rules::MAX_NESTED_URL_DEPTH => {
89 if !session.admit_format_node(url_depth.saturating_add(1))
90 || !admit_url_structure_at_depth(session, &nested, url_depth.saturating_add(1))
91 {
92 return false;
93 }
94 }
95 NestedUrl::NotUrl | NestedUrl::Parsed(_) | NestedUrl::Invalid | NestedUrl::LimitExceeded => {}
96 }
97 }
98 true
99}
100
101pub(crate) fn collect_admitted_headers(session: &mut dyn RuntimeSession, headers: &HeaderMap) -> Option<HeaderMap> {
103 if session.skip_aggregate_for_exhausted_output() || !session.admit_format_node(1) {
104 return None;
105 }
106 let mut admitted = HeaderMap::new();
107 for (name, value) in headers {
108 if !session.admit_format_collection_item()
109 || !session.admit_format_node(2)
110 || !session.admit_input(name.as_str().len().saturating_add(value.as_bytes().len()))
111 {
112 return None;
113 }
114 admitted.append(name.clone(), value.clone());
115 }
116 Some(admitted)
117}
118
119impl<'session> HttpRedactionWriter<'session> {
120 #[must_use]
122 fn redact_url_str_direct(&mut self, text: &str) -> super::redaction::HttpRendered {
123 super::redaction::redact_url_str_with_policy(self.session.policy(), text, self.session.remaining_output_bytes())
124 }
125
126 #[must_use]
128 fn redact_headers_direct(&mut self, headers: &HeaderMap) -> super::redaction::HttpRendered {
129 super::redaction::redact_headers_with_policy(
130 self.session.policy(),
131 headers,
132 self.session.remaining_output_bytes(),
133 )
134 }
135
136 #[must_use]
154 pub fn body(&mut self, capture: BodyCapture<'_>, content_type: Option<&HeaderValue>) -> &mut Self {
155 if self.session.skip_aggregate_for_exhausted_output()
156 || !admit_body_input(self.session, capture, content_type.map(|v| v.as_bytes().len()))
157 {
158 return self;
159 }
160 let Some(admitted) = admit_body_structure(self.session, capture, content_type.map(|value| value.as_bytes()))
161 else {
162 self.session.append_rendered_operation(
163 OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
164 );
165 return self;
166 };
167 let remaining = self.session.remaining_output_bytes();
168 let result = super::redaction::redact_admitted_body_with_policy(
169 self.session.policy(),
170 capture,
171 content_type,
172 admitted,
173 remaining,
174 );
175 self.session.append_rendered_operation(result.into_operation());
176 self
177 }
178
179 #[must_use]
197 pub fn body_with_content_type_text(&mut self, capture: BodyCapture<'_>, content_type: Option<&str>) -> &mut Self {
198 if self.session.skip_aggregate_for_exhausted_output()
199 || !admit_body_input(self.session, capture, content_type.map(str::len))
200 {
201 return self;
202 }
203 let Some(admitted) = admit_body_structure(self.session, capture, content_type.map(str::as_bytes)) else {
204 self.session.append_rendered_operation(
205 OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
206 );
207 return self;
208 };
209 let remaining = self.session.remaining_output_bytes();
210 let result = super::redaction::redact_admitted_body_with_content_type_text_with_policy(
211 self.session.policy(),
212 capture,
213 content_type,
214 admitted,
215 remaining,
216 );
217 self.session.append_rendered_operation(result.into_operation());
218 self
219 }
220}
221
222pub(crate) fn admit_body_structure(
224 session: &mut dyn RuntimeSession,
225 capture: BodyCapture<'_>,
226 content_type: Option<&[u8]>,
227) -> Option<AdmittedBody> {
228 if session.policy().is_disabled() {
229 return session.admit_format_node(1).then_some(AdmittedBody::Other);
230 }
231 let has_content_type = content_type.is_some();
232 let content_type = content_type
233 .and_then(|value| std::str::from_utf8(value).ok())
234 .and_then(super::internal::content_type::parse);
235 let inferred_json = !has_content_type
236 && matches!(
237 capture.bytes().iter().copied().find(|byte| !byte.is_ascii_whitespace()),
238 Some(b'{') | Some(b'[')
239 );
240 if capture.is_source_truncated()
241 && (matches!(
242 &content_type,
243 Some(super::internal::content_type::ContentType::Json)
244 | Some(super::internal::content_type::ContentType::Ndjson)
245 ) || inferred_json)
246 {
247 return session.admit_format_node(1).then_some(AdmittedBody::Other);
251 }
252 if matches!(&content_type, Some(super::internal::content_type::ContentType::Json)) || inferred_json {
253 let Ok(text) = std::str::from_utf8(capture.bytes()) else {
254 return session.admit_format_node(1).then_some(AdmittedBody::InvalidJson);
255 };
256 return match crate::formats::json::admit_json_text_value(session, text) {
257 Ok(value) => Some(AdmittedBody::Json(value)),
258 Err(crate::formats::json::JsonAdmissionError::Invalid) => Some(AdmittedBody::InvalidJson),
259 Err(crate::formats::json::JsonAdmissionError::Limit) => None,
260 };
261 }
262 if matches!(&content_type, Some(super::internal::content_type::ContentType::Ndjson)) {
263 let Ok(text) = std::str::from_utf8(capture.bytes()) else {
264 return session.admit_format_node(1).then_some(AdmittedBody::InvalidNdjson);
265 };
266 let mut lines = Vec::new();
267 let mut admitted_any = false;
268 for line in text.lines() {
269 if line.trim().is_empty() {
270 lines.push(None);
271 continue;
272 }
273 admitted_any = true;
274 match crate::formats::json::admit_json_text_value(session, line) {
275 Ok(value) => lines.push(Some(value)),
276 Err(crate::formats::json::JsonAdmissionError::Invalid) => {
277 return Some(AdmittedBody::InvalidNdjson);
278 }
279 Err(crate::formats::json::JsonAdmissionError::Limit) => return None,
280 }
281 }
282 if !admitted_any && !session.admit_format_node(1) {
283 return None;
284 }
285 return Some(AdmittedBody::Ndjson {
286 lines,
287 trailing_newline: text.ends_with('\n'),
288 });
289 }
290 if !session.admit_format_node(1) {
291 return None;
292 }
293 let admitted = match content_type {
294 Some(super::internal::content_type::ContentType::Form) => {
295 super::internal::multipart::admit_form_fields(session, capture.bytes(), 2)
296 }
297 Some(super::internal::content_type::ContentType::Multipart {
298 boundary: Some(boundary),
299 require_form_data,
300 }) => {
301 return super::internal::multipart::admit_structure(session, &boundary, require_form_data, capture.bytes())
302 .map(AdmittedBody::Multipart);
303 }
304 Some(super::internal::content_type::ContentType::Multipart { boundary: None, .. })
305 | Some(super::internal::content_type::ContentType::Text)
306 | Some(super::internal::content_type::ContentType::Other)
307 | None => true,
308 Some(super::internal::content_type::ContentType::Json)
309 | Some(super::internal::content_type::ContentType::Ndjson) => true,
310 };
311 admitted.then_some(AdmittedBody::Other)
312}
313
314pub(crate) fn admit_body_input(
316 session: &mut dyn RuntimeSession,
317 capture: BodyCapture<'_>,
318 content_type_len: Option<usize>,
319) -> bool {
320 let content_type_len = content_type_len.unwrap_or(0);
321 let inspectable = capture.bytes().len().saturating_add(content_type_len);
322 let total = capture
323 .total_len()
324 .map(|length| length.saturating_add(content_type_len));
325 session.admit_source_input(total, inspectable)
326}
327
328#[cfg(test)]
329mod tests {
330 use http::HeaderValue;
331
332 use super::BodyCapture;
333 use crate::Redactor;
334 use crate::formats::json::parse_counter::json_parse_count;
335 use crate::formats::json::parse_counter::reset_json_parse_count;
336
337 #[test]
339 fn enabled_http_json_body_is_parsed_exactly_once() {
340 reset_json_parse_count();
341
342 let output = Redactor::standard().redact_http_body(
343 BodyCapture::complete(br#"{"token":"raw-secret"}"#),
344 Some(&HeaderValue::from_static("application/json")),
345 );
346
347 assert_eq!(json_parse_count(), 1);
348 assert!(!output.text().as_str().contains("raw-secret"));
349 }
350
351 #[test]
353 fn enabled_http_ndjson_lines_are_parsed_exactly_once() {
354 reset_json_parse_count();
355
356 let output = Redactor::standard().redact_http_body(
357 BodyCapture::complete(b"{\"token\":\"one\"}\n{\"token\":\"two\"}\n"),
358 Some(&HeaderValue::from_static("application/x-ndjson")),
359 );
360
361 assert_eq!(json_parse_count(), 2);
362 assert!(!output.text().as_str().contains("one"));
363 assert!(!output.text().as_str().contains("two"));
364 }
365
366 #[test]
368 fn enabled_http_ndjson_preserves_empty_lines() {
369 let output = Redactor::standard().redact_http_body(
370 BodyCapture::complete(b"{\"name\":\"one\"}\n\n{\"name\":\"two\"}\n"),
371 Some(&HeaderValue::from_static("application/x-ndjson")),
372 );
373
374 assert_eq!(output.text().as_str(), "{\"name\":\"one\"}\\n\\n{\"name\":\"two\"}\\n",);
375 }
376}