clawft_kernel/infer_proxy/
http.rs1use std::net::IpAddr;
9use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
10
11use super::types::{Method, ProxyError, ProxyLimits, ProxyRequest};
12
13#[derive(Debug)]
15pub struct Head {
16 pub method: String,
18 pub target: String,
20 pub headers: Vec<(String, String)>,
22}
23
24impl Head {
25 pub fn header(&self, name: &str) -> Option<&str> {
27 self.headers
28 .iter()
29 .find(|(n, _)| n == name)
30 .map(|(_, v)| v.as_str())
31 }
32}
33
34fn io(e: std::io::Error) -> ProxyError {
35 ProxyError::Io(e.to_string())
36}
37
38pub fn path_allowed(path: &str) -> bool {
42 let route = path.split('?').next().unwrap_or("");
43 if route.starts_with("/v1/") {
44 return true;
45 }
46 matches!(
47 route,
48 "/health"
49 | "/v1"
50 | "/api/tags"
51 | "/api/version"
52 | "/api/ps"
53 | "/api/show"
54 | "/api/chat"
55 | "/api/generate"
56 | "/api/embed"
57 | "/api/embeddings"
58 )
59}
60
61pub fn validate_path(path: &str) -> Result<(), ProxyError> {
64 let bad = |m: &str| Err(ProxyError::Forbidden(m.into()));
65 if path.len() > 2048 || !path.starts_with('/') || path.starts_with("//") {
66 return bad("path must be origin-form");
67 }
68 if path
69 .bytes()
70 .any(|b| b <= b' ' || b == 0x7f || b == b'\\' || b == b'#')
71 {
72 return bad("path has forbidden characters");
73 }
74 let route = path.split('?').next().unwrap_or("");
75 let lower = route.to_ascii_lowercase();
76 if route.split('/').any(|s| s == ".." || s == ".")
77 || ["%2e", "%2f", "%5c", "%00"].iter().any(|p| lower.contains(p))
78 {
79 return bad("path traversal");
80 }
81 if !path_allowed(path) {
82 return bad("path is not served by the inference proxy");
83 }
84 Ok(())
85}
86
87pub fn loopback_host(value: &str) -> bool {
91 let v = value.trim();
92 let (host, port) = if let Some(rest) = v.strip_prefix('[') {
93 match rest.split_once(']') {
94 Some((h, tail)) => (h, tail.strip_prefix(':')),
95 None => return false,
96 }
97 } else {
98 match v.split_once(':') {
99 Some((h, p)) => (h, Some(p)),
100 None => (v, None),
101 }
102 };
103 if port.is_some_and(|p| p.is_empty() || p.len() > 5 || !p.bytes().all(|b| b.is_ascii_digit())) {
104 return false;
105 }
106 host.eq_ignore_ascii_case("localhost") || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
107}
108
109pub fn origin_ok(origin: Option<&str>) -> bool {
112 match origin {
113 None => true,
114 Some(o) => o
115 .strip_prefix("http://")
116 .is_some_and(|rest| loopback_host(rest.split('/').next().unwrap_or(""))),
117 }
118}
119
120fn token_char(b: u8) -> bool {
121 b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
122}
123
124pub fn parse_head(raw: &[u8], limits: &ProxyLimits) -> Result<Head, ProxyError> {
126 let bad = |m: &str| ProxyError::BadRequest(m.into());
127 let text = std::str::from_utf8(raw).map_err(|_| bad("head is not ASCII"))?;
128 let mut lines = text.split("\r\n");
129 let line = lines.next().ok_or_else(|| bad("empty request"))?;
130 let mut parts = line.split(' ');
131 let (method, target, version) = (
132 parts.next().unwrap_or(""),
133 parts.next().unwrap_or(""),
134 parts.next().unwrap_or(""),
135 );
136 if parts.next().is_some() || method.is_empty() || target.is_empty() {
137 return Err(bad("malformed request line"));
138 }
139 if !method.bytes().all(token_char) {
140 return Err(bad("bad method"));
141 }
142 if version != "HTTP/1.1" && version != "HTTP/1.0" {
143 return Err(bad("unsupported HTTP version"));
144 }
145 let mut headers = Vec::new();
146 for l in lines {
147 if headers.len() >= limits.max_headers {
148 return Err(ProxyError::TooLarge("too many headers".into()));
149 }
150 if l.starts_with(' ') || l.starts_with('\t') {
151 return Err(bad("folded header"));
152 }
153 let (n, v) = l.split_once(':').ok_or_else(|| bad("malformed header"))?;
154 if n.is_empty() || !n.bytes().all(token_char) {
155 return Err(bad("bad header name"));
156 }
157 let v = v.trim();
158 if v.bytes().any(|b| b < b' ' && b != b'\t' || b == 0x7f) {
159 return Err(bad("control byte in header"));
160 }
161 headers.push((n.to_ascii_lowercase(), v.to_string()));
162 }
163 Ok(Head {
164 method: method.to_string(),
165 target: target.to_string(),
166 headers,
167 })
168}
169
170fn has_bare_lf(buf: &[u8]) -> bool {
173 buf.iter()
174 .enumerate()
175 .any(|(i, b)| *b == b'\n' && (i == 0 || buf[i - 1] != b'\r'))
176}
177
178fn find_end(buf: &[u8]) -> Option<usize> {
179 buf.windows(4).position(|w| w == b"\r\n\r\n")
180}
181
182pub async fn read_request<S>(
184 s: &mut S,
185 role: &str,
186 limits: &ProxyLimits,
187) -> Result<ProxyRequest, ProxyError>
188where
189 S: AsyncRead + AsyncWrite + Unpin,
190{
191 read_request_with(s, role, limits, None).await
192}
193
194pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
196 let mut diff = a.len() ^ b.len();
197 for i in 0..a.len().max(b.len()) {
198 diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0));
199 }
200 diff == 0
201}
202
203pub async fn read_request_with<S>(
210 s: &mut S,
211 role: &str,
212 limits: &ProxyLimits,
213 bearer: Option<&[u8]>,
214) -> Result<ProxyRequest, ProxyError>
215where
216 S: AsyncRead + AsyncWrite + Unpin,
217{
218 let timeout = |what: &str| ProxyError::Timeout(format!("reading request {what}"));
219 let mut buf: Vec<u8> = Vec::with_capacity(1024);
220 let mut chunk = [0u8; 4096];
221 let end = tokio::time::timeout(limits.head_timeout, async {
222 loop {
223 if let Some(i) = find_end(&buf) {
224 return Ok(i);
225 }
226 if buf.len() > limits.max_head_bytes {
227 return Err(ProxyError::TooLarge("request head".into()));
228 }
229 if has_bare_lf(&buf) {
230 return Err(ProxyError::BadRequest("bare LF line ending".into()));
231 }
232 let n = s.read(&mut chunk).await.map_err(io)?;
233 if n == 0 {
234 return Err(ProxyError::BadRequest("connection closed in head".into()));
235 }
236 buf.extend_from_slice(&chunk[..n]);
237 }
238 })
239 .await
240 .map_err(|_| timeout("head"))??;
241 if end > limits.max_head_bytes {
242 return Err(ProxyError::TooLarge("request head".into()));
243 }
244 if has_bare_lf(&buf[..end]) {
245 return Err(ProxyError::BadRequest("bare LF line ending".into()));
246 }
247 let head = parse_head(&buf[..end], limits)?;
248 let mut body = buf[end + 4..].to_vec();
249
250 let method = match head.method.as_str() {
251 "GET" => Method::Get,
252 "POST" => Method::Post,
253 _ => return Err(ProxyError::MethodNotAllowed),
254 };
255 validate_path(&head.target)?;
256 match bearer {
257 Some(token) => {
258 let sent = head
259 .header("authorization")
260 .and_then(|a| a.strip_prefix("Bearer "))
261 .unwrap_or("");
262 if !ct_eq(sent.as_bytes(), token) {
263 return Err(ProxyError::Unauthorized);
264 }
265 }
266 None => match head.header("host") {
267 Some(h) if loopback_host(h) => {}
268 _ => return Err(ProxyError::Forbidden("Host is not loopback".into())),
269 },
270 }
271 if !origin_ok(head.header("origin")) {
272 return Err(ProxyError::Forbidden("cross-origin request".into()));
273 }
274 if head.header("transfer-encoding").is_some() {
275 return Err(ProxyError::Unsupported(
276 "chunked request bodies: send Content-Length".into(),
277 ));
278 }
279 let lens: Vec<&str> = head
280 .headers
281 .iter()
282 .filter(|(n, _)| n == "content-length")
283 .map(|(_, v)| v.as_str())
284 .collect();
285 let want: usize = match lens.as_slice() {
286 [] => 0,
287 [one] if !one.is_empty() && one.bytes().all(|b| b.is_ascii_digit()) => one
288 .parse()
289 .map_err(|_| ProxyError::TooLarge("Content-Length".into()))?,
290 _ => return Err(ProxyError::BadRequest("bad Content-Length".into())),
291 };
292 if want > limits.max_request_body {
293 return Err(ProxyError::TooLarge(format!(
294 "request body over {} bytes",
295 limits.max_request_body
296 )));
297 }
298 if want > body.len() {
299 if head
300 .header("expect")
301 .is_some_and(|e| e.eq_ignore_ascii_case("100-continue"))
302 {
303 s.write_all(b"HTTP/1.1 100 Continue\r\n\r\n")
304 .await
305 .map_err(io)?;
306 }
307 tokio::time::timeout(limits.body_timeout, async {
308 while body.len() < want {
309 let n = s.read(&mut chunk).await.map_err(io)?;
310 if n == 0 {
311 return Err(ProxyError::BadRequest("connection closed in body".into()));
312 }
313 body.extend_from_slice(&chunk[..n]);
314 }
315 Ok(())
316 })
317 .await
318 .map_err(|_| timeout("body"))??;
319 }
320 body.truncate(want);
321
322 Ok(ProxyRequest {
323 role: role.to_string(),
324 method,
325 path: head.target.clone(),
326 content_type: head.header("content-type").map(str::to_string),
327 accept: head.header("accept").map(str::to_string),
328 authorization: if bearer.is_some() {
331 None
332 } else {
333 head.header("authorization").map(str::to_string)
334 },
335 body,
336 })
337}
338
339pub fn safe_header_value(v: &str) -> Option<&str> {
342 (!v.is_empty() && v.len() <= 200 && v.bytes().all(|b| (b' '..0x7f).contains(&b))).then_some(v)
343}
344
345fn reason(status: u16) -> &'static str {
346 match status {
347 200 => "OK",
348 201 => "Created",
349 204 => "No Content",
350 400 => "Bad Request",
351 401 => "Unauthorized",
352 403 => "Forbidden",
353 404 => "Not Found",
354 405 => "Method Not Allowed",
355 408 => "Request Timeout",
356 413 => "Payload Too Large",
357 422 => "Unprocessable Entity",
358 429 => "Too Many Requests",
359 500 => "Internal Server Error",
360 501 => "Not Implemented",
361 502 => "Bad Gateway",
362 503 => "Service Unavailable",
363 504 => "Gateway Timeout",
364 _ => "Status",
365 }
366}
367
368pub async fn write_head<S: AsyncWrite + Unpin>(
371 s: &mut S,
372 status: u16,
373 content_type: Option<&str>,
374) -> Result<(), ProxyError> {
375 let ct = content_type
376 .and_then(safe_header_value)
377 .unwrap_or("application/octet-stream");
378 let head = format!(
379 "HTTP/1.1 {status} {}\r\nContent-Type: {ct}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
380 reason(status)
381 );
382 s.write_all(head.as_bytes()).await.map_err(io)
383}
384
385pub async fn write_error<S: AsyncWrite + Unpin>(s: &mut S, e: &ProxyError) {
387 let body = serde_json::json!({
388 "error": {"message": e.to_string(), "type": "weftos_inference_proxy"}
389 })
390 .to_string();
391 let challenge = if matches!(e, ProxyError::Unauthorized) {
392 "WWW-Authenticate: Bearer\r\n"
393 } else {
394 ""
395 };
396 let head = format!(
397 "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\n{challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
398 e.status(),
399 reason(e.status()),
400 body.len()
401 );
402 let _ = s.write_all(head.as_bytes()).await;
403 let _ = s.write_all(body.as_bytes()).await;
404 let _ = s.shutdown().await;
405}