Skip to main content

termivar_scanner/http_evidence/
policy.rs

1use std::{collections::BTreeSet, time::Duration};
2
3use reqwest::{header::HeaderName, Url};
4use serde::Serialize;
5use termivar_core::ConfidenceScore;
6
7use super::HttpEvidenceError;
8
9/// Default maximum number of response-body bytes read by one probe.
10pub const DEFAULT_HTTP_BODY_LIMIT: usize = 256 * 1024;
11
12/// Hard guard preventing an individual evidence probe from buffering too much.
13pub const MAX_HTTP_BODY_LIMIT: usize = 16 * 1024 * 1024;
14
15/// Response-body representation allowed to enter the knowledge base.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(tag = "mode", rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum HttpBodyCapture {
20    /// Record byte count, truncation, and SHA-256 only.
21    #[default]
22    MetadataOnly,
23    /// Also record a bounded UTF-8 sample for textual response types.
24    TextSample {
25        /// Maximum Unicode scalar values retained in the sample.
26        max_chars: usize,
27    },
28}
29
30/// Scope, resource, and evidence policy applied to every HTTP probe.
31#[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    /// Creates a policy for one or more explicitly authorized origins.
43    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    /// Uses the standard timeout, body limit, headers, and maximum reliability.
74    pub fn for_origin(origin: Url) -> Result<Self, HttpEvidenceError> {
75        Self::new([origin], Duration::from_secs(15), DEFAULT_HTTP_BODY_LIMIT)
76    }
77
78    /// Returns this policy narrowed to the exact origin of `target`.
79    ///
80    /// All non-scope settings are preserved. The target must already be covered
81    /// by this policy, so narrowing can never turn an unauthorized target into
82    /// an authorized one. Bounded origin-level runtimes use this seam to ensure
83    /// that an explicitly broader host policy cannot silently expand discovery.
84    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    /// Narrows a policy for the names-only assessment observer.
96    ///
97    /// The assessment is never allowed to inherit text sampling or a
98    /// caller-added sensitive response header. The response body remains
99    /// transient inside the sealed executor observer. No raw response header
100    /// value enters assessment discovery evidence; normalized media type has
101    /// its own bounded predicate.
102    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        // Even Content-Type parameters are untrusted and may contain tokens.
112        // Discovery uses only RESPONSE_MEDIA_TYPE's normalized essence.
113        restricted.captured_headers.clear();
114        Ok(restricted)
115    }
116
117    /// Configures optional bounded text sampling.
118    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    /// Adds one response header to the evidence allowlist.
138    ///
139    /// Sensitive headers such as `set-cookie` are not included by default and
140    /// should be enabled only when the host's storage policy permits them.
141    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    /// Sets a non-zero ordinal source reliability for emitted evidence.
150    ///
151    /// Zero-confidence observations are rejected because deterministic rules
152    /// currently use declared likelihoods rather than scaling by this metadata.
153    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    /// Returns normalized authorized origins.
165    pub fn allowed_origins(&self) -> &BTreeSet<String> {
166        &self.allowed_origins
167    }
168
169    /// Returns the total request and body-read timeout.
170    pub fn request_timeout(&self) -> Duration {
171        Duration::from_millis(self.request_timeout_ms)
172    }
173
174    /// Returns the maximum buffered body bytes.
175    pub fn max_body_bytes(&self) -> usize {
176        self.max_body_bytes
177    }
178
179    /// Returns the body representation policy.
180    pub fn body_capture(&self) -> HttpBodyCapture {
181        self.body_capture
182    }
183
184    /// Returns captured response header names in stable order.
185    pub fn captured_headers(&self) -> &BTreeSet<String> {
186        &self.captured_headers
187    }
188
189    /// Returns the ordinal reliability attached to each observation.
190    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    /// Validates the complete request URL and enforces this policy's scope.
199    ///
200    /// Callers must use this typed seam instead of comparing serialized origins:
201    /// origin equality alone does not reject unsupported schemes or embedded
202    /// credentials.
203    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}