termivar_scanner/http_evidence/
policy.rs1use std::{collections::BTreeSet, time::Duration};
2
3use reqwest::{header::HeaderName, Url};
4use serde::Serialize;
5use termivar_core::ConfidenceScore;
6
7use super::HttpEvidenceError;
8
9pub const DEFAULT_HTTP_BODY_LIMIT: usize = 256 * 1024;
11
12pub const MAX_HTTP_BODY_LIMIT: usize = 16 * 1024 * 1024;
14
15#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(tag = "mode", rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum HttpBodyCapture {
20 #[default]
22 MetadataOnly,
23 TextSample {
25 max_chars: usize,
27 },
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct HttpEvidencePolicy {
33 allowed_origins: BTreeSet<String>,
34 pub(super) request_timeout_ms: u64,
35 max_body_bytes: usize,
36 body_capture: HttpBodyCapture,
37 captured_headers: BTreeSet<String>,
38 reliability: ConfidenceScore,
39}
40
41impl HttpEvidencePolicy {
42 pub fn new(
44 allowed_origins: impl IntoIterator<Item = Url>,
45 request_timeout: Duration,
46 max_body_bytes: usize,
47 ) -> Result<Self, HttpEvidenceError> {
48 if request_timeout.is_zero() {
49 return Err(HttpEvidenceError::ZeroTimeout);
50 }
51 validate_body_limit(max_body_bytes)?;
52
53 let mut origins = BTreeSet::new();
54 for url in allowed_origins {
55 validate_http_url(&url)?;
56 origins.insert(origin(&url)?);
57 }
58 if origins.is_empty() {
59 return Err(HttpEvidenceError::EmptyAllowedOrigins);
60 }
61
62 Ok(Self {
63 allowed_origins: origins,
64 request_timeout_ms: u64::try_from(request_timeout.as_millis().max(1))
65 .unwrap_or(u64::MAX),
66 max_body_bytes,
67 body_capture: HttpBodyCapture::MetadataOnly,
68 captured_headers: default_captured_headers(),
69 reliability: ConfidenceScore::MAX,
70 })
71 }
72
73 pub fn for_origin(origin: Url) -> Result<Self, HttpEvidenceError> {
75 Self::new([origin], Duration::from_secs(15), DEFAULT_HTTP_BODY_LIMIT)
76 }
77
78 pub(crate) fn restricted_to_exact_origin(
85 &self,
86 target: &Url,
87 ) -> Result<Self, HttpEvidenceError> {
88 self.require_permitted_target(target)?;
89
90 let mut restricted = self.clone();
91 restricted.allowed_origins = BTreeSet::from([origin(target)?]);
92 Ok(restricted)
93 }
94
95 pub(crate) fn restricted_for_web_assessment(
103 &self,
104 target: &Url,
105 max_body_bytes: usize,
106 ) -> Result<Self, HttpEvidenceError> {
107 validate_body_limit(max_body_bytes)?;
108 let mut restricted = self.restricted_to_exact_origin(target)?;
109 restricted.max_body_bytes = restricted.max_body_bytes.min(max_body_bytes);
110 restricted.body_capture = HttpBodyCapture::MetadataOnly;
111 restricted.captured_headers.clear();
114 Ok(restricted)
115 }
116
117 pub fn with_body_capture(
119 mut self,
120 capture: HttpBodyCapture,
121 ) -> Result<Self, HttpEvidenceError> {
122 if let HttpBodyCapture::TextSample { max_chars } = capture {
123 if max_chars == 0 {
124 return Err(HttpEvidenceError::ZeroTextSampleLimit);
125 }
126 if max_chars > self.max_body_bytes {
127 return Err(HttpEvidenceError::TextSampleLimitTooLarge {
128 max_chars,
129 max_body_bytes: self.max_body_bytes,
130 });
131 }
132 }
133 self.body_capture = capture;
134 Ok(self)
135 }
136
137 pub fn capture_header(mut self, name: impl Into<String>) -> Result<Self, HttpEvidenceError> {
142 let name = name.into();
143 let parsed = HeaderName::from_bytes(name.as_bytes())
144 .map_err(|_| HttpEvidenceError::InvalidHeaderName { name: name.clone() })?;
145 self.captured_headers.insert(parsed.as_str().to_owned());
146 Ok(self)
147 }
148
149 pub fn with_reliability(
154 mut self,
155 reliability: ConfidenceScore,
156 ) -> Result<Self, HttpEvidenceError> {
157 if reliability == ConfidenceScore::NONE {
158 return Err(HttpEvidenceError::ZeroReliability);
159 }
160 self.reliability = reliability;
161 Ok(self)
162 }
163
164 pub fn allowed_origins(&self) -> &BTreeSet<String> {
166 &self.allowed_origins
167 }
168
169 pub fn request_timeout(&self) -> Duration {
171 Duration::from_millis(self.request_timeout_ms)
172 }
173
174 pub fn max_body_bytes(&self) -> usize {
176 self.max_body_bytes
177 }
178
179 pub fn body_capture(&self) -> HttpBodyCapture {
181 self.body_capture
182 }
183
184 pub fn captured_headers(&self) -> &BTreeSet<String> {
186 &self.captured_headers
187 }
188
189 pub fn reliability(&self) -> ConfidenceScore {
191 self.reliability
192 }
193
194 fn permits(&self, url: &Url) -> Result<bool, HttpEvidenceError> {
195 Ok(self.allowed_origins.contains(&origin(url)?))
196 }
197
198 pub(crate) fn require_permitted_target(&self, target: &Url) -> Result<(), HttpEvidenceError> {
204 if !self.permits(target)? {
205 return Err(HttpEvidenceError::TargetOutsidePolicy {
206 url: target.to_string(),
207 });
208 }
209 Ok(())
210 }
211}
212
213pub(super) fn validate_http_url(url: &Url) -> Result<(), HttpEvidenceError> {
214 if !matches!(url.scheme(), "http" | "https") {
215 return Err(HttpEvidenceError::UnsupportedScheme {
216 scheme: url.scheme().to_owned(),
217 });
218 }
219 if !url.username().is_empty() || url.password().is_some() {
220 return Err(HttpEvidenceError::EmbeddedCredentials);
221 }
222 Ok(())
223}
224
225fn origin(url: &Url) -> Result<String, HttpEvidenceError> {
226 validate_http_url(url)?;
227 Ok(url.origin().ascii_serialization())
228}
229
230fn validate_body_limit(max_body_bytes: usize) -> Result<(), HttpEvidenceError> {
231 if max_body_bytes == 0 {
232 return Err(HttpEvidenceError::ZeroBodyLimit);
233 }
234 if max_body_bytes > MAX_HTTP_BODY_LIMIT {
235 return Err(HttpEvidenceError::BodyLimitTooLarge {
236 actual: max_body_bytes,
237 maximum: MAX_HTTP_BODY_LIMIT,
238 });
239 }
240 Ok(())
241}
242
243pub(super) fn forbidden_request_header(name: &HeaderName) -> bool {
244 matches!(
245 name.as_str(),
246 "host"
247 | "connection"
248 | "content-length"
249 | "transfer-encoding"
250 | "upgrade"
251 | "proxy-authorization"
252 | "proxy-connection"
253 )
254}
255
256fn default_captured_headers() -> BTreeSet<String> {
257 [
258 "access-control-allow-origin",
259 "allow",
260 "cache-control",
261 "content-length",
262 "content-security-policy",
263 "content-type",
264 "location",
265 "ratelimit-limit",
266 "ratelimit-remaining",
267 "ratelimit-reset",
268 "retry-after",
269 "server",
270 "strict-transport-security",
271 "vary",
272 "www-authenticate",
273 "x-frame-options",
274 "x-powered-by",
275 "x-ratelimit-limit",
276 "x-ratelimit-remaining",
277 "x-ratelimit-reset",
278 ]
279 .into_iter()
280 .map(str::to_owned)
281 .collect()
282}