Add shared rate limiting to all endpoints
Diff
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(-)
@@ -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]]
@@ -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"
@@ -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(),
@@ -1,3 +1,4 @@
pub mod cargo_auth;
pub mod logging;
pub mod rate_limit;
pub mod web_auth;
@@ -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<RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>>,
counter: Arc<AtomicUsize>,
}
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<RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>>,
counter: Arc<AtomicUsize>,
cost: NonZeroU32,
}
impl<S> Layer<S> for RateLimitLayer {
type Service = RateLimitMiddleware<S>;
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<S> {
inner: S,
governor: Arc<RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>>,
counter: Arc<AtomicUsize>,
cost: NonZeroU32,
}
impl<S, ReqBody> Service<Request<ReqBody>> for RateLimitMiddleware<S>
where
S: Service<Request<ReqBody>, Response = Response<BoxBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
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::<std::net::SocketAddr>::from_request(&mut req)
.await
.map(|v| v.0);
if let Ok(socket_addr) = socket_addr {
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());
}
if this.counter.fetch_add(1, Ordering::AcqRel) % 500 == 0 {
this.governor.retain_recent();
}
}
this.inner.call(req.try_into_request().unwrap()).await
})
}
}
@@ -11,20 +11,37 @@
mod publish;
mod yank;
use crate::RateLimit;
use axum::{
handler::Handler,
routing::{delete, get, put},
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/:crate/owners", get(owners::handle_get))
.route(
"/crates/:crate/owners",
get(owners::handle_get.layer(rate_limit.with_cost(1))),
)
.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))),
)
}
@@ -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))
}
@@ -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)]
@@ -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))),
)
}
@@ -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)),
)
}
@@ -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))),
)
}
@@ -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))),
)
}