michal/tit

Browse tree · Show commit · Download archive

Diff

08988dac2993a30e8497e07e

Cargo.lock

Mode 100644100644; object 9c7c166169c2ecfc7f5cc875

@@ -3628,6 +3628,7 @@
  "tokio",
  "tokio-stream",
  "toml",
+ "tower-http",
  "url",
 ]
 
@@ -3719,6 +3720,23 @@
  "pin-project-lite",
  "sync_wrapper",
  "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytes",
+ "http",
+ "http-body",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project-lite",
  "tower-layer",
  "tower-service",
 ]

Cargo.toml

Mode 100644100644; object 1b8335f1fe40d345a7602e0a

@@ -33,6 +33,7 @@
 tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
 tokio-stream = { version = "0.1", default-features = false }
 toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] }
+tower-http = { version = "0.7", default-features = false, features = ["limit"] }
 url = { version = "2.5", features = ["serde"] }
 
 [dev-dependencies]

README.md

Mode 100644100644; object 0230d5df636e42667fe46152

@@ -66,6 +66,24 @@
 writer can push branches and tags. A reader cannot push. HTTP Git access stays
 read-only.
 
+The `[limits]` configuration sets the maximum HTTP request size and the maximum
+concurrent HTTP requests and SSH sessions. The default values are 1 MiB and
+1024. A route can use a smaller request limit. Each HTTP request has a
+30-second time limit. An HTTP request can wait for one second for a concurrency
+permit.
+
+The server permits 10 Web login attempts per client address in one minute. It
+permits 30 SSH authentication attempts per client address in one minute. It
+keeps attempt state for a maximum of 4096 client addresses. SSH also permits a
+maximum of three public-key authentication attempts on one connection and
+closes an inactive connection after 30 seconds.
+
+Git reads have byte, object-count, entry-count, and 30-second limits. Git
+receive-pack has byte, object-count, delta-depth, and processing-time limits.
+Markdown rendering accepts a maximum of 256 KiB of source and 1 MiB of rendered
+HTML. The Web UI shows a limit message instead of content that exceeds a
+Markdown limit.
+
 Stop the server and show the newest audit events with this command:
 
 ```text
@@ -602,3 +620,17 @@
 data. Read the
 [read-only diagnostics architectural decision record](docs/adr/0030-read-only-diagnostics.md)
 for the check, repair, inspection, and dump contracts.
+
+## Milestone 6.4 gate
+
+Install stock OpenSSH. Then, run the limits gate:
+
+```text
+./scripts/check-m6-4
+```
+
+This command tests HTTP request and login-attempt limits, SSH authentication
+limits, Markdown limits, Git pack limits, and repository diff and archive
+limits. Read the
+[limits and abuse resistance architectural decision record](docs/adr/0031-limits-and-abuse-resistance.md)
+for the limit values and failure behavior.

docs/adr/0031-limits-and-abuse-resistance.md

Mode 100644; object a3fd2f65a7fc

@@ -1,0 +1,81 @@
+# Architectural decision record 0031: limits and abuse resistance
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+HTTP, SSH, Git, and Markdown process data from clients and repositories. A
+client can keep connections active, send large input, or start expensive Git
+operations. A repository can also contain content that is expensive to render.
+
+The configuration already has `max_request_bytes` and `max_connections`.
+Before this decision, the server validated these fields but did not apply them
+at each listener.
+
+## Decision
+
+Apply `max_request_bytes` as a hard limit to the complete HTTP request body.
+A route can apply a smaller limit. The default is 1 MiB. Reject a request that
+exceeds the limit with status 413. The `tower-http` request-body limit supplies
+streaming enforcement and removes the need for a custom HTTP body wrapper.
+
+Apply `max_connections` to concurrent HTTP requests and SSH sessions. The
+default is 1024. An HTTP request can wait for one second for a permit. Reject it
+with status 503 if no permit becomes available. Give each HTTP request 30
+seconds, including body reads and handler work. Return status 408 after this
+time. Russh closes an inactive SSH connection after 30 seconds.
+
+Permit 10 Web login attempts from one client address in one minute. Permit 30
+SSH public-key authentication attempts from one client address in one minute.
+Track a maximum of 4096 client addresses for each interface. Reject a new
+address when the table is full. Remove an address after its attempt window is
+empty. These in-memory limits reset after a server restart. Russh also permits
+only three authentication attempts on one connection.
+
+Keep the existing Git limits. Upload-pack and receive-pack limit packet-line
+input, pack bytes, object bytes, object counts, and generated pack bytes.
+Receive-pack also limits command counts, delta depth, reachable-object walks,
+and processing time. Repository views limit refs, history, paths, trees, blobs,
+diffs, archives, searches, and operation time.
+
+Accept a maximum of 256 KiB of Markdown source and 1 MiB of sanitized HTML.
+Return a fixed safe message when a rendering limit is exceeded. Issue, comment,
+pull-request, and review validation applies the same source limit before
+storage. The Markdown limit also protects README rendering from a large Git
+blob.
+
+## Failure and threat cases
+
+The rate tables use client network addresses, not usernames. Thus, an attacker
+cannot use a victim username to prevent login by that user. A shared network
+address shares one limit. This can delay users behind the same proxy, but it
+also bounds work from that proxy. A subsequent change can use a trusted proxy
+address only after the server has a complete forwarded-address policy.
+
+The HTTP concurrency limit counts requests instead of TCP connections. An idle
+HTTP connection does not consume an application permit, but the 30-second
+request limit bounds an incomplete active request after request processing
+starts. Operating-system file-descriptor limits remain a separate deployment
+boundary.
+
+## Evidence
+
+Unit tests prove the attempt-window and tracked-address limits. Web server tests
+send more than 10 login attempts and a request larger than 1 MiB. Markdown
+tests exceed the source and rendered-output limits. Existing Git tests exceed
+packet-line, pack, object, diff, archive, search, and operation limits. Stock
+OpenSSH tests prove the per-connection authentication and inactivity settings.
+
+The complete quality gate runs all tests and the release build. The hosted gate
+runs on Linux and macOS.
+
+## Consequences
+
+The limits are process-local and use safe fixed defaults. The administrator can
+decrease the HTTP request and concurrency limits, but cannot configure values
+larger than 256 MiB and 100000 connections.
+
+Repository read limits remain code constants because they are product safety
+boundaries. They are not tuning settings.

scripts/check-m6-4

Mode 100755; object b6ee35c03a83

@@ -1,0 +1,11 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test web_shell enforces_request_and_login_attempt_limits
+cargo test --locked --release --test ssh limits_authentication_attempts_by_client_address
+cargo test --locked --release --bin tit markdown::tests::bounds_source_and_rendered_content
+cargo test --locked --release --bin tit rate_limit::tests::bounds_attempts_and_tracked_keys
+cargo test --locked --release --test git_reads enforces_each_read_boundary_and_rejects_unsafe_paths
+cargo test --locked --release --test git_reads produces_bounded_diffs_blame_and_streaming_archives
+cargo test --locked --release --test git_repository reads_sorted_refs_and_generates_complete_packs_for_both_hashes

src/config.rs

Mode 100644100644; object 1bdbb2c0b55cbc1971664d64

@@ -12,6 +12,8 @@
 
 const CONFIG_VERSION: u32 = 1;
 const SYSTEM_CONFIG_PATH: &str = "/srv/tit/config.toml";
+const MAX_CONFIG_REQUEST_BYTES: u64 = 256 * 1024 * 1024;
+const MAX_CONFIG_CONNECTIONS: u32 = 100_000;
 
 #[derive(Debug)]
 pub(crate) struct Config {
@@ -57,8 +59,14 @@
         if self.max_request_bytes == 0 {
             return Err(ConfigError::ZeroLimit("max_request_bytes"));
         }
+        if self.max_request_bytes > MAX_CONFIG_REQUEST_BYTES {
+            return Err(ConfigError::LimitTooLarge("max_request_bytes"));
+        }
         if self.max_connections == 0 {
             return Err(ConfigError::ZeroLimit("max_connections"));
+        }
+        if self.max_connections > MAX_CONFIG_CONNECTIONS {
+            return Err(ConfigError::LimitTooLarge("max_connections"));
         }
         if self.signup_policy != SignupPolicy::Invite {
             return Err(ConfigError::UnsupportedSignupPolicy);
@@ -242,6 +250,8 @@
     ZeroListenerPort(&'static str),
     #[error("configuration limit {0} must not be zero")]
     ZeroLimit(&'static str),
+    #[error("configuration limit {0} is too large")]
+    LimitTooLarge(&'static str),
     #[error("signup policy is not supported")]
     UnsupportedSignupPolicy,
     #[error("trusted proxy address is not a unicast address: {0}")]
@@ -575,6 +585,39 @@
         assert!(matches!(
             load(&cli(&path)),
             Err(ConfigError::ZeroLimit("max_request_bytes"))
+        ));
+    }
+
+    #[test]
+    fn rejects_limits_that_are_too_large() {
+        let (_directory, path) = write_config(
+            r#"
+version = 1
+public_url = "https://tit.example/"
+
+[limits]
+max_request_bytes = 268435457
+max_connections = 1
+"#,
+        );
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::LimitTooLarge("max_request_bytes"))
+        ));
+
+        let (_directory, path) = write_config(
+            r#"
+version = 1
+public_url = "https://tit.example/"
+
+[limits]
+max_request_bytes = 1
+max_connections = 100001
+"#,
+        );
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::LimitTooLarge("max_connections"))
         ));
     }
 

src/http/mod.rs

Mode 100644100644; object 3cc16c7a0d2c02535c022c12

@@ -5,7 +5,7 @@
 mod pull_requests;
 mod watches;
 
-use std::net::SocketAddr;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
 use std::path::PathBuf;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, Ordering};
@@ -14,7 +14,9 @@
 use askama::Template;
 use axum::Router;
 use axum::body::{Body, Bytes, HttpBody};
-use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State};
+use axum::extract::{
+    ConnectInfo, DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State,
+};
 use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
 use axum::middleware::{self, Next};
 use axum::response::Response;
@@ -23,6 +25,7 @@
 use tokio::net::TcpListener;
 use tokio::sync::{Semaphore, oneshot};
 use tokio::task::JoinHandle;
+use tower_http::limit::RequestBodyLimitLayer;
 
 use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
@@ -31,6 +34,7 @@
 use crate::issue::IssueService;
 use crate::maintenance::MaintenanceGate;
 use crate::pull_request::PullRequestService;
+use crate::rate_limit::AttemptLimiter;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::search::MetadataSearchService;
 use crate::session::{SessionError, WebLoginService};
@@ -43,6 +47,10 @@
 const MAX_LOCATION_QUERY_BYTES: usize = 512;
 const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
 const MAX_BLOCKING_WEB_JOBS: usize = 8;
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+const CONCURRENCY_WAIT: Duration = Duration::from_secs(1);
+const LOGIN_ATTEMPTS_PER_MINUTE: usize = 10;
+const MAX_LOGIN_CLIENTS: usize = 4096;
 const SESSION_COOKIE: &str = "tit-session";
 const CSRF_COOKIE: &str = "tit-csrf";
 const LOGIN_CSRF_COOKIE: &str = "tit-login-csrf";
@@ -52,6 +60,9 @@
     public: Option<PublicWeb>,
     accounts: Option<AccountService>,
     jobs: Arc<Semaphore>,
+    requests: Arc<Semaphore>,
+    login_attempts: AttemptLimiter<IpAddr>,
+    max_request_bytes: usize,
     key_reloader: Option<AccountKeyReloader>,
     login: Option<WebLoginService>,
     repositories: Option<RepositoryService>,
@@ -67,6 +78,9 @@
 #[derive(Clone)]
 pub(super) struct RequestActor(pub(super) Option<String>);
 
+#[derive(Clone, Copy)]
+struct ClientAddress(IpAddr);
+
 type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;
 
 #[derive(Clone, Debug)]
@@ -74,6 +88,8 @@
     pub(crate) instance_dir: PathBuf,
     pub(crate) http_clone_base: String,
     pub(crate) ssh_clone_base: String,
+    pub(crate) max_request_bytes: usize,
+    pub(crate) max_connections: usize,
 }
 
 #[derive(Clone, Default)]
@@ -109,6 +125,9 @@
                 public: None,
                 accounts: None,
                 jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
+                requests: Arc::new(Semaphore::new(1024)),
+                login_attempts: login_attempt_limiter(),
+                max_request_bytes: 1024 * 1024,
                 key_reloader: None,
                 login: None,
                 repositories: None,
@@ -156,6 +175,8 @@
         maintenance: Option<MaintenanceGate>,
     ) -> Result<Self, WebError> {
         let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
+        let requests = Arc::new(Semaphore::new(config.max_connections));
+        let max_request_bytes = config.max_request_bytes;
         let database = config.instance_dir.join(crate::store::DATABASE_FILE);
         let accounts = AccountService::new(database.clone());
         let public_url = url::Url::parse(&format!("{}/", config.http_clone_base))
@@ -185,6 +206,9 @@
                 public: Some(public),
                 accounts: Some(accounts),
                 jobs,
+                requests,
+                login_attempts: login_attempt_limiter(),
+                max_request_bytes,
                 key_reloader,
                 login: Some(login),
                 repositories: Some(repositories),
@@ -205,11 +229,14 @@
         let address = listener.local_addr()?;
         let (shutdown, receiver) = oneshot::channel();
         let task = tokio::spawn(async move {
-            axum::serve(listener, router_with_state(state))
-                .with_graceful_shutdown(async {
-                    let _ = receiver.await;
-                })
-                .await
+            axum::serve(
+                listener,
+                router_with_state(state).into_make_service_with_connect_info::<SocketAddr>(),
+            )
+            .with_graceful_shutdown(async {
+                let _ = receiver.await;
+            })
+            .await
         });
         Ok(Self {
             address,
@@ -249,6 +276,9 @@
         public: None,
         accounts: None,
         jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
+        requests: Arc::new(Semaphore::new(1024)),
+        login_attempts: login_attempt_limiter(),
+        max_request_bytes: 1024 * 1024,
         key_reloader: None,
         login: None,
         repositories: None,
@@ -263,6 +293,7 @@
 }
 
 fn router_with_state(state: WebState) -> Router {
+    let max_request_bytes = state.max_request_bytes;
     let repository_routes = metadata_search::routes()
         .merge(watches::routes())
         .merge(issues::routes())
@@ -316,8 +347,62 @@
         .merge(repository_routes)
         .fallback(not_found)
         .method_not_allowed_fallback(method_not_allowed)
-        .with_state(state)
+        .layer(RequestBodyLimitLayer::new(max_request_bytes))
+        .layer(middleware::from_fn_with_state(state.clone(), request_guard))
         .layer(middleware::from_fn(response_policy))
+        .with_state(state)
+}
+
+fn login_attempt_limiter() -> AttemptLimiter<IpAddr> {
+    AttemptLimiter::new(
+        LOGIN_ATTEMPTS_PER_MINUTE,
+        Duration::from_secs(60),
+        MAX_LOGIN_CLIENTS,
+    )
+}
+
+async fn request_guard(
+    State(state): State<WebState>,
+    mut request: Request,
+    next: Next,
+) -> Response {
+    let address = request
+        .extensions()
+        .get::<ConnectInfo<SocketAddr>>()
+        .map(|peer| peer.0.ip())
+        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
+    request.extensions_mut().insert(ClientAddress(address));
+    let permit = match tokio::time::timeout(
+        CONCURRENCY_WAIT,
+        state.requests.clone().acquire_owned(),
+    )
+    .await
+    {
+        Ok(Ok(permit)) => permit,
+        _ => return limit_response(StatusCode::SERVICE_UNAVAILABLE, "Server is busy.\n"),
+    };
+    let response = tokio::time::timeout(REQUEST_TIMEOUT, next.run(request)).await;
+    drop(permit);
+    match response {
+        Ok(response) => response,
+        Err(_) => limit_response(
+            StatusCode::REQUEST_TIMEOUT,
+            "Request time limit exceeded.\n",
+        ),
+    }
+}
+
+fn limit_response(status: StatusCode, message: &'static str) -> Response {
+    Response::builder()
+        .status(status)
+        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::from(message))
+        .expect("the limit response is valid")
+}
+
+fn allow_login_attempt(state: &WebState, peer: ClientAddress) -> bool {
+    state.login_attempts.allow(peer.0)
 }
 
 async fn repository_actor(
@@ -468,10 +553,17 @@
 
 async fn login_challenge(
     State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
     Extension(request_id): Extension<RequestId>,
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
+    if !allow_login_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Login attempt limit exceeded.\n",
+        );
+    }
     let fields = match parse_named_form(&headers, &body, &["username", "public-key"]) {
         Ok(fields) => fields,
         Err(()) => return login_error(&request_id.0, "", "The login request is not valid."),
@@ -513,10 +605,17 @@
 
 async fn login_verify(
     State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
     Extension(request_id): Extension<RequestId>,
     headers: HeaderMap,
     body: Bytes,
 ) -> Response {
+    if !allow_login_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Login attempt limit exceeded.\n",
+        );
+    }
     let fields = match parse_named_form(
         &headers,
         &body,
@@ -562,10 +661,17 @@
 
 async fn login_verify_file(
     State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
     Extension(request_id): Extension<RequestId>,
     headers: HeaderMap,
     body: Body,
 ) -> Response {
+    if !allow_login_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Login attempt limit exceeded.\n",
+        );
+    }
     let Some(content_type) = headers
         .get(header::CONTENT_TYPE)
         .and_then(|value| value.to_str().ok())

src/main.rs

Mode 100644100644; object 7a8922efeb132c43c46a067a

@@ -24,6 +24,7 @@
 mod markdown;
 mod policy;
 mod pull_request;
+mod rate_limit;
 mod repair;
 mod repository;
 mod search;

src/markdown.rs

Mode 100644100644; object ef0956bec51751625fb7772c

@@ -5,6 +5,10 @@
 use pulldown_cmark::{Event, Parser, Tag, TagEnd, html};
 use url::Url;
 
+pub(crate) const MAX_MARKDOWN_BYTES: usize = 256 * 1024;
+const MAX_RENDERED_BYTES: usize = 1024 * 1024;
+const LIMIT_MESSAGE: &str = "<p>Markdown content exceeds the rendering limit.</p>\n";
+
 #[derive(Default)]
 pub struct RenderedMarkdown(String);
 
@@ -15,6 +19,9 @@
 }
 
 pub fn render(source: &str) -> RenderedMarkdown {
+    if source.len() > MAX_MARKDOWN_BYTES {
+        return RenderedMarkdown(LIMIT_MESSAGE.to_owned());
+    }
     let mut skipped_link = false;
     let events = Parser::new(source).filter_map(|event| match event {
         Event::Start(Tag::Link { ref dest_url, .. }) if !safe_link(dest_url) => {
@@ -67,7 +74,12 @@
         .url_relative(UrlRelative::PassThrough)
         .link_rel(Some("nofollow noopener noreferrer"));
 
-    RenderedMarkdown(sanitizer.clean(&rendered).to_string())
+    let rendered = sanitizer.clean(&rendered).to_string();
+    if rendered.len() > MAX_RENDERED_BYTES {
+        RenderedMarkdown(LIMIT_MESSAGE.to_owned())
+    } else {
+        RenderedMarkdown(rendered)
+    }
 }
 
 fn safe_link(destination: &str) -> bool {
@@ -135,5 +147,17 @@
 
         assert!(output.contains("Text &amp; markup."));
         assert!(!output.contains("href="));
+    }
+
+    #[test]
+    fn bounds_source_and_rendered_content() {
+        assert_eq!(
+            render(&"x".repeat(super::MAX_MARKDOWN_BYTES + 1)).to_string(),
+            super::LIMIT_MESSAGE
+        );
+        assert_eq!(
+            render(&"<".repeat(super::MAX_MARKDOWN_BYTES)).to_string(),
+            super::LIMIT_MESSAGE
+        );
     }
 }

src/rate_limit.rs

Mode 100644; object b83f0ea02d27

@@ -1,0 +1,73 @@
+use std::collections::{HashMap, VecDeque};
+use std::hash::Hash;
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+#[derive(Clone)]
+pub(crate) struct AttemptLimiter<K> {
+    inner: Arc<Mutex<AttemptState<K>>>,
+    maximum: usize,
+    window: Duration,
+    maximum_keys: usize,
+}
+
+struct AttemptState<K> {
+    attempts: HashMap<K, VecDeque<Instant>>,
+}
+
+impl<K> AttemptLimiter<K>
+where
+    K: Clone + Eq + Hash,
+{
+    pub(crate) fn new(maximum: usize, window: Duration, maximum_keys: usize) -> Self {
+        Self {
+            inner: Arc::new(Mutex::new(AttemptState {
+                attempts: HashMap::new(),
+            })),
+            maximum,
+            window,
+            maximum_keys,
+        }
+    }
+
+    pub(crate) fn allow(&self, key: K) -> bool {
+        let now = Instant::now();
+        let Ok(mut state) = self.inner.lock() else {
+            return false;
+        };
+        state.attempts.retain(|_, attempts| {
+            while attempts
+                .front()
+                .is_some_and(|attempt| now.duration_since(*attempt) >= self.window)
+            {
+                attempts.pop_front();
+            }
+            !attempts.is_empty()
+        });
+        if !state.attempts.contains_key(&key) && state.attempts.len() >= self.maximum_keys {
+            return false;
+        }
+        let attempts = state.attempts.entry(key).or_default();
+        if attempts.len() >= self.maximum {
+            return false;
+        }
+        attempts.push_back(now);
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::AttemptLimiter;
+    use std::time::Duration;
+
+    #[test]
+    fn bounds_attempts_and_tracked_keys() {
+        let limiter = AttemptLimiter::new(2, Duration::from_secs(60), 2);
+        assert!(limiter.allow("first"));
+        assert!(limiter.allow("first"));
+        assert!(!limiter.allow("first"));
+        assert!(limiter.allow("second"));
+        assert!(!limiter.allow("third"));
+    }
+}

src/serve.rs

Mode 100644100644; object 88484a7317e6b733dc48053e

@@ -68,6 +68,10 @@
             instance_dir: config.instance_dir.clone(),
             http_clone_base,
             ssh_clone_base,
+            max_request_bytes: usize::try_from(config.max_request_bytes)
+                .map_err(|_| ServeError::RequestLimit)?,
+            max_connections: usize::try_from(config.max_connections)
+                .map_err(|_| ServeError::ConnectionLimit)?,
         },
         reload_keys,
         readiness.clone(),
@@ -79,6 +83,7 @@
         authorized_keys,
         git,
         host_key,
+        usize::try_from(config.max_connections).map_err(|_| ServeError::ConnectionLimit)?,
     )
     .await
     {
@@ -284,6 +289,10 @@
     HostKeyPermissions { path: PathBuf, mode: u32 },
     #[error(transparent)]
     HostKey(#[from] ssh_key::Error),
+    #[error("the HTTP request limit does not fit this platform")]
+    RequestLimit,
+    #[error("the connection limit does not fit this platform")]
+    ConnectionLimit,
 }
 
 #[cfg(test)]

src/ssh.rs

Mode 100644100644; object 268ef49f624d5c2212fe8c9e

@@ -1,6 +1,6 @@
 use std::borrow::Cow;
 use std::collections::{HashMap, HashSet};
-use std::net::SocketAddr;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
 use std::sync::atomic::{AtomicUsize, Ordering};
 use std::sync::{Arc, RwLock};
 use std::time::Duration;
@@ -12,7 +12,7 @@
 use thiserror::Error;
 use tokio::io::AsyncWriteExt;
 use tokio::net::TcpListener;
-use tokio::sync::oneshot;
+use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot};
 use tokio::task::JoinHandle;
 
 use crate::auth::SshPublicKey;
@@ -22,6 +22,7 @@
 use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
 use crate::issue::{IssueError, IssueService, MAX_BODY_BYTES, MAX_TITLE_BYTES};
 use crate::policy::RepositoryOperation;
+use crate::rate_limit::AttemptLimiter;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::store::{Store, StoreError};
 
@@ -32,6 +33,8 @@
 const MAX_ISSUE_COMMAND_BYTES: usize = 512;
 const MAX_PULL_REQUEST_COMMAND_BYTES: usize = 512;
 const MAX_ISSUE_INPUT_BYTES: usize = MAX_TITLE_BYTES + 1 + MAX_BODY_BYTES;
+const SSH_ATTEMPTS_PER_MINUTE: usize = 30;
+const MAX_SSH_CLIENTS: usize = 4096;
 const REPOSITORY_CREATE_USAGE: &str =
     "repo create NAME [--object-format sha1|sha256] [--output human|json]";
 const ISSUE_LIST_USAGE: &str = "issue list OWNER/REPOSITORY [--output human|json]";
@@ -146,10 +149,18 @@
         authorized_keys: AuthorizedSshKeys,
         repositories: GitRepositories,
         host_key: PrivateKey,
+        max_connections: usize,
     ) -> Result<Self, SshServerError> {
         recover_pushes(&repositories).await?;
-        Self::start_inner_with_keys(address, authorized_keys, &[], Some(repositories), host_key)
-            .await
+        Self::start_inner_with_keys(
+            address,
+            authorized_keys,
+            &[],
+            Some(repositories),
+            host_key,
+            max_connections,
+        )
+        .await
     }
 
     pub(crate) async fn start_with_git_writes(
@@ -183,6 +194,7 @@
             writable_keys,
             repositories,
             host_key,
+            1024,
         )
         .await
     }
@@ -193,6 +205,7 @@
         writable_keys: &[SshPublicKey],
         repositories: Option<GitRepositories>,
         host_key: PrivateKey,
+        max_connections: usize,
     ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
         let address = listener.local_addr()?;
@@ -229,6 +242,12 @@
             writable_keys,
             audit: Arc::clone(&audit),
             repositories: repositories.map(Arc::new),
+            connections: Arc::new(Semaphore::new(max_connections)),
+            attempts: AttemptLimiter::new(
+                SSH_ATTEMPTS_PER_MINUTE,
+                Duration::from_secs(60),
+                MAX_SSH_CLIENTS,
+            ),
         };
         let (handle_sender, handle_receiver) = oneshot::channel();
         let task = tokio::spawn(async move {
@@ -312,12 +331,14 @@
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
+    connections: Arc<Semaphore>,
+    attempts: AttemptLimiter<IpAddr>,
 }
 
 impl Server for SshServer {
     type Handler = SshSession;
 
-    fn new_client(&mut self, _peer_address: Option<SocketAddr>) -> Self::Handler {
+    fn new_client(&mut self, peer_address: Option<SocketAddr>) -> Self::Handler {
         SshSession {
             authorized_keys: self.authorized_keys.clone(),
             writable_keys: Arc::clone(&self.writable_keys),
@@ -328,6 +349,11 @@
             authenticated_identity: None,
             authenticated_key: None,
             authenticated_writer: false,
+            peer_address: peer_address
+                .map(|address| address.ip())
+                .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
+            attempts: self.attempts.clone(),
+            _connection_permit: self.connections.clone().try_acquire_owned().ok(),
         }
     }
 }
@@ -342,6 +368,9 @@
     authenticated_identity: Option<SshIdentity>,
     authenticated_key: Option<PublicKey>,
     authenticated_writer: bool,
+    peer_address: IpAddr,
+    attempts: AttemptLimiter<IpAddr>,
+    _connection_permit: Option<OwnedSemaphorePermit>,
 }
 
 enum ExecChannel {
@@ -393,6 +422,9 @@
         _user: &str,
         public_key: &PublicKey,
     ) -> Result<Auth, Self::Error> {
+        if self._connection_permit.is_none() || !self.attempts.allow(self.peer_address) {
+            return Ok(Auth::reject());
+        }
         if let Some(identity) = self.authorized_keys.identity(public_key) {
             self.authenticated_identity = Some(identity);
             self.authenticated_key = Some(public_key.clone());

tests/git_push_ssh.rs

Mode 100644100644; object 6a14ee0ce247179035d0559f

@@ -22,6 +22,8 @@
 #[allow(dead_code, reason = "the SSH push test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[path = "../src/rate_limit.rs"]
+mod rate_limit;
 #[allow(dead_code, reason = "the SSH push test does not create repositories")]
 #[path = "../src/repository.rs"]
 mod repository;

tests/git_ssh.rs

Mode 100644100644; object 12b069b25e008e85534522bd

@@ -22,6 +22,8 @@
 #[allow(dead_code, reason = "the SSH Git test does not use repository policy")]
 #[path = "../src/policy.rs"]
 mod policy;
+#[path = "../src/rate_limit.rs"]
+mod rate_limit;
 #[allow(dead_code, reason = "the SSH Git test does not create repositories")]
 #[path = "../src/repository.rs"]
 mod repository;

tests/public_routes.rs

Mode 100644100644; object aa8a0814df7f1501c8e25bb9

@@ -51,6 +51,8 @@
 )]
 #[path = "../src/pull_request.rs"]
 mod pull_request;
+#[path = "../src/rate_limit.rs"]
+mod rate_limit;
 #[allow(
     dead_code,
     reason = "the public route test does not create repositories through forms"
@@ -95,6 +97,8 @@
                 instance_dir: fixture.instance.path().to_owned(),
                 http_clone_base: "https://tit.example".to_owned(),
                 ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+                max_request_bytes: 1024 * 1024,
+                max_connections: 1024,
             },
         )
         .await
@@ -616,6 +620,8 @@
             instance_dir: fixture.instance.path().to_owned(),
             http_clone_base: "http://127.0.0.1".to_owned(),
             ssh_clone_base: "ssh://tit.example:2222".to_owned(),
+            max_request_bytes: 1024 * 1024,
+            max_connections: 1024,
         },
     )
     .await
@@ -1477,12 +1483,21 @@
     head.push_str("\r\n");
     stream
         .write_all(head.as_bytes())
-        .and_then(|()| stream.write_all(body))
-        .expect("write an HTTP request");
+        .expect("write HTTP request headers");
+    if let Err(error) = stream.write_all(body)
+        && !matches!(
+            error.kind(),
+            std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
+        )
+    {
+        panic!("write an HTTP request: {error}");
+    }
     let mut response = Vec::new();
-    stream
-        .read_to_end(&mut response)
-        .expect("read an HTTP response");
+    if let Err(error) = stream.read_to_end(&mut response)
+        && error.kind() != std::io::ErrorKind::ConnectionReset
+    {
+        panic!("read an HTTP response: {error}");
+    }
     HttpResponse::parse(&response)
 }
 

tests/ssh.rs

Mode 100644100644; object 67a71e186b462b2a660466cf

@@ -25,6 +25,8 @@
 )]
 #[path = "../src/policy.rs"]
 mod policy;
+#[path = "../src/rate_limit.rs"]
+mod rate_limit;
 #[allow(
     dead_code,
     reason = "the SSH identity test does not create repositories"
@@ -115,6 +117,28 @@
         .shutdown()
         .await
         .expect("stop the RSA SSH server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn limits_authentication_attempts_by_client_address() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let server = start(&[parse_public_key(&private_key)]).await;
+
+    for attempt in 0..30 {
+        let output = ssh(&server, &private_key, "alice", &["tit --version"]);
+        assert!(
+            output.status.success(),
+            "authentication attempt {} failed: {}",
+            attempt + 1,
+            String::from_utf8_lossy(&output.stderr)
+        );
+    }
+    let limited = ssh(&server, &private_key, "alice", &["tit --version"]);
+    assert!(!limited.status.success());
+
+    server.shutdown().await.expect("stop the SSH server");
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]

tests/web_shell.rs

Mode 100644100644; object c4ab206cf7a214f949804b8c

@@ -46,6 +46,8 @@
 #[allow(dead_code, reason = "the Web shell test does not use pull requests")]
 #[path = "../src/pull_request.rs"]
 mod pull_request;
+#[path = "../src/rate_limit.rs"]
+mod rate_limit;
 #[allow(dead_code, reason = "the Web shell test does not create repositories")]
 #[path = "../src/repository.rs"]
 mod repository;
@@ -243,6 +245,23 @@
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn enforces_request_and_login_attempt_limits() {
+    let server = start().await;
+
+    for _ in 0..10 {
+        assert_eq!(request(server.address(), "POST", "/login", &[]).status, 400);
+    }
+    let limited = request(server.address(), "POST", "/login", &[]);
+    assert_eq!(limited.status, 429);
+    assert_eq!(limited.body, "Login attempt limit exceeded.\n");
+
+    let oversized = request_with_declared_length(server.address(), "/", 1024 * 1024 + 1);
+    assert_eq!(oversized.status, 413);
+
+    server.shutdown().await.expect("stop the Web server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn cancels_a_connection_after_the_shutdown_drain_limit() {
     let server = start().await;
     let mut stalled = tokio::net::TcpStream::connect(server.address())
@@ -284,6 +303,24 @@
     stream
         .write_all(request.as_bytes())
         .expect("write an HTTP request");
+    let mut bytes = Vec::new();
+    stream
+        .read_to_end(&mut bytes)
+        .expect("read an HTTP response");
+    HttpResponse::parse(&bytes)
+}
+
+fn request_with_declared_length(
+    address: SocketAddr,
+    path: &str,
+    content_length: usize,
+) -> HttpResponse {
+    let mut stream = TcpStream::connect(address).expect("connect to the Web server");
+    write!(
+        stream,
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {content_length}\r\n\r\n"
+    )
+    .expect("write an HTTP request");
     let mut bytes = Vec::new();
     stream
         .read_to_end(&mut bytes)