michal/tit
Browse tree · Show commit · Download archive
Diff
08988dac2993 → a30e8497e07e
Cargo.lock
Mode 100644 → 100644; object 9c7c166169c2 → ecfc7f5cc875
@@ -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 100644 → 100644; object 1b8335f1fe40 → d345a7602e0a
@@ -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 100644 → 100644; object 0230d5df636e → 42667fe46152
@@ -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 100644 → 100644; object 1bdbb2c0b55c → bc1971664d64
@@ -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 100644 → 100644; object 3cc16c7a0d2c → 02535c022c12
@@ -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 100644 → 100644; object 7a8922efeb13 → 2c43c46a067a
@@ -24,6 +24,7 @@ mod markdown; mod policy; mod pull_request; +mod rate_limit; mod repair; mod repository; mod search;
src/markdown.rs
Mode 100644 → 100644; object ef0956bec517 → 51625fb7772c
@@ -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 & 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 100644 → 100644; object 88484a7317e6 → b733dc48053e
@@ -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 100644 → 100644; object 268ef49f624d → 5c2212fe8c9e
@@ -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 100644 → 100644; object 6a14ee0ce247 → 179035d0559f
@@ -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 100644 → 100644; object 12b069b25e00 → 8e85534522bd
@@ -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 100644 → 100644; object aa8a0814df7f → 1501c8e25bb9
@@ -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 100644 → 100644; object 67a71e186b46 → 2b2a660466cf
@@ -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 100644 → 100644; object c4ab206cf7a2 → 14f949804b8c
@@ -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)