new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
256
server-rs/src/ratelimit.rs
Normal file
256
server-rs/src/ratelimit.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
//! Defense-in-depth guards for unlicensed ("demo") API traffic: a per-key rate
|
||||
//! limit and a server-side filter-count cap. These complement the client-side UX
|
||||
//! limits so a scripted client can't trivially bypass them. Licensed users and
|
||||
//! admins are exempt.
|
||||
//!
|
||||
//! This is a soft anti-abuse measure, not a hard security boundary: the rate-limit
|
||||
//! key falls back to the (spoofable) client IP for anonymous users, and the demo
|
||||
//! gate is best-effort by design (see demo_zone.rs). Logged-in free accounts are keyed
|
||||
//! by their stable account id, which spoofing the IP header does not evade.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use axum::extract::{ConnectInfo, Request};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use parking_lot::Mutex;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde_json::json;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{
|
||||
DEMO_MAX_FILTERS, DEMO_RATE_LIMIT_MAX, DEMO_RATE_LIMIT_MAX_KEYS, DEMO_RATE_LIMIT_WINDOW_SECS,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Extract the client IP, preferring reverse-proxy/CDN headers (we sit behind one
|
||||
/// in production) and falling back to the socket peer. Client-supplied and
|
||||
/// spoofable — only used as a coarse rate-limit key for anonymous visitors.
|
||||
fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>) -> Option<IpAddr> {
|
||||
let from_header = |name: &str, take_first: bool| -> Option<IpAddr> {
|
||||
let raw = headers.get(name)?.to_str().ok()?;
|
||||
let candidate = if take_first {
|
||||
raw.split(',').next().unwrap_or(raw)
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
candidate.trim().parse().ok()
|
||||
};
|
||||
|
||||
from_header("cf-connecting-ip", false)
|
||||
.or_else(|| from_header("x-forwarded-for", true))
|
||||
.or_else(|| from_header("x-real-ip", false))
|
||||
.or(peer)
|
||||
}
|
||||
|
||||
/// Sliding-window request counter keyed by account id or client IP.
|
||||
pub struct DemoRateLimiter {
|
||||
windows: Mutex<FxHashMap<String, Vec<Instant>>>,
|
||||
}
|
||||
|
||||
impl DemoRateLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
windows: Mutex::new(FxHashMap::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a hit for `key`; returns `false` when the per-window limit is
|
||||
/// exceeded (caller should reject with 429).
|
||||
pub fn check(&self, key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
let window = Duration::from_secs(DEMO_RATE_LIMIT_WINDOW_SECS);
|
||||
let mut map = self.windows.lock();
|
||||
|
||||
// Bound memory: when the table grows large, drop keys with no recent hits.
|
||||
// If even that can't get us back under the cap (e.g. a spoofed-IP flood
|
||||
// minting a fresh key per request), clear the table outright. That resets
|
||||
// everyone's window — acceptable under attack — and keeps both the size and
|
||||
// the cost of this scan bounded (it then won't re-run for ~MAX_KEYS inserts,
|
||||
// instead of scanning O(n) on every request once full).
|
||||
if map.len() > DEMO_RATE_LIMIT_MAX_KEYS {
|
||||
map.retain(|_, hits| hits.iter().any(|t| now.duration_since(*t) < window));
|
||||
if map.len() > DEMO_RATE_LIMIT_MAX_KEYS {
|
||||
map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let hits = map.entry(key.to_string()).or_default();
|
||||
hits.retain(|t| now.duration_since(*t) < window);
|
||||
if hits.len() >= DEMO_RATE_LIMIT_MAX {
|
||||
return false;
|
||||
}
|
||||
hits.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DemoRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a request is internal/direct rather than from a public visitor via the
|
||||
/// edge proxy. Internal callers (the screenshot/OG service, health checks, local
|
||||
/// dev) reach the server container directly: a private/loopback socket peer and no
|
||||
/// edge-proxy forwarding headers. We exempt them so demo guards never throttle our
|
||||
/// own services. (Behind the public proxy, real visitors carry forwarding headers.)
|
||||
fn is_internal_request(headers: &HeaderMap, peer: Option<IpAddr>) -> bool {
|
||||
let has_forwarding = headers.contains_key("cf-connecting-ip")
|
||||
|| headers.contains_key("x-forwarded-for")
|
||||
|| headers.contains_key("x-real-ip");
|
||||
if has_forwarding {
|
||||
return false;
|
||||
}
|
||||
match peer {
|
||||
Some(IpAddr::V4(v4)) => {
|
||||
v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified()
|
||||
}
|
||||
Some(IpAddr::V6(v6)) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Count non-empty `;;`-separated entries in the `filters` query parameter.
|
||||
fn filter_count(query: &str) -> usize {
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
if key == "filters" {
|
||||
return value.split(";;").filter(|entry| !entry.trim().is_empty()).count();
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Whether the request carries a non-empty `share` code. Such requests view a shared
|
||||
/// dashboard whose saved filter set may legitimately exceed the demo cap, so they're
|
||||
/// exempt from the filter cap — the actual share-bounds authorization still runs in
|
||||
/// the handler.
|
||||
fn has_share_code(query: &str) -> bool {
|
||||
form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(key, value)| key == "share" && !value.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Middleware applying the demo filter cap and rate limit to unlicensed `/api/`
|
||||
/// traffic. Must run after `auth_middleware` (for `OptionalUser`) and the state
|
||||
/// injection layer (for `AppState`).
|
||||
pub async fn demo_guard_middleware(req: Request, next: Next) -> Response {
|
||||
let path = req.uri().path();
|
||||
// Map/overlay tiles aren't property-data pulls, and the basemap visibly breaks if
|
||||
// they're throttled, so the demo guards only cover the data endpoints.
|
||||
if !path.starts_with("/api/")
|
||||
|| path.starts_with("/api/tiles/")
|
||||
|| path.starts_with("/api/overlays/")
|
||||
{
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// Licensed users and admins bypass demo guards entirely.
|
||||
let user = req.extensions().get::<OptionalUser>().and_then(|u| u.0.clone());
|
||||
if let Some(u) = &user {
|
||||
if u.is_admin || u.subscription == "licensed" {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|info| info.0.ip());
|
||||
|
||||
// Exempt our own internal services (screenshots/OG, health checks, dev).
|
||||
if is_internal_request(req.headers(), peer) {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// Server-side filter cap (mirrors the client demo limit; blunts oversized
|
||||
// aggregation requests). Share recipients are exempt — their saved view may
|
||||
// legitimately carry >5 filters and the share grant is checked in the handler.
|
||||
if let Some(query) = req.uri().query() {
|
||||
if !has_share_code(query) && filter_count(query) > DEMO_MAX_FILTERS {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(json!({
|
||||
"error": "filter_limit",
|
||||
"message": format!("The demo is limited to {DEMO_MAX_FILTERS} filters"),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit per account (stable, not IP-spoofable) or, for anonymous
|
||||
// visitors, per client IP.
|
||||
let key = match &user {
|
||||
Some(u) => format!("acct:{}", u.id),
|
||||
None => match client_ip(req.headers(), peer) {
|
||||
Some(ip) => format!("ip:{ip}"),
|
||||
None => "anon".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(state) = req.extensions().get::<Arc<AppState>>() {
|
||||
if !state.demo_rate_limiter.check(&key) {
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
axum::Json(json!({
|
||||
"error": "rate_limited",
|
||||
"message": "Too many requests — slow down, or sign in for full access",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_filters_in_query() {
|
||||
assert_eq!(filter_count(""), 0);
|
||||
assert_eq!(filter_count("resolution=9"), 0);
|
||||
assert_eq!(filter_count("filters=price%3A1%3A2"), 1);
|
||||
assert_eq!(
|
||||
filter_count("filters=price%3A1%3A2%3B%3Bbeds%3A2%3A4&resolution=9"),
|
||||
2
|
||||
);
|
||||
// Trailing/empty entries are ignored.
|
||||
assert_eq!(filter_count("filters=price%3A1%3A2%3B%3B"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_share_code() {
|
||||
assert!(!has_share_code(""));
|
||||
assert!(!has_share_code("filters=a%3B%3Bb"));
|
||||
assert!(!has_share_code("share=")); // empty value doesn't count
|
||||
assert!(has_share_code("share=abc123"));
|
||||
assert!(has_share_code("bounds=1,2,3,4&share=xyz&filters=a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_blocks_past_the_window_limit() {
|
||||
let limiter = DemoRateLimiter::new();
|
||||
// The first DEMO_RATE_LIMIT_MAX hits for a key pass; the next is blocked.
|
||||
for _ in 0..DEMO_RATE_LIMIT_MAX {
|
||||
assert!(limiter.check("acct:abc"));
|
||||
}
|
||||
assert!(!limiter.check("acct:abc"));
|
||||
// A different key has its own independent budget.
|
||||
assert!(limiter.check("acct:xyz"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue