Rate limit: a hundred at once, ten a second, and the page's own files are not counted
Test / test (pull_request) Successful in 1m15s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 19:51:48 +02:00
co-authored by Claude Opus 5.5
parent 3604683df3
commit 87d4dda0eb
3 changed files with 74 additions and 51 deletions
+54 -44
View File
@@ -166,30 +166,32 @@ async fn serve() -> anyhow::Result<()> {
.with_name("portal_session");
// What a person does costs a little; what a script does costs a
// lot. A burst of 30 with one refilled every two seconds is well
// above anything a visitor filling in a form will reach - a page
// load fetches several resources at once - and well below what it
// takes to be a nuisance. Per caller IP, in memory, no store: this
// is a fence, not an accounting system, and the fence that matters
// for uploads is the policy check on the route itself.
// lot. More than a hundred requests in ten seconds is a script: a
// burst of 100, refilled at ten a second. Someone reloading a page
// again and again stays well inside it, the more so since the
// files a page is made of (stylesheet, scripts, fonts, pictures)
// are not counted at all - see `assets` below. Per caller IP, in
// memory, no store: this is a fence, not an accounting system, and
// the fence that matters for uploads is the policy check on the
// route itself.
//
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
// 0 for either turns it off, for an instance behind something that
// already does this.
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
let rate_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2);
.unwrap_or(10);
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
.unwrap_or(100);
let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
// what every one of these instances is behind; it falls back to
// the socket address when neither is set.
let conf = tower_governor::governor::GovernorConfigBuilder::default()
.per_second(rate_seconds)
.per_millisecond((1000 / rate_per_second).max(1))
.burst_size(rate_burst)
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
.finish()
@@ -197,8 +199,8 @@ async fn serve() -> anyhow::Result<()> {
std::sync::Arc::new(conf)
});
match &governor {
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
}
async fn server_fn_handler(
@@ -233,34 +235,13 @@ async fn serve() -> anyhow::Result<()> {
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
let app = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
// axum caps request bodies at 2 MB by default, which had made the
// 20 MB upload limit unreachable; raise the fence on this route only,
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
)
.route(
"/attachment/{bucket}/{record}/{field}",
get(upload::attachment),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// What the storylines did, as counts (report.rs).
.route("/report", get(portal::report::handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
// Maps, proxied: the browser asks this origin for every tile
// and every still, so nothing third-party loads in a page and
// the token stays here (see maps.rs).
.route("/map/style.json", get(portal::maps::style))
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
.route("/map/still/{place}/{size}", get(portal::maps::still))
// The files a page is made of, from this disk: the stylesheet, the
// scripts, the fonts. Not counted by the rate limit - they cost
// next to nothing, and refusing one is what turns a reloaded page
// into an unstyled mess - and no session is loaded for them.
// Anything that makes portal ask someone else (content pictures
// from Gitea, map tiles) stays below, counted.
let assets: Router<AppState> = Router::new()
// no-cache = "revalidate before reuse", not "don't cache":
// pkg files keep the same names across releases (portal.js,
// portal.wasm), and without this browsers heuristically cache
@@ -296,7 +277,36 @@ async fn serve() -> anyhow::Result<()> {
.service(ServeFile::new(page_motion)),
)
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css));
let app = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.route("/auth/logout", get(oidc::logout))
.route("/api/{*fn_name}", any(server_fn_handler))
// axum caps request bodies at 2 MB by default, which had made the
// 20 MB upload limit unreachable; raise the fence on this route only,
// with room for the multipart framing.
.route(
"/upload",
post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)),
)
.route(
"/attachment/{bucket}/{record}/{field}",
get(upload::attachment),
)
.route("/gitea-repo", get(content::gitea_repo_handler))
// What the storylines did, as counts (report.rs).
.route("/report", get(portal::report::handler))
// Content-shipped assets (hero module etc.), same-origin.
.route("/site/{*path}", get(content::site_asset_handler))
.route("/automation/kv/{bucket}", get(content::automation_kv_handler))
// Maps, proxied: the browser asks this origin for every tile
// and every still, so nothing third-party loads in a page and
// the token stays here (see maps.rs).
.route("/map/style.json", get(portal::maps::style))
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
.route("/map/still/{place}/{size}", get(portal::maps::still))
.leptos_routes_with_context(
&state,
routes,
@@ -318,8 +328,8 @@ async fn serve() -> anyhow::Result<()> {
let app = match governor {
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
None => app,
}
.with_state(state);
};
let app = assets.merge(app).with_state(state);
tracing::info!("listening on http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?;