Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion bin/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,14 @@ async fn main() -> Result<()> {
.await
.with_context(|| format!("bind {}", cfg.bind_addr))?;
tracing::info!(addr = %cfg.bind_addr, "API listening");
axum::serve(listener, app).await.context("serve API")?;
// `into_make_service_with_connect_info` is what makes the peer address available to the
// rate limiter's `ConnectInfo` extractor; without it every caller looks like one client.
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.context("serve API")?;
Ok(())
}

Expand Down
32 changes: 29 additions & 3 deletions crates/api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,36 @@ pub struct Claims {
// Route handlers
// ---------------------------------------------------------------------------

/// Rate-limit an auth attempt by client IP: 10 per minute per IP.
///
/// Blunts credential stuffing and signup spam. Deliberately per-IP rather than per-email — an
/// attacker rotates emails freely, but not source addresses.
fn check_auth_rate_limit(
state: &AppState,
headers: &HeaderMap,
peer: Option<std::net::SocketAddr>,
) -> Result<(), ApiError> {
let ip = crate::rate_limit::client_ip(headers, peer);
if state
.rate_limiter()
.check(&ip, "auth", 10, std::time::Duration::from_secs(60))
{
Ok(())
} else {
Err(ApiError::TooManyRequests(
"too many attempts — wait a minute and try again".into(),
))
}
}

/// `POST /v1/auth/signup`
pub async fn signup(
State(state): State<AppState>,
peer: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<(StatusCode, Json<Envelope<AuthResponse>>)> {
check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?;
let creds: Credentials = parse_optional(&body)?;
let (email, password) = validate(creds)?;

Expand Down Expand Up @@ -136,9 +160,11 @@ pub async fn signup(
/// `POST /v1/auth/login`
pub async fn login(
State(state): State<AppState>,
peer: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<Json<Envelope<AuthResponse>>> {
check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?;
let creds: Credentials = parse_optional(&body)?;
let (email, password) = validate(creds)?;

Expand Down Expand Up @@ -373,13 +399,13 @@ pub fn verify_token(secret: &[u8], token: &str) -> Option<Claims> {
Some(claims)
}

fn sign_hs256(secret: &[u8], input: &[u8]) -> String {
pub(crate) fn sign_hs256(secret: &[u8], input: &[u8]) -> String {
let mut mac = <HmacSha256 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(input);
b64(&mac.finalize().into_bytes())
}

fn verify_hs256(secret: &[u8], input: &[u8], signature_b64: &str) -> bool {
pub(crate) fn verify_hs256(secret: &[u8], input: &[u8], signature_b64: &str) -> bool {
let Some(sig) = b64_decode(signature_b64) else {
return false;
};
Expand All @@ -388,7 +414,7 @@ fn verify_hs256(secret: &[u8], input: &[u8], signature_b64: &str) -> bool {
mac.verify_slice(&sig).is_ok()
}

fn now_secs() -> i64 {
pub(crate) fn now_secs() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down
3 changes: 2 additions & 1 deletion crates/api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ impl From<octo_wallet_core::WalletError> for ApiError {
| W::InvalidAssetCode
| W::InvalidAmount
| W::InvalidDerivationPath
| W::InvalidXdr => ApiError::BadRequest("invalid input".into()),
| W::InvalidXdr
| W::InvalidSignature => ApiError::BadRequest("invalid input".into()),
W::KeyDerivation | W::Signing | W::SeedDecryption => ApiError::Internal,
}
}
Expand Down
14 changes: 14 additions & 0 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod auth;
mod error;
pub mod horizon;
mod json;
pub mod rate_limit;
pub mod routes;
pub mod sponsor_validation;
mod state;
Expand Down Expand Up @@ -47,10 +48,19 @@ pub fn build_router(state: AppState) -> Router {
.route("/v1/auth/me", get(auth::me))
.route("/v1/auth/logout", post(auth::logout))
.route("/v1/audit-logs", get(routes::audit::list_audit_logs))
.route(
"/v1/uploads/signature",
get(routes::uploads::upload_signature),
)
.route(
"/v1/wallets",
post(routes::wallets::create_wallet).get(routes::wallets::list_wallets),
)
// Static segment takes priority over the :id capture below (matchit routing).
.route(
"/v1/wallets/challenge",
get(routes::wallets::wallet_challenge),
)
.route("/v1/wallets/:id", get(routes::wallets::get_wallet))
.route(
"/v1/wallets/:id/balances",
Expand Down Expand Up @@ -138,6 +148,10 @@ pub fn build_router(state: AppState) -> Router {
get(routes::payment_links::get_payment_link)
.put(routes::payment_links::set_payment_link_active),
)
.route(
"/v1/wallets/:id/payment-links/:link_id/payments",
get(routes::payment_links::list_payment_link_payments),
)
// Public: no auth, reachable by anyone with the link.
.route(
"/v1/pay/:slug",
Expand Down
100 changes: 100 additions & 0 deletions crates/api/src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Per-IP fixed-window rate limiting for public and auth endpoints.
//!
//! In-memory and per-process (matches this API's single-instance deployment). Handlers call
//! [`RateLimiter::check`] explicitly, the same way auth is enforced per-handler here — there is
//! no tower middleware layer to configure or bypass.

use axum::http::HeaderMap;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

/// Cap on tracked (ip, class) buckets before expired entries are swept.
const SWEEP_THRESHOLD: usize = 10_000;

/// Bucket key: the client IP plus the endpoint class it is being limited against.
type BucketKey = (String, &'static str);
/// Bucket value: when the current fixed window started, and hits so far within it.
type Bucket = (Instant, u32);

#[derive(Clone, Default)]
pub struct RateLimiter {
buckets: Arc<Mutex<HashMap<BucketKey, Bucket>>>,
}

impl RateLimiter {
/// Record a hit for `(ip, class)`; false when the fixed window's limit is exceeded.
pub fn check(&self, ip: &str, class: &'static str, limit: u32, window: Duration) -> bool {
let now = Instant::now();
let mut buckets = self.buckets.lock().expect("rate limiter lock");

if buckets.len() > SWEEP_THRESHOLD {
buckets.retain(|_, (start, _)| now.duration_since(*start) < window);
}

let entry = buckets.entry((ip.to_string(), class)).or_insert((now, 0));
if now.duration_since(entry.0) >= window {
*entry = (now, 0);
}
entry.1 += 1;
entry.1 <= limit
}
}

/// Best-effort client IP: first hop of `X-Forwarded-For` (set by a fronting proxy), else the
/// socket peer address, else a shared fallback key (still rate-limited, just collectively).
pub fn client_ip(headers: &HeaderMap, peer: Option<std::net::SocketAddr>) -> String {
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
if let Some(first) = xff.split(',').next() {
let first = first.trim();
if !first.is_empty() {
return first.to_string();
}
}
}
match peer {
Some(addr) => addr.ip().to_string(),
None => "unknown".to_string(),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn allows_up_to_limit_then_rejects() {
let rl = RateLimiter::default();
let window = Duration::from_secs(60);
for _ in 0..5 {
assert!(rl.check("1.2.3.4", "test", 5, window));
}
assert!(!rl.check("1.2.3.4", "test", 5, window));
// A different IP has its own bucket.
assert!(rl.check("5.6.7.8", "test", 5, window));
// A different class on the same IP has its own bucket too.
assert!(rl.check("1.2.3.4", "other", 5, window));
}

#[test]
fn window_resets_after_expiry() {
let rl = RateLimiter::default();
let window = Duration::from_millis(30);
assert!(rl.check("1.2.3.4", "test", 1, window));
assert!(!rl.check("1.2.3.4", "test", 1, window));
std::thread::sleep(Duration::from_millis(40));
assert!(rl.check("1.2.3.4", "test", 1, window));
}

#[test]
fn client_ip_prefers_forwarded_header() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "9.9.9.9, 10.0.0.1".parse().unwrap());
assert_eq!(client_ip(&headers, None), "9.9.9.9");

let empty = HeaderMap::new();
let peer = "127.0.0.1:5000".parse().ok();
assert_eq!(client_ip(&empty, peer), "127.0.0.1");
assert_eq!(client_ip(&empty, None), "unknown");
}
}
1 change: 1 addition & 0 deletions crates/api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod sponsor;
pub mod sponsorship;
pub mod submit;
pub mod trustlines;
pub mod uploads;
pub mod wallets;
pub mod webhooks;
pub mod whitelist;
Expand Down
Loading
Loading