termivar_scanner/plugin/
transport.rs1use async_trait::async_trait;
2use serde::Serialize;
3use std::{collections::BTreeMap, fmt};
4use tokio_util::sync::CancellationToken;
5use url::Url;
6
7use super::{
8 limits::{
9 invalid_config, HARD_MAX_PLUGIN_RESPONSE_BODY_BYTES, MAX_PLUGIN_HEADERS,
10 MAX_PLUGIN_HEADER_NAME_BYTES, MAX_PLUGIN_HEADER_VALUE_BYTES, MAX_PLUGIN_URL_BYTES,
11 },
12 PluginError,
13};
14
15#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "UPPERCASE")]
19pub enum PluginHttpMethod {
20 Get,
22 Head,
24 Options,
26}
27
28pub struct PluginHttpRequest {
30 pub(super) method: PluginHttpMethod,
31 pub(super) url: Url,
32 pub(super) max_response_body_bytes: u64,
33 pub(super) cancellation: CancellationToken,
34}
35
36impl PluginHttpRequest {
37 pub const fn method(&self) -> PluginHttpMethod {
39 self.method
40 }
41
42 pub fn url(&self) -> &Url {
44 &self.url
45 }
46
47 pub const fn max_response_body_bytes(&self) -> u64 {
53 self.max_response_body_bytes
54 }
55
56 pub fn cancellation(&self) -> &CancellationToken {
58 &self.cancellation
59 }
60}
61
62impl fmt::Debug for PluginHttpRequest {
63 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64 formatter
65 .debug_struct("PluginHttpRequest")
66 .field("method", &self.method)
67 .field("origin", &origin_string(&self.url))
68 .field("path", &"[redacted]")
69 .field("max_response_body_bytes", &self.max_response_body_bytes)
70 .finish_non_exhaustive()
71 }
72}
73
74pub struct PluginHttpResponse {
76 status: u16,
77 final_url: Url,
78 headers: BTreeMap<String, String>,
79 body: Vec<u8>,
80 pub(super) delivered_body_bytes: u64,
81 body_truncated: bool,
82}
83
84impl PluginHttpResponse {
85 pub fn new(status: u16, final_url: Url, body: Vec<u8>) -> Result<Self, PluginError> {
87 if !(100..=599).contains(&status) {
88 return Err(invalid_config(
89 "plugin broker returned an invalid HTTP status",
90 ));
91 }
92 if body.len() > HARD_MAX_PLUGIN_RESPONSE_BODY_BYTES as usize {
93 return Err(PluginError::ResponseBodyBudgetExceeded {
94 actual: u64::try_from(body.len()).unwrap_or(u64::MAX),
95 maximum: HARD_MAX_PLUGIN_RESPONSE_BODY_BYTES,
96 });
97 }
98 let delivered_body_bytes = u64::try_from(body.len()).unwrap_or(u64::MAX);
99 Ok(Self {
100 status,
101 final_url,
102 headers: BTreeMap::new(),
103 body,
104 delivered_body_bytes,
105 body_truncated: false,
106 })
107 }
108
109 pub fn with_header(
111 mut self,
112 name: impl Into<String>,
113 value: impl Into<String>,
114 ) -> Result<Self, PluginError> {
115 let name = name.into().to_ascii_lowercase();
116 let value = value.into();
117 validate_header(&name, &value)?;
118 if !self.headers.contains_key(&name) && self.headers.len() >= MAX_PLUGIN_HEADERS {
119 return Err(invalid_config(
120 "plugin response header count exceeds the maximum",
121 ));
122 }
123 self.headers.insert(name, value);
124 Ok(self)
125 }
126
127 pub fn with_capture_metadata(
129 mut self,
130 delivered_body_bytes: u64,
131 body_truncated: bool,
132 ) -> Result<Self, PluginError> {
133 let retained = u64::try_from(self.body.len()).unwrap_or(u64::MAX);
134 if delivered_body_bytes < retained {
135 return Err(invalid_config(
136 "delivered response bytes cannot be smaller than retained bytes",
137 ));
138 }
139 if !body_truncated && delivered_body_bytes != retained {
140 return Err(invalid_config(
141 "an incomplete response body must be marked truncated",
142 ));
143 }
144 self.delivered_body_bytes = delivered_body_bytes;
145 self.body_truncated = body_truncated;
146 Ok(self)
147 }
148
149 pub const fn status(&self) -> u16 {
151 self.status
152 }
153
154 pub fn final_url(&self) -> &Url {
156 &self.final_url
157 }
158
159 pub fn header(&self, name: &str) -> Option<&str> {
161 self.headers
162 .get(&name.to_ascii_lowercase())
163 .map(String::as_str)
164 }
165
166 pub fn body(&self) -> &[u8] {
168 &self.body
169 }
170
171 pub const fn delivered_body_bytes(&self) -> u64 {
173 self.delivered_body_bytes
174 }
175
176 pub const fn body_truncated(&self) -> bool {
178 self.body_truncated
179 }
180}
181
182impl fmt::Debug for PluginHttpResponse {
183 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184 formatter
185 .debug_struct("PluginHttpResponse")
186 .field("status", &self.status)
187 .field("final_origin", &origin_string(&self.final_url))
188 .field("header_count", &self.headers.len())
189 .field("retained_body_bytes", &self.body.len())
190 .field("delivered_body_bytes", &self.delivered_body_bytes)
191 .field("body_truncated", &self.body_truncated)
192 .finish()
193 }
194}
195
196#[async_trait]
203pub trait PluginRequestBroker: Send + Sync {
204 async fn execute(&self, request: PluginHttpRequest) -> Result<PluginHttpResponse, PluginError>;
206}
207
208pub(super) fn validate_authorized_origin(origin: &Url) -> Result<(), PluginError> {
209 if !matches!(origin.scheme(), "http" | "https")
210 || origin.host_str().is_none()
211 || !origin.username().is_empty()
212 || origin.password().is_some()
213 || origin.path() != "/"
214 || origin.query().is_some()
215 || origin.fragment().is_some()
216 || origin.as_str().len() > MAX_PLUGIN_URL_BYTES
217 {
218 return Err(PluginError::ScopeViolation);
219 }
220 Ok(())
221}
222
223pub(super) fn validate_scoped_url(origin: &Url, url: &Url) -> Result<(), PluginError> {
224 if !matches!(url.scheme(), "http" | "https")
225 || url.host_str().is_none()
226 || !url.username().is_empty()
227 || url.password().is_some()
228 || url.fragment().is_some()
229 || url.origin() != origin.origin()
230 {
231 return Err(PluginError::ScopeViolation);
232 }
233 Ok(())
234}
235
236pub(super) fn origin_string(url: &Url) -> String {
237 url.origin().ascii_serialization()
238}
239
240fn validate_header(name: &str, value: &str) -> Result<(), PluginError> {
241 if name.is_empty()
242 || name.len() > MAX_PLUGIN_HEADER_NAME_BYTES
243 || value.len() > MAX_PLUGIN_HEADER_VALUE_BYTES
244 || !name
245 .bytes()
246 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
247 || value.contains(['\r', '\n'])
248 {
249 return Err(invalid_config("plugin response header is invalid"));
250 }
251 Ok(())
252}