steid

@jamesgill /

steid/src/infrastructure/web/body_limit.rs
Rust9.3 KBCode·Blame·History·Raw
1//! A ceiling on how much of a request body this process will hold.
2//!
3//! **Topcoat 0.5 has none.** `Form<T>` and `Json<T>` both collect the body with
4//! `to_bytes(body, usize::MAX)` — read in `topcoat-router-0.5.0/src/content/form.rs`
5//! and `json.rs` — so every form endpoint, `/auth/login` and `/api/setup` among them,
6//! buffered whatever arrived. One anonymous `POST /auth/login` carrying 200 MB took the
7//! process to 811 MB resident before answering 400; a handful in parallel is the whole
8//! machine. There is no builder knob for it, so the cap is ours to impose.
9//!
10//! Applied in the [`security_headers`](super::security_headers) layer, which is the one
11//! layer at `/` — `.discover()` allows one per path, and a limit that covered only a
12//! subtree would be a limit somebody adds a route outside.
13//!
14//! **The git RPC endpoints are exempt**, and have to be: a push is arbitrarily large and
15//! is the one body Steid deliberately does not collect. `web::git::into_reader` streams
16//! it straight into the subprocess, so what bounds it is the pack git will accept rather
17//! than what this process can hold.
18
19use std::{
20 pin::Pin,
21 task::{Context, Poll},
22};
23
24use bytes::Bytes;
25use http_body::{Body as HttpBody, Frame, SizeHint};
26use topcoat::router::{Body, HeaderMap, Response, StatusCode, header::CONTENT_LENGTH};
27
28/// How much of a request body any non-streaming route may hold.
29///
30/// An issue body, a README pasted into a form, a webhook URL: the largest of them is
31/// kilobytes, so a mebibyte is generous by three orders of magnitude and still small
32/// enough that a hundred concurrent requests are a hundred mebibytes rather than a
33/// hundred gigabytes.
34pub const MAX_BODY_BYTES: u64 = 1024 * 1024;
35
36/// The two paths whose bodies stream and must not be capped.
37///
38/// The whole shape rather than the last segment: `RepoName` does not reserve
39/// `git-receive-pack`, so `/{handle}/repos/git-receive-pack` is a repository somebody
40/// may legitimately have, and matching on the tail alone would exempt it. Four segments
41/// with `repos` in the middle is the route and nothing else. Written out here rather
42/// than asked of the router, because a layer runs before a route is chosen.
43fn streams(path: &str) -> bool {
44 let mut segments = path.trim_start_matches('/').split('/');
45
46 let (Some(_handle), Some("repos"), Some(_repo), Some(endpoint), None) = (
47 segments.next(),
48 segments.next(),
49 segments.next(),
50 segments.next(),
51 segments.next(),
52 ) else {
53 return false;
54 };
55
56 matches!(endpoint, "git-upload-pack" | "git-receive-pack")
57}
58
59/// What a request body should be replaced with, if anything.
60///
61/// `Err` is the refusal to send back: a `Content-Length` above the cap is refused before
62/// a byte is read, which is the cheap case and the one a well-behaved client hits.
63/// `Ok` is the body to carry on with — wrapped in the counter for everything else, since
64/// a chunked body announces no length and can only be caught while it is arriving.
65///
66/// The refusal is boxed because a `Response` is wide and this is the rare arm; clippy
67/// says so, and it is right.
68pub fn capped(path: &str, headers: &HeaderMap, body: Body) -> Result<Body, Box<Response>> {
69 if streams(path) {
70 return Ok(body);
71 }
72
73 if declared_length(headers).is_some_and(|length| length > MAX_BODY_BYTES) {
74 return Err(Box::new(too_large()));
75 }
76
77 Ok(Body::new(Limited {
78 inner: body,
79 remaining: MAX_BODY_BYTES,
80 }))
81}
82
83/// The `Content-Length` the client declared, when it declared a usable one.
84fn declared_length(headers: &HeaderMap) -> Option<u64> {
85 headers.get(CONTENT_LENGTH)?.to_str().ok()?.parse().ok()
86}
87
88/// "That is more than this instance will read."
89///
90/// `text/plain`, for the reason the git transport's refusals are: the caller may well be
91/// `curl` or a script, and a bare status sends whoever is reading it looking in the wrong
92/// place.
93fn too_large() -> Response {
94 Response::builder()
95 .status(StatusCode::PAYLOAD_TOO_LARGE)
96 .header("content-type", "text/plain; charset=utf-8")
97 .body(Body::from(format!(
98 "This request body is larger than the {} bytes this instance reads.\n",
99 MAX_BODY_BYTES
100 )))
101 .expect("a response built from constants")
102}
103
104/// A body that fails rather than yielding more than it was allowed.
105///
106/// Hand-written on `http_body` rather than reaching for `http-body-util`'s `Limited`:
107/// the crate is not a dependency, the trait already is — `web::git::GitBody` is written
108/// the same way — and this is the whole of it.
109///
110/// The error surfaces where the body is collected, so an over-long chunked submission
111/// answers **400 rather than 413**. Worth knowing and not worth machinery: a client that
112/// declares its length gets the honest status, and one that does not is refused either
113/// way without the bytes being kept.
114struct Limited {
115 inner: Body,
116 remaining: u64,
117}
118
119impl HttpBody for Limited {
120 type Data = Bytes;
121 type Error = Box<dyn std::error::Error + Send + Sync>;
122
123 fn poll_frame(
124 mut self: Pin<&mut Self>,
125 cx: &mut Context<'_>,
126 ) -> Poll<Option<Result<Frame<Bytes>, Self::Error>>> {
127 let polled = Pin::new(&mut self.inner).poll_frame(cx);
128
129 let Poll::Ready(Some(Ok(frame))) = polled else {
130 return polled;
131 };
132
133 let Some(data) = frame.data_ref() else {
134 // Trailers carry no payload, so nothing is counted and they pass through.
135 return Poll::Ready(Some(Ok(frame)));
136 };
137
138 match self.remaining.checked_sub(data.len() as u64) {
139 Some(left) => {
140 self.remaining = left;
141 Poll::Ready(Some(Ok(frame)))
142 }
143 None => Poll::Ready(Some(Err(format!(
144 "the request body is larger than the {MAX_BODY_BYTES} bytes this instance reads"
145 )
146 .into()))),
147 }
148 }
149
150 fn size_hint(&self) -> SizeHint {
151 let mut hint = self.inner.size_hint();
152
153 if hint.upper().is_none_or(|upper| upper > self.remaining) {
154 hint.set_upper(self.remaining);
155 }
156
157 hint
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn the_git_rpc_endpoints_are_the_only_exemptions() {
167 assert!(streams("/ada/repos/steid/git-upload-pack"));
168 assert!(streams("/ada/repos/steid/git-receive-pack"));
169
170 assert!(!streams("/auth/login"));
171 assert!(!streams("/api/setup"));
172 assert!(!streams("/ada/repos/steid/info/refs"));
173 // `RepoName` reserves neither name, so this is a repository somebody may
174 // actually have — and matching on the last segment alone would have exempted
175 // its page. This assertion is what caught that.
176 assert!(!streams("/ada/repos/git-receive-pack"));
177 assert!(!streams("/ada/repos/steid/git-receive-pack/more"));
178 }
179
180 fn declared(value: &str) -> Option<u64> {
181 let mut headers = HeaderMap::new();
182 headers.insert(CONTENT_LENGTH, value.parse().expect("a header value"));
183
184 declared_length(&headers)
185 }
186
187 #[test]
188 fn a_length_is_read_when_it_is_a_number_and_ignored_when_it_is_not() {
189 assert_eq!(declared("42"), Some(42));
190 assert_eq!(declared("nonsense"), None);
191 assert_eq!(declared("-1"), None);
192 assert_eq!(declared_length(&HeaderMap::new()), None);
193 }
194
195 #[tokio::test]
196 async fn a_declared_length_over_the_cap_is_refused_before_a_byte_is_read() {
197 let mut headers = HeaderMap::new();
198 headers.insert(
199 CONTENT_LENGTH,
200 (MAX_BODY_BYTES + 1).to_string().parse().expect("a number"),
201 );
202
203 let refused = capped("/auth/login", &headers, Body::empty()).expect_err("too large");
204
205 assert_eq!(refused.status(), StatusCode::PAYLOAD_TOO_LARGE);
206 }
207
208 #[tokio::test]
209 async fn a_body_within_the_cap_arrives_whole() {
210 let body = capped(
211 "/auth/login",
212 &HeaderMap::new(),
213 Body::from(vec![b'a'; 1024]),
214 )
215 .map_err(|_| "should be allowed")
216 .expect("within the cap");
217
218 let bytes = topcoat::router::to_bytes(body, usize::MAX)
219 .await
220 .expect("should collect");
221
222 assert_eq!(bytes.len(), 1024);
223 }
224
225 /// The hole this closed: with no cap the whole body was collected, whatever it was.
226 #[tokio::test]
227 async fn an_undeclared_body_over_the_cap_fails_while_it_arrives() {
228 let body = capped(
229 "/auth/login",
230 &HeaderMap::new(),
231 Body::from(vec![b'a'; (MAX_BODY_BYTES + 1) as usize]),
232 )
233 .map_err(|_| "no declared length, so nothing to refuse up front")
234 .expect("allowed to start");
235
236 assert!(
237 topcoat::router::to_bytes(body, usize::MAX).await.is_err(),
238 "an over-long body must fail rather than be collected"
239 );
240 }
241
242 /// A push is the one body that is streamed rather than held, so capping it would
243 /// cap `git push` at a mebibyte.
244 #[tokio::test]
245 async fn a_push_is_never_capped() {
246 let mut headers = HeaderMap::new();
247 headers.insert(
248 CONTENT_LENGTH,
249 (MAX_BODY_BYTES * 100)
250 .to_string()
251 .parse()
252 .expect("a number"),
253 );
254
255 assert!(capped("/ada/repos/steid/git-receive-pack", &headers, Body::empty()).is_ok());
256 }
257}