Skip to main content

clawft_kernel/infer_proxy/
http.rs

1//! Minimal HTTP/1.1 for the loopback listener, and the request allowlist.
2//!
3//! One request per connection (`Connection: close`), so there is no
4//! pipelining and no keep-alive state to get wrong. Only origin-form
5//! targets are accepted: an absolute-form target (`GET http://host/...`)
6//! is how a forward proxy becomes an open relay, so it is refused.
7
8use std::net::IpAddr;
9use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
10
11use super::types::{Method, ProxyError, ProxyLimits, ProxyRequest};
12
13/// A parsed request head.
14#[derive(Debug)]
15pub struct Head {
16    /// Method token.
17    pub method: String,
18    /// Request target.
19    pub target: String,
20    /// Header (lower-case name, value) pairs.
21    pub headers: Vec<(String, String)>,
22}
23
24impl Head {
25    /// First value of `name` (lower-case).
26    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
38/// Paths the proxy forwards: the OpenAI-compatible surface, the servers'
39/// health endpoints, and Ollama's inference and read-only endpoints. Model
40/// management (`/api/pull`, `/api/delete`, `/api/create`, ...) is refused.
41pub 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
61/// Origin-form path with no way to change the authority, climb out of the
62/// route, or smuggle control bytes.
63pub 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
87/// `host[:port]` is a loopback name or address (DNS-rebinding guard). The
88/// port, when present, must be digits: `127.0.0.1:80.evil.example` is not
89/// a loopback host.
90pub 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
109/// An `Origin` is acceptable when absent or itself loopback: a web page on
110/// another origin must not drive a local model through the proxy.
111pub 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
124/// Parse a head (without the terminating blank line).
125pub 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
170/// A `\n` not preceded by `\r`: lenient parsers elsewhere accept it, which
171/// is the root of request-smuggling disagreements, so it is refused.
172fn 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
182/// Read one request: head, then exactly `Content-Length` body bytes.
183pub 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
194/// Constant-time equality of two byte strings of any length.
195pub 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
203/// [`read_request`] for a listener with a bearer token (an exposed
204/// listener, see `InferProxy::start_exposed`). The token is checked right
205/// after the head, before any body is read, and then removed from the
206/// request: it is this listener's credential, not the model server's. With
207/// a token the `Host` need not be loopback (the credential, not the
208/// address, is the guard); without one it must be.
209pub 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        // With a bearer token the Authorization header was this listener's
329        // credential: never pass it on.
330        authorization: if bearer.is_some() {
331            None
332        } else {
333            head.header("authorization").map(str::to_string)
334        },
335        body,
336    })
337}
338
339/// Visible ASCII only, bounded: a value that came from a server or a peer
340/// must not be able to end the header block.
341pub 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
368/// Write a status line and headers; the body that follows is delimited by
369/// closing the connection.
370pub 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
385/// A complete JSON error response.
386pub 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}