Skip to content

Commit

Permalink
Add rate limiter for insertions and deletions
Browse files Browse the repository at this point in the history
Add options to rate limit insertion and deletions.
Disabled by default.
  • Loading branch information
cgzones committed Nov 12, 2024
1 parent 9d7df4f commit 05e47c5
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 3 deletions.
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ lru = "0"
mime = "0"
qrcodegen = "1"
rand = "0"
ratelimit = "0.10"
rusqlite = { version = "0.32", features = ["bundled"] }
rusqlite_migration = { version = "1", default-features = false }
rust-argon2 = "2.0.0"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ run-time behavior:
will be generated which means cookies will become invalid after restarts and
paste creators will not be able to delete their pastes anymore.
* `WASTEBIN_TITLE` overrides the HTML page title. Defaults to `wastebin`.
* `WASTEBIN_RATELIMIT_INSERT` sets the maximum allowed creation amount of new
pastes per minute. Defaults to 0 meaning disabled.
* `WASTEBIN_RATELIMIT_DELETE` sets the maximum allowed delete attempts of existing
pastes per minute. Defaults to 0 meaning disabled.
* `RUST_LOG` influences logging. Besides the typical `trace`, `debug`, `info`
etc. keys, you can also set the `tower_http` key to some log level to get
additional information request and response logs.
Expand Down
22 changes: 22 additions & 0 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const VAR_BASE_URL: &str = "WASTEBIN_BASE_URL";
const VAR_PASSWORD_SALT: &str = "WASTEBIN_PASSWORD_SALT";
const VAR_HTTP_TIMEOUT: &str = "WASTEBIN_HTTP_TIMEOUT";
const VAR_MAX_PASTE_EXPIRATION: &str = "WASTEBIN_MAX_PASTE_EXPIRATION";
const VAR_RATELIMIT_INSERT: &str = "WASTEBIN_RATELIMIT_INSERT";
const VAR_RATELIMIT_DELETE: &str = "WASTEBIN_RATELIMIT_DELETE";

#[derive(thiserror::Error, Debug)]
pub enum Error {
Expand All @@ -47,6 +49,10 @@ pub enum Error {
HttpTimeout(ParseIntError),
#[error("failed to parse {VAR_MAX_PASTE_EXPIRATION}: {0}")]
MaxPasteExpiration(ParseIntError),
#[error("failed to parse {VAR_RATELIMIT_INSERT}: {0}")]
RatelimitInsert(ParseIntError),
#[error("failed to parse {VAR_RATELIMIT_DELETE}: {0}")]
RatelimitDelete(ParseIntError),
}

pub struct BasePath(String);
Expand Down Expand Up @@ -185,3 +191,19 @@ pub fn max_paste_expiration() -> Result<Option<NonZeroU32>, Error> {
.transpose()
.map(|op| op.and_then(NonZero::new))
}

pub fn ratelimit_insert() -> Result<Option<NonZeroU32>, Error> {
std::env::var(VAR_RATELIMIT_INSERT)
.ok()
.map(|value| value.parse::<u32>().map_err(Error::RatelimitInsert))
.transpose()
.map(|op| op.and_then(NonZero::new))
}

pub fn ratelimit_delete() -> Result<Option<NonZeroU32>, Error> {
std::env::var(VAR_RATELIMIT_DELETE)
.ok()
.map(|value| value.parse::<u32>().map_err(Error::RatelimitDelete))
.transpose()
.map(|op| op.and_then(NonZero::new))
}
28 changes: 28 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ use crate::errors::Error;
use axum::extract::{DefaultBodyLimit, FromRef};
use axum::Router;
use axum_extra::extract::cookie::Key;
use ratelimit::Ratelimiter;
use std::num::NonZeroU32;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tower::ServiceBuilder;
Expand Down Expand Up @@ -34,6 +36,8 @@ pub struct AppState {
key: Key,
base_url: Option<Url>,
max_expiration: Option<NonZeroU32>,
ratelimit_insert: Option<Arc<Ratelimiter>>,
ratelimit_delete: Option<Arc<Ratelimiter>>,
}

impl FromRef<AppState> for Key {
Expand Down Expand Up @@ -92,6 +96,8 @@ async fn start() -> Result<(), Box<dyn std::error::Error>> {
let base_url = env::base_url()?;
let timeout = env::http_timeout()?;
let max_expiration = env::max_paste_expiration()?;
let ratelimit_insert = env::ratelimit_insert()?;
let ratelimit_delete = env::ratelimit_delete()?;

let cache = Cache::new(cache_size);
let db = Database::new(method)?;
Expand All @@ -101,13 +107,35 @@ async fn start() -> Result<(), Box<dyn std::error::Error>> {
key,
base_url,
max_expiration,
ratelimit_insert: ratelimit_insert.map(|rli| {
let value = rli.get().into();
Arc::new(
Ratelimiter::builder(value, Duration::from_secs(60))
.max_tokens(value)
.initial_available(value)
.build()
.expect("valid rate limiter values"),
)
}),
ratelimit_delete: ratelimit_delete.map(|rld| {
let value = rld.get().into();
Arc::new(
Ratelimiter::builder(value, Duration::from_secs(60))
.max_tokens(value)
.initial_available(value)
.build()
.expect("valid rate limiter values"),
)
}),
};

tracing::debug!("serving on {addr}");
tracing::debug!("caching {cache_size} paste highlights");
tracing::debug!("restricting maximum body size to {max_body_size} bytes");
tracing::debug!("enforcing a http timeout of {timeout:#?}");
tracing::debug!("maximum expiration time of {max_expiration:?} seconds");
tracing::debug!("ratelimiting insert amount to {ratelimit_insert:?} per minute");
tracing::debug!("ratelimiting delete attempts to {ratelimit_delete:?} per minute");

let service = make_app(max_body_size, timeout).with_state(state);
let listener = TcpListener::bind(&addr).await?;
Expand Down
36 changes: 33 additions & 3 deletions src/routes/paste.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::sync::atomic::AtomicBool;

use crate::cache::Key as CacheKey;
use crate::crypto::Password;
use crate::db::read::Entry;
Expand Down Expand Up @@ -190,6 +192,20 @@ pub async fn insert(
headers: HeaderMap,
request: Request<Body>,
) -> Result<Response, Response> {
if let Some(ref ratelimiter) = state.0.ratelimit_insert {
static RL_LOGGED: AtomicBool = AtomicBool::new(false);

if ratelimiter.try_wait().is_err() {
if !RL_LOGGED.fetch_or(true, std::sync::atomic::Ordering::Acquire) {
tracing::info!("Rate limiting paste insertions");
}

Err(StatusCode::FORBIDDEN.into_response())?;
}

RL_LOGGED.store(false, std::sync::atomic::Ordering::Relaxed);
}

let content_type = headers
.typed_get::<headers::ContentType>()
.ok_or_else(|| StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())?;
Expand Down Expand Up @@ -232,14 +248,28 @@ pub async fn delete(
jar: SignedCookieJar,
) -> Result<Redirect, pages::ErrorResponse<'static>> {
let id = id.parse()?;
let uid = state.db.get_uid(id).await?;
let db_uid = state.db.get_uid(id).await?.ok_or(Error::Delete)?;

if let Some(ref ratelimiter) = state.0.ratelimit_delete {
static RL_LOGGED: AtomicBool = AtomicBool::new(false);

if ratelimiter.try_wait().is_err() {
if !RL_LOGGED.fetch_or(true, std::sync::atomic::Ordering::Acquire) {
tracing::info!("Rate limiting paste deletions");
}

Err(Error::Delete)?;
}

RL_LOGGED.store(false, std::sync::atomic::Ordering::Relaxed);
}

let can_delete = jar
.get("uid")
.map(|cookie| cookie.value().parse::<i64>())
.transpose()
.map_err(|err| Error::CookieParsing(err.to_string()))?
.zip(uid)
.map_or(false, |(user_uid, db_uid)| user_uid == db_uid);
.map_or(false, |user_uid| user_uid == db_uid);

if !can_delete {
Err(Error::Delete)?;
Expand Down
10 changes: 10 additions & 0 deletions src/test_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ use axum::extract::Request;
use axum::response::Response;
use axum::Router;
use axum_extra::extract::cookie::Key;
use ratelimit::Ratelimiter;
use reqwest::RequestBuilder;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tower::make::Shared;
Expand Down Expand Up @@ -64,6 +66,14 @@ pub(crate) fn make_app() -> Result<Router, Box<dyn std::error::Error>> {
key,
base_url,
max_expiration: None,
ratelimit_insert: Some(Arc::new(
Ratelimiter::builder(60, Duration::from_secs(1))
.max_tokens(60)
.initial_available(60)
.build()
.unwrap(),
)),
ratelimit_delete: None,
};

Ok(crate::make_app(4096, Duration::new(30, 0)).with_state(state))
Expand Down

0 comments on commit 05e47c5

Please sign in to comment.