michal/tit
Browse tree · Show commit · Download archive
Diff
46e73d7742dd → 9135c3ab80dc
Cargo.lock
Mode 100644 → 100644; object f4e2775996e8 → 4a555ba65cff
@@ -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 100644 → 100644; object 962fc54987a6 → 7112bfe27085
@@ -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 100644 → 100644; object e66a5b838e68 → 27c315912321
@@ -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 100644 → 100644; object 7671cc6a00d1 → 3112a0153107
@@ -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 100644 → 100644; object c6070f477dbd → 6ef6e9ee3c7a
@@ -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)