Skip to main content

netget/server/fastcgi/
record.rs

1//! FastCGI 1.0 records, name-value pairs and CGI responses, shared by the responder and the
2//! client. Every length is checked against what the peer announced before anything is
3//! allocated for it.
4use anyhow::{bail, ensure, Context, Result};
5use serde_json::{json, Map, Value};
6use tokio::io::{AsyncRead, AsyncReadExt};
7
8pub const VERSION: u8 = 1;
9pub const HEADER_LEN: usize = 8;
10/// Largest content a record can carry; stream data is split at a multiple of 8 below it.
11pub const MAX_CONTENT: usize = 65_535;
12const CHUNK: usize = 65_528;
13
14pub const BEGIN_REQUEST: u8 = 1;
15pub const ABORT_REQUEST: u8 = 2;
16pub const END_REQUEST: u8 = 3;
17pub const PARAMS: u8 = 4;
18pub const STDIN: u8 = 5;
19pub const STDOUT: u8 = 6;
20pub const STDERR: u8 = 7;
21pub const DATA: u8 = 8;
22pub const GET_VALUES: u8 = 9;
23pub const GET_VALUES_RESULT: u8 = 10;
24pub const UNKNOWN_TYPE: u8 = 11;
25
26pub const ROLE_RESPONDER: u16 = 1;
27pub const ROLE_AUTHORIZER: u16 = 2;
28pub const ROLE_FILTER: u16 = 3;
29pub const KEEP_CONN: u8 = 1;
30
31pub const REQUEST_COMPLETE: u8 = 0;
32pub const CANT_MPX_CONN: u8 = 1;
33pub const OVERLOADED: u8 = 2;
34pub const UNKNOWN_ROLE: u8 = 3;
35
36/// Total bytes of name-value pairs accepted for one request's PARAMS stream.
37pub const MAX_PARAMS_BYTES: usize = 64 * 1024;
38pub const MAX_PAIRS: usize = 512;
39/// Request body (STDIN) and response (STDOUT) bound.
40pub const MAX_BODY_BYTES: usize = 1024 * 1024;
41pub const MAX_STDERR_BYTES: usize = 64 * 1024;
42
43pub fn protocol_status_name(s: u8) -> &'static str {
44    match s {
45        REQUEST_COMPLETE => "request_complete",
46        CANT_MPX_CONN => "cant_mpx_conn",
47        OVERLOADED => "overloaded",
48        UNKNOWN_ROLE => "unknown_role",
49        _ => "unknown",
50    }
51}
52
53#[derive(Debug, Clone, PartialEq)]
54pub struct Record {
55    pub kind: u8,
56    pub request_id: u16,
57    pub content: Vec<u8>,
58}
59
60/// Read one record. `Ok(None)` is a clean end of stream at a record boundary.
61pub async fn read_record<R: AsyncRead + Unpin>(r: &mut R) -> Result<Option<Record>> {
62    let mut header = [0u8; HEADER_LEN];
63    let mut got = 0;
64    while got < HEADER_LEN {
65        let n = r.read(&mut header[got..]).await?;
66        if n == 0 {
67            if got == 0 {
68                return Ok(None);
69            }
70            bail!("stream ended inside a record header");
71        }
72        got += n;
73    }
74    ensure!(
75        header[0] == VERSION,
76        "unsupported FastCGI version {}",
77        header[0]
78    );
79    let content_len = u16::from_be_bytes([header[4], header[5]]) as usize;
80    let padding = header[6] as usize;
81    let mut body = vec![0u8; content_len + padding];
82    r.read_exact(&mut body)
83        .await
84        .context("stream ended inside a record")?;
85    body.truncate(content_len);
86    Ok(Some(Record {
87        kind: header[1],
88        request_id: u16::from_be_bytes([header[2], header[3]]),
89        content: body,
90    }))
91}
92
93/// One record, padded to a multiple of 8 as the specification recommends.
94pub fn encode(kind: u8, request_id: u16, content: &[u8]) -> Vec<u8> {
95    assert!(
96        content.len() <= MAX_CONTENT,
97        "record content over 65535 bytes"
98    );
99    let padding = (8 - content.len() % 8) % 8;
100    let mut out = Vec::with_capacity(HEADER_LEN + content.len() + padding);
101    out.push(VERSION);
102    out.push(kind);
103    out.extend_from_slice(&request_id.to_be_bytes());
104    out.extend_from_slice(&(content.len() as u16).to_be_bytes());
105    out.push(padding as u8);
106    out.push(0);
107    out.extend_from_slice(content);
108    out.resize(out.len() + padding, 0);
109    out
110}
111
112/// A whole stream (PARAMS, STDIN, STDOUT, STDERR): as many records as it takes, then the empty
113/// record that ends it.
114pub fn encode_stream(kind: u8, request_id: u16, data: &[u8]) -> Vec<u8> {
115    let mut out = Vec::new();
116    for chunk in data.chunks(CHUNK) {
117        out.extend(encode(kind, request_id, chunk));
118    }
119    out.extend(encode(kind, request_id, &[]));
120    out
121}
122
123pub fn begin_request(role: u16, flags: u8) -> [u8; 8] {
124    let r = role.to_be_bytes();
125    [r[0], r[1], flags, 0, 0, 0, 0, 0]
126}
127
128pub fn end_request(app_status: u32, protocol_status: u8) -> [u8; 8] {
129    let a = app_status.to_be_bytes();
130    [a[0], a[1], a[2], a[3], protocol_status, 0, 0, 0]
131}
132
133pub fn unknown_type(kind: u8) -> [u8; 8] {
134    [kind, 0, 0, 0, 0, 0, 0, 0]
135}
136
137fn put_len(out: &mut Vec<u8>, n: usize) {
138    if n < 128 {
139        out.push(n as u8);
140    } else {
141        out.extend_from_slice(&((n as u32) | 0x8000_0000).to_be_bytes());
142    }
143}
144
145pub fn encode_pairs<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Vec<u8> {
146    let mut out = Vec::new();
147    for (k, v) in pairs {
148        put_len(&mut out, k.len());
149        put_len(&mut out, v.len());
150        out.extend_from_slice(k.as_bytes());
151        out.extend_from_slice(v.as_bytes());
152    }
153    out
154}
155
156/// Decode name-value pairs. Names must be printable ASCII; values may be any bytes and are
157/// decoded lossily (a CGI value is text in practice).
158pub fn decode_pairs(data: &[u8]) -> Result<Vec<(String, String)>> {
159    let mut out = Vec::new();
160    let mut i = 0;
161    let len = |i: &mut usize| -> Result<usize> {
162        let b = *data.get(*i).context("truncated name-value length")?;
163        if b < 0x80 {
164            *i += 1;
165            Ok(b as usize)
166        } else {
167            let bytes = data
168                .get(*i..*i + 4)
169                .context("truncated name-value length")?;
170            *i += 4;
171            Ok(
172                (u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & 0x7fff_ffff)
173                    as usize,
174            )
175        }
176    };
177    while i < data.len() {
178        ensure!(
179            out.len() < MAX_PAIRS,
180            "more than {MAX_PAIRS} name-value pairs"
181        );
182        let nl = len(&mut i)?;
183        let vl = len(&mut i)?;
184        ensure!(
185            nl <= data.len() && vl <= data.len() && i + nl + vl <= data.len(),
186            "name-value pair longer than its stream"
187        );
188        let name = &data[i..i + nl];
189        ensure!(
190            !name.is_empty() && name.iter().all(|b| b.is_ascii_graphic()),
191            "name-value pair name is not printable ASCII"
192        );
193        let value = String::from_utf8_lossy(&data[i + nl..i + nl + vl]).into_owned();
194        out.push((String::from_utf8_lossy(name).into_owned(), value));
195        i += nl + vl;
196    }
197    Ok(out)
198}
199
200/// Body bytes for an event: text when they are UTF-8, hex otherwise, and which.
201pub fn body_text(bytes: &[u8]) -> (String, &'static str) {
202    match std::str::from_utf8(bytes) {
203        Ok(s) => (s.to_owned(), "utf8"),
204        Err(_) => (hex::encode(bytes), "hex"),
205    }
206}
207
208/// Body bytes from an action's `body` and `body_encoding`.
209pub fn decode_body(v: &Value) -> Result<Vec<u8>> {
210    let body = match v.get("body") {
211        None | Some(Value::Null) => return Ok(Vec::new()),
212        Some(Value::String(s)) => s,
213        Some(_) => bail!("body must be a string"),
214    };
215    let bytes = match v["body_encoding"].as_str().unwrap_or("utf8") {
216        "utf8" => body.as_bytes().to_vec(),
217        "hex" => hex::decode(body).context("body is not valid hex")?,
218        other => bail!("body_encoding must be utf8 or hex, not {other}"),
219    };
220    ensure!(bytes.len() <= MAX_BODY_BYTES, "body over 1 MiB");
221    Ok(bytes)
222}
223
224/// Header names are HTTP tokens; values carry no line breaks or other controls.
225pub fn check_headers(headers: Option<&Value>) -> Result<Vec<(String, String)>> {
226    let Some(h) = headers.filter(|h| !h.is_null()) else {
227        return Ok(vec![]);
228    };
229    let map = h.as_object().context("headers must be an object")?;
230    ensure!(map.len() <= 64, "at most 64 headers");
231    map.iter()
232        .map(|(k, v)| {
233            ensure!(
234                !k.is_empty()
235                    && k.len() <= 256
236                    && k.bytes()
237                        .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)),
238                "header name {k:?} is not an HTTP token"
239            );
240            let v = v
241                .as_str()
242                .with_context(|| format!("header {k} must be a string"))?;
243            ensure!(
244                v.len() <= 8192 && !v.chars().any(|c| c.is_control() && c != '\t'),
245                "header {k} contains a control character"
246            );
247            Ok((k.clone(), v.to_owned()))
248        })
249        .collect()
250}
251
252fn reason(status: u16) -> &'static str {
253    hyper::StatusCode::from_u16(status)
254        .ok()
255        .and_then(|s| s.canonical_reason())
256        .unwrap_or("Unknown")
257}
258
259/// A CGI response (RFC 3875 ยง6): `Status:`, the handler's headers, a blank line, the body.
260pub fn build_cgi_response(status: u16, headers: &[(String, String)], body: &[u8]) -> Vec<u8> {
261    let mut out = format!("Status: {status} {}\r\n", reason(status)).into_bytes();
262    let mut typed = false;
263    for (k, v) in headers {
264        if k.eq_ignore_ascii_case("status") {
265            continue;
266        }
267        typed |= k.eq_ignore_ascii_case("content-type");
268        out.extend_from_slice(format!("{k}: {v}\r\n").as_bytes());
269    }
270    if !typed {
271        out.extend_from_slice(b"Content-Type: text/plain; charset=utf-8\r\n");
272    }
273    out.extend_from_slice(b"\r\n");
274    out.extend_from_slice(body);
275    out
276}
277
278/// Parse a CGI response: header lines up to the first blank line (CRLF or LF), `Status`
279/// (default 200, or 302 with a `Location`), the rest the body.
280pub fn parse_cgi_response(stdout: &[u8]) -> Result<(u16, Map<String, Value>, Vec<u8>)> {
281    let (head, body) = match find(stdout, b"\r\n\r\n") {
282        Some(i) => (&stdout[..i], &stdout[i + 4..]),
283        None => match find(stdout, b"\n\n") {
284            Some(i) => (&stdout[..i], &stdout[i + 2..]),
285            None => bail!("CGI response has no end of headers"),
286        },
287    };
288    ensure!(head.len() <= 64 * 1024, "CGI response headers over 64 KiB");
289    let head = std::str::from_utf8(head).context("CGI response headers are not UTF-8")?;
290    let mut headers = Map::new();
291    let mut status = None;
292    for line in head.split('\n').map(|l| l.trim_end_matches('\r')) {
293        if line.is_empty() {
294            continue;
295        }
296        let (k, v) = line
297            .split_once(':')
298            .with_context(|| format!("CGI header line without a colon: {line:?}"))?;
299        let (k, v) = (k.trim(), v.trim());
300        if k.eq_ignore_ascii_case("status") {
301            let code = v.split_whitespace().next().unwrap_or("");
302            let code: u16 = code.parse().context("Status is not a number")?;
303            ensure!((100..=599).contains(&code), "Status {code} out of range");
304            status = Some(code);
305        } else {
306            headers.insert(k.to_ascii_lowercase(), json!(v));
307        }
308    }
309    let status = status.unwrap_or(if headers.contains_key("location") {
310        302
311    } else {
312        200
313    });
314    Ok((status, headers, body.to_vec()))
315}
316
317fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
318    hay.windows(needle.len()).position(|w| w == needle)
319}