From 034015d3cf93969dad02e4a4c231645867c09042 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Sat, 17 Sep 2022 13:21:48 +0100 Subject: [PATCH] Add shared rate limiting to all endpoints --- Cargo.lock | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/Cargo.toml | 2 ++ chartered-web/src/main.rs | 11 ++++++++--- chartered-web/src/middleware/mod.rs | 1 + chartered-web/src/middleware/rate_limit.rs | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ chartered-web/src/endpoints/cargo_api/mod.rs | 29 ++++++++++++++++++++++++----- chartered-web/src/endpoints/web_api/mod.rs | 26 ++++++++++++++++---------- chartered-web/src/endpoints/web_api/auth/mod.rs | 43 ++++++++++++++++++++++++++++++++++++------- chartered-web/src/endpoints/web_api/crates/mod.rs | 39 ++++++++++++++++++++++++++++++--------- chartered-web/src/endpoints/web_api/organisations/mod.rs | 18 ++++++++++++++---- chartered-web/src/endpoints/web_api/sessions/mod.rs | 11 ++++++++--- chartered-web/src/endpoints/web_api/users/mod.rs | 20 +++++++++++++++----- 12 files changed, 367 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c444881..b9a9124 100644 --- a/Cargo.lock +++ a/Cargo.lock @@ -738,10 +738,12 @@ "chrono", "clap", "futures", + "governor", "headers", "hex", "nom", "nom-bytes", + "nonzero_ext", "oauth2", "once_cell", "openid", @@ -901,6 +903,16 @@ ] [[package]] +name = "crossbeam-utils" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -947,6 +959,19 @@ checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" dependencies = [ "cipher 0.3.0", +] + +[[package]] +name = "dashmap" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] @@ -1215,6 +1240,12 @@ version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6508c467c73851293f390476d4491cf4d227dbabcd4170f3bb6044959b294f1" + +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" @@ -1255,6 +1286,23 @@ "libc", "wasi 0.11.0+wasi-snapshot-preview1", "wasm-bindgen", +] + +[[package]] +name = "governor" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19775995ee20209163239355bc3ad2f33f83da35d9ef72dea26e5af753552c87" +dependencies = [ + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "quanta", + "rand", + "smallvec", ] [[package]] @@ -1611,6 +1659,15 @@ ] [[package]] +name = "mach" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +dependencies = [ + "libc", +] + +[[package]] name = "matches" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1716,6 +1773,12 @@ ] [[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] name = "nom" version = "7.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1733,6 +1796,12 @@ "bytes", "nom", ] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "num" @@ -2102,6 +2171,22 @@ ] [[package]] +name = "quanta" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20afe714292d5e879d8b12740aa223c6a88f118af41870e8b6196e39a02238a8" +dependencies = [ + "crossbeam-utils", + "libc", + "mach", + "once_cell", + "raw-cpuid", + "wasi 0.10.0+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] name = "quote" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2149,6 +2234,15 @@ checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ "getrandom", +] + +[[package]] +name = "raw-cpuid" +version = "10.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6823ea29436221176fe662da99998ad3b4db2c7f31e7b6f5fe43adccd6320bb" +dependencies = [ + "bitflags", ] [[package]] diff --git a/chartered-web/Cargo.toml b/chartered-web/Cargo.toml index d8e5973..c8e23d1 100644 --- a/chartered-web/Cargo.toml +++ a/chartered-web/Cargo.toml @@ -19,8 +19,10 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "3", features = ["cargo", "derive", "std", "suggestions", "color"] } futures = "0.3" +governor = "0.4" headers = "0.3" hex = "0.4" +nonzero_ext = "0.3.0" nom = "7" nom-bytes = { git = "https://github.com/w4/nom-bytes" } oauth2 = "4.2" diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs index 42dae1b..1083c5b 100644 --- a/chartered-web/src/main.rs +++ a/chartered-web/src/main.rs @@ -6,12 +6,15 @@ mod endpoints; mod middleware; +use crate::middleware::rate_limit::RateLimit; use axum::{ http::{header, Method}, routing::get, Extension, Router, }; use clap::{crate_name, crate_version, Parser}; +use governor::Quota; +use nonzero_ext::nonzero; use std::{fmt::Formatter, path::PathBuf, sync::Arc}; use thiserror::Error; use tower::ServiceBuilder; @@ -68,11 +71,13 @@ .user_agent(format!("{}/{}", crate_name!(), crate_version!())) .build()?; + let rate_limit = RateLimit::new(Quota::per_hour(nonzero!(5000_u32))); + let app = Router::new() .route("/", get(hello_world)) .nest( "/web/v1", - endpoints::web_api::authenticated_routes().layer( + endpoints::web_api::authenticated_routes(&rate_limit).layer( ServiceBuilder::new() .layer_fn(crate::middleware::web_auth::WebAuthMiddleware) .into_inner(), @@ -80,11 +85,11 @@ ) .nest( "/web/v1/public", - endpoints::web_api::unauthenticated_routes(), + endpoints::web_api::unauthenticated_routes(&rate_limit), ) .nest( "/a/:key/o/:organisation/api/v1", - endpoints::cargo_api::routes().layer( + endpoints::cargo_api::routes(&rate_limit).layer( ServiceBuilder::new() .layer_fn(crate::middleware::cargo_auth::CargoAuthMiddleware) .into_inner(), diff --git a/chartered-web/src/middleware/mod.rs b/chartered-web/src/middleware/mod.rs index d452bf7..0016701 100644 --- a/chartered-web/src/middleware/mod.rs +++ a/chartered-web/src/middleware/mod.rs @@ -1,3 +1,4 @@ pub mod cargo_auth; pub mod logging; +pub mod rate_limit; pub mod web_auth; diff --git a/chartered-web/src/middleware/rate_limit.rs b/chartered-web/src/middleware/rate_limit.rs new file mode 100644 index 0000000..8ec2d6f 100644 --- /dev/null +++ a/chartered-web/src/middleware/rate_limit.rs @@ -1,0 +1,125 @@ +use crate::endpoints::ErrorResponse; + +use axum::{ + body::{boxed, Body, BoxBody}, + extract::{self, FromRequest, RequestParts}, + http::{Request, StatusCode}, + response::Response, +}; +use futures::future::BoxFuture; +use governor::{clock::DefaultClock, state::keyed::DefaultKeyedStateStore, Quota, RateLimiter}; +use tower::{Layer, Service}; + +use std::{ + net::IpAddr, + num::NonZeroU32, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; + +pub struct RateLimit { + governor: Arc, DefaultClock>>, + counter: Arc, +} + +impl RateLimit { + pub fn new(quota: Quota) -> Self { + Self { + governor: Arc::new(RateLimiter::keyed(quota)), + counter: Arc::new(AtomicUsize::new(0)), + } + } + + pub fn with_cost(&self, cost: u32) -> RateLimitLayer { + RateLimitLayer { + governor: self.governor.clone(), + counter: self.counter.clone(), + cost: NonZeroU32::new(cost).unwrap(), + } + } +} + +pub struct RateLimitLayer { + governor: Arc, DefaultClock>>, + counter: Arc, + cost: NonZeroU32, +} + +impl Layer for RateLimitLayer { + type Service = RateLimitMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + RateLimitMiddleware { + inner, + governor: self.governor.clone(), + counter: self.counter.clone(), + cost: self.cost, + } + } +} + +#[derive(Clone)] +pub struct RateLimitMiddleware { + inner: S, + governor: Arc, DefaultClock>>, + counter: Arc, + cost: NonZeroU32, +} + +impl Service> for RateLimitMiddleware +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + ReqBody: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + // ensure we take the instance that has already been poll_ready'd + let clone = self.clone(); + let mut this = std::mem::replace(self, clone); + + Box::pin(async move { + let mut req = RequestParts::new(req); + let socket_addr = extract::ConnectInfo::::from_request(&mut req) + .await + .map(|v| v.0); + + if let Ok(socket_addr) = socket_addr { + // TODO: cloudflare? + let addr = socket_addr.ip(); + + if let Err(_e) = this.governor.check_key_n(&addr, this.cost) { + return Ok(Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(boxed(Body::from( + serde_json::to_vec(&ErrorResponse { + error: Some( + "You are being rate limited. Please wait a bit and try again." + .into(), + ), + }) + .unwrap(), + ))) + .unwrap()); + } + + // every 500 requests, clear out keys that haven't been used in a while + if this.counter.fetch_add(1, Ordering::AcqRel) % 500 == 0 { + this.governor.retain_recent(); + } + } + + this.inner.call(req.try_into_request().unwrap()).await + }) + } +} diff --git a/chartered-web/src/endpoints/cargo_api/mod.rs b/chartered-web/src/endpoints/cargo_api/mod.rs index 5800477..258e3e8 100644 --- a/chartered-web/src/endpoints/cargo_api/mod.rs +++ a/chartered-web/src/endpoints/cargo_api/mod.rs @@ -11,20 +11,37 @@ mod publish; mod yank; +use crate::RateLimit; use axum::{ + handler::Handler, routing::{delete, get, put}, Router, }; // requests are already authenticated before this router -pub fn routes() -> Router { +pub fn routes(rate_limit: &RateLimit) -> Router { Router::new() - .route("/crates/new", put(publish::handle)) + .route( + "/crates/new", + put(publish::handle.layer(rate_limit.with_cost(200))), + ) // .route("/crates/search", get(hello_world)) - .route("/crates/:crate/owners", get(owners::handle_get)) + .route( + "/crates/:crate/owners", + get(owners::handle_get.layer(rate_limit.with_cost(1))), + ) // .route("/crates/:crate/owners", put(hello_world)) // .route("/crates/:crate/owners", delete(hello_world)) - .route("/crates/:crate/:version/yank", delete(yank::handle_yank)) - .route("/crates/:crate/:version/unyank", put(yank::handle_unyank)) - .route("/crates/:crate/:version/download", get(download::handle)) + .route( + "/crates/:crate/:version/yank", + delete(yank::handle_yank.layer(rate_limit.with_cost(50))), + ) + .route( + "/crates/:crate/:version/unyank", + put(yank::handle_unyank.layer(rate_limit.with_cost(50))), + ) + .route( + "/crates/:crate/:version/download", + get(download::handle.layer(rate_limit.with_cost(1))), + ) } diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs index 08450e8..292612d 100644 --- a/chartered-web/src/endpoints/web_api/mod.rs +++ a/chartered-web/src/endpoints/web_api/mod.rs @@ -5,25 +5,31 @@ mod ssh_key; mod users; +use crate::RateLimit; use axum::{ + handler::Handler, routing::{delete, get}, Router, }; -pub fn authenticated_routes() -> Router { +pub fn authenticated_routes(rate_limit: &RateLimit) -> Router { Router::new() - .nest("/organisations", organisations::routes()) - .nest("/crates", crates::routes()) - .nest("/users", users::routes()) - .nest("/auth", auth::authenticated_routes()) - .nest("/sessions", sessions::routes()) + .nest("/organisations", organisations::routes(rate_limit)) + .nest("/crates", crates::routes(rate_limit)) + .nest("/users", users::routes(rate_limit)) + .nest("/auth", auth::authenticated_routes(rate_limit)) + .nest("/sessions", sessions::routes(rate_limit)) .route( "/ssh-key", - get(ssh_key::handle_get).put(ssh_key::handle_put), + get(ssh_key::handle_get.layer(rate_limit.with_cost(1))) + .put(ssh_key::handle_put.layer(rate_limit.with_cost(24))), ) - .route("/ssh-key/:id", delete(ssh_key::handle_delete)) + .route( + "/ssh-key/:id", + delete(ssh_key::handle_delete.layer(rate_limit.with_cost(24))), + ) } -pub fn unauthenticated_routes() -> Router { - Router::new().nest("/auth", auth::unauthenticated_routes()) +pub fn unauthenticated_routes(rate_limit: &RateLimit) -> Router { + Router::new().nest("/auth", auth::unauthenticated_routes(rate_limit)) } diff --git a/chartered-web/src/endpoints/web_api/auth/mod.rs b/chartered-web/src/endpoints/web_api/auth/mod.rs index 4f0f6cc..de7ff37 100644 --- a/chartered-web/src/endpoints/web_api/auth/mod.rs +++ a/chartered-web/src/endpoints/web_api/auth/mod.rs @@ -1,3 +1,4 @@ +use axum::handler::Handler; use axum::{ extract, routing::{get, post}, @@ -8,27 +9,49 @@ uuid::Uuid, ConnectionPool, }; - use serde::Serialize; + +use crate::middleware::rate_limit::RateLimit; pub mod extend; pub mod logout; pub mod openid; pub mod password; -pub fn authenticated_routes() -> Router { +pub fn authenticated_routes(rate_limit: &RateLimit) -> Router { Router::new() - .route("/logout", get(logout::handle)) - .route("/extend", get(extend::handle)) + .route( + "/logout", + get(logout::handle.layer(rate_limit.with_cost(5))), + ) + .route( + "/extend", + get(extend::handle.layer(rate_limit.with_cost(1))), + ) } -pub fn unauthenticated_routes() -> Router { +pub fn unauthenticated_routes(rate_limit: &RateLimit) -> Router { Router::new() - .route("/register/password", post(password::handle_register)) - .route("/login/password", post(password::handle_login)) - .route("/login/oauth/:provider/begin", get(openid::begin_oidc)) - .route("/login/oauth/complete", get(openid::complete_oidc)) - .route("/login/oauth/providers", get(openid::list_providers)) + .route( + "/register/password", + post(password::handle_register.layer(rate_limit.with_cost(200))), + ) + .route( + "/login/password", + post(password::handle_login.layer(rate_limit.with_cost(100))), + ) + .route( + "/login/oauth/:provider/begin", + get(openid::begin_oidc.layer(rate_limit.with_cost(1))), + ) + .route( + "/login/oauth/complete", + get(openid::complete_oidc.layer(rate_limit.with_cost(75))), + ) + .route( + "/login/oauth/providers", + get(openid::list_providers.layer(rate_limit.with_cost(1))), + ) } #[derive(Serialize)] diff --git a/chartered-web/src/endpoints/web_api/crates/mod.rs b/chartered-web/src/endpoints/web_api/crates/mod.rs index 91163cc..dcac16b 100644 --- a/chartered-web/src/endpoints/web_api/crates/mod.rs +++ a/chartered-web/src/endpoints/web_api/crates/mod.rs @@ -5,20 +5,37 @@ mod recently_updated; mod search; +use crate::middleware::rate_limit::RateLimit; +use axum::handler::Handler; use axum::{routing::get, Router}; -pub fn routes() -> Router { +pub fn routes(rate_limit: &RateLimit) -> Router { Router::new() - .route("/:org/:crate", get(info::handle)) .route( + "/:org/:crate", + get(info::handle.layer(rate_limit.with_cost(1))), + ) + .route( "/:org/:crate/members", - get(members::handle_get) - .patch(members::handle_patch) - .put(members::handle_put) - .delete(members::handle_delete), - ) - .route("/recently-updated", get(recently_updated::handle)) - .route("/recently-created", get(recently_created::handle)) - .route("/most-downloaded", get(most_downloaded::handle)) - .route("/search", get(search::handle)) + get(members::handle_get.layer(rate_limit.with_cost(1))) + .patch(members::handle_patch.layer(rate_limit.with_cost(10))) + .put(members::handle_put.layer(rate_limit.with_cost(10))) + .delete(members::handle_delete.layer(rate_limit.with_cost(10))), + ) + .route( + "/recently-updated", + get(recently_updated::handle.layer(rate_limit.with_cost(1))), + ) + .route( + "/recently-created", + get(recently_created::handle.layer(rate_limit.with_cost(1))), + ) + .route( + "/most-downloaded", + get(most_downloaded::handle.layer(rate_limit.with_cost(1))), + ) + .route( + "/search", + get(search::handle.layer(rate_limit.with_cost(5))), + ) } diff --git a/chartered-web/src/endpoints/web_api/organisations/mod.rs b/chartered-web/src/endpoints/web_api/organisations/mod.rs index a79f5de..f723916 100644 --- a/chartered-web/src/endpoints/web_api/organisations/mod.rs +++ a/chartered-web/src/endpoints/web_api/organisations/mod.rs @@ -1,21 +1,31 @@ mod crud; mod info; mod list; mod members; +use crate::middleware::rate_limit::RateLimit; use axum::{ + handler::Handler, routing::{get, patch}, Router, }; -pub fn routes() -> Router { +pub fn routes(rate_limit: &RateLimit) -> Router { Router::new() - .route("/", get(list::handle_get).put(crud::handle_put)) - .route("/:org", get(info::handle_get)) .route( + "/", + get(list::handle_get.layer(rate_limit.with_cost(1))) + .put(crud::handle_put.layer(rate_limit.with_cost(100))), + ) + .route( + "/:org", + get(info::handle_get.layer(rate_limit.with_cost(1))), + ) + .route( "/:org/members", patch(members::handle_patch) .put(members::handle_put) - .delete(members::handle_delete), + .delete(members::handle_delete) + .layer(rate_limit.with_cost(10)), ) } diff --git a/chartered-web/src/endpoints/web_api/sessions/mod.rs b/chartered-web/src/endpoints/web_api/sessions/mod.rs index b234f9c..32e7b65 100644 --- a/chartered-web/src/endpoints/web_api/sessions/mod.rs +++ a/chartered-web/src/endpoints/web_api/sessions/mod.rs @@ -1,8 +1,13 @@ mod delete; mod list; -use axum::{routing::get, Router}; +use crate::RateLimit; +use axum::{handler::Handler, routing::get, Router}; -pub fn routes() -> Router { - Router::new().route("/", get(list::handle_get).delete(delete::handle_delete)) +pub fn routes(rate_limit: &RateLimit) -> Router { + Router::new().route( + "/", + get(list::handle_get.layer(rate_limit.with_cost(1))) + .delete(delete::handle_delete.layer(rate_limit.with_cost(5))), + ) } diff --git a/chartered-web/src/endpoints/web_api/users/mod.rs b/chartered-web/src/endpoints/web_api/users/mod.rs index eca9e4b..8afaa9b 100644 --- a/chartered-web/src/endpoints/web_api/users/mod.rs +++ a/chartered-web/src/endpoints/web_api/users/mod.rs @@ -1,12 +1,22 @@ mod heatmap; mod info; mod search; -use axum::{routing::get, Router}; +use crate::RateLimit; +use axum::{handler::Handler, routing::get, Router}; -pub fn routes() -> Router { +pub fn routes(rate_limit: &RateLimit) -> Router { Router::new() - .route("/search", get(search::handle)) - .route("/info/:uuid", get(info::handle)) - .route("/info/:uuid/heatmap", get(heatmap::handle)) + .route( + "/search", + get(search::handle.layer(rate_limit.with_cost(5))), + ) + .route( + "/info/:uuid", + get(info::handle.layer(rate_limit.with_cost(1))), + ) + .route( + "/info/:uuid/heatmap", + get(heatmap::handle.layer(rate_limit.with_cost(1))), + ) } -- rgit 0.1.3