michal/tit

Browse tree · Show commit · Download archive

Diff

46e73d7742dd9135c3ab80dc

Cargo.lock

Mode 100644100644; object f4e2775996e84a555ba65cff

@@ -180,7 +180,6 @@
  "matchit",
  "memchr",
  "mime",
- "multer",
  "percent-encoding",
  "pin-project-lite",
  "serde_core",
@@ -2429,10 +2428,10 @@
 ]
 
 [[package]]
-name = "multer"
-version = "3.1.0"
+name = "multra"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+checksum = "8781a1be1aec5a4f806836060f714f4f3286f400493ed3568dd8583b57730234"
 dependencies = [
  "bytes",
  "encoding_rs",
@@ -2441,8 +2440,6 @@
  "httparse",
  "memchr",
  "mime",
- "spin",
- "version_check",
 ]
 
 [[package]]
@@ -3343,12 +3340,6 @@
 ]
 
 [[package]]
-name = "spin"
-version = "0.9.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
-
-[[package]]
 name = "spki"
 version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3582,6 +3573,7 @@
  "gix-pack",
  "httpdate",
  "jiff",
+ "multra",
  "pulldown-cmark",
  "rand",
  "rusqlite",

Cargo.toml

Mode 100644100644; object 962fc54987a67112bfe27085

@@ -13,12 +13,13 @@
 [dependencies]
 ammonia = "4.1.4"
 askama = { version = "0.16", default-features = false, features = ["derive", "std"] }
-axum = { version = "0.8", default-features = false, features = ["http1", "multipart", "original-uri", "query", "tokio"] }
+axum = { version = "0.8", default-features = false, features = ["http1", "original-uri", "query", "tokio"] }
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
 gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
 gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }
 httpdate = "1.0.3"
 jiff = { version = "0.2.34", default-features = false, features = ["std"] }
+multra = "1.1.0"
 pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
 rand = "0.10"
 rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }

docs/adr/0011-web-login-sessions.md

Mode 100644100644; object e66a5b838e6827c315912321

@@ -37,8 +37,9 @@
 
 ## Failure and threat cases
 
-Login and signature forms have fixed body limits. Axum and `multer` parse the
-uploaded form within the 64-KiB limit. Challenge and SSHSIG parsing have the
+Login and signature forms have fixed body limits. Axum limits the request body,
+and `multra` parses the uploaded form as a stream within the 64-KiB limit.
+Challenge and SSHSIG parsing have the
 authentication limits from architectural decision record 0002. A bad
 identity, signature, expired challenge, and consumed challenge use a common Web
 UI error. The response does not disclose which input failed.
@@ -56,7 +57,8 @@
 between issue and verification, rejects challenge replay and a bad CSRF token,
 checks the stored hashes, invalidates a session after key addition, and tests
 the function that ends all sessions. The executable test completes login and
-logout through HTTP. It also confirms CSRF rejection and session invalidation.
+logout through HTTP. It rejects an incorrect upload content type and malformed
+multipart content. It also confirms CSRF rejection and session invalidation.
 
 ## Consequences
 

src/http/mod.rs

Mode 100644100644; object 7671cc6a00d13112a0153107

@@ -7,9 +7,7 @@
 use askama::Template;
 use axum::Router;
 use axum::body::{Body, Bytes, HttpBody};
-use axum::extract::{
-    DefaultBodyLimit, Extension, Multipart, OriginalUri, RawQuery, Request, State,
-};
+use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State};
 use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
 use axum::middleware::{self, Next};
 use axum::response::Response;
@@ -390,8 +388,18 @@
     State(state): State<WebState>,
     Extension(request_id): Extension<RequestId>,
     headers: HeaderMap,
-    mut multipart: Multipart,
+    body: Body,
 ) -> Response {
+    let Some(content_type) = headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+    else {
+        return login_error(&request_id.0, "", "The login response is not valid.");
+    };
+    let Ok(boundary) = multra::parse_boundary(content_type) else {
+        return login_error(&request_id.0, "", "The login response is not valid.");
+    };
+    let mut multipart = multra::Multipart::new(body.into_data_stream(), boundary);
     let mut username = None;
     let mut public_key = None;
     let mut challenge = None;

tests/serve.rs

Mode 100644100644; object c6070f477dbd6ef6e9ee3c7a

@@ -145,6 +145,21 @@
     let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
     let upload_csrf_cookies = response_cookies(&upload_challenge_page);
     let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
+    let wrong_upload_type = http_form_with_headers(
+        http,
+        "/login/verify-file",
+        &[("signature-file", &upload_signature)],
+        &[("Cookie", &upload_csrf_cookies)],
+    );
+    assert!(wrong_upload_type.starts_with("HTTP/1.1 400"));
+    let malformed_upload = http_body(
+        http,
+        "/login/verify-file",
+        "multipart/form-data; boundary=tit-broken-boundary",
+        "--tit-broken-boundary\r\ninvalid",
+        &[("Cookie", &upload_csrf_cookies)],
+    );
+    assert!(malformed_upload.starts_with("HTTP/1.1 400"));
     let uploaded = http_multipart(
         http,
         "/login/verify-file",
@@ -502,6 +517,33 @@
     stream
         .write_all(request.as_bytes())
         .expect("write a multipart HTTP form");
+    let mut response = String::new();
+    stream
+        .read_to_string(&mut response)
+        .expect("read an HTTP response");
+    response
+}
+
+fn http_body(
+    address: SocketAddr,
+    path: &str,
+    content_type: &str,
+    body: &str,
+    headers: &[(&str, &str)],
+) -> String {
+    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+    let mut request = format!(
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
+        body.len()
+    );
+    for (name, value) in headers {
+        request.push_str(&format!("{name}: {value}\r\n"));
+    }
+    request.push_str("\r\n");
+    request.push_str(body);
+    stream
+        .write_all(request.as_bytes())
+        .expect("write an HTTP request body");
     let mut response = String::new();
     stream
         .read_to_string(&mut response)