fix: resolve image/upload 500 errors and refactor rate limiting

- Separate /uploads and /api/upload routes from Dioxus app to avoid
  IncrementalRenderer intercepting non-page requests
- Remove broken SmartIpKeyExtractor-based general_limit() that failed
  under Dioxus dev server proxy (Unable To Extract Key)
- Move rate limiting into handlers using governor::RateLimiter directly
- Add IMAGE_LIMITER for /uploads/* serving
- Make all rate limits configurable via environment variables
- Add rate limit config to .env.example with sensible defaults
This commit is contained in:
xfy 2026-06-09 09:48:51 +08:00
parent 4f368e6fb8
commit 3974856f3d
4 changed files with 64 additions and 40 deletions

View File

@ -1,2 +1,12 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
RUST_LOG=info RUST_LOG=info
# Rate Limit — 严格限流(登录、注册)
RATE_LIMIT_STRICT_PER_SEC=1
RATE_LIMIT_STRICT_BURST=5
# Rate Limit — 上传限流(图片上传)
RATE_LIMIT_UPLOAD_PER_SEC=2
RATE_LIMIT_UPLOAD_BURST=15
# Rate Limit — 图片访问限流(/uploads/*
RATE_LIMIT_IMAGE_PER_SEC=10
RATE_LIMIT_IMAGE_BURST=50

View File

@ -224,8 +224,19 @@ fn is_path_safe(path: &str) -> bool {
} }
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn serve_image(Path(path): Path<String>, Query(params): Query<ImageParams>) -> Response { use axum::http::HeaderMap;
// Path traversal protection
#[cfg(feature = "server")]
pub async fn serve_image(
Path(path): Path<String>,
Query(params): Query<ImageParams>,
headers: HeaderMap,
) -> Response {
let ip = crate::api::rate_limit::get_client_ip(&headers);
if let Err(status) = crate::api::rate_limit::check_image_limit(&ip) {
return status.into_response();
}
if !is_path_safe(&path) { if !is_path_safe(&path) {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
} }

View File

@ -1,58 +1,57 @@
#![allow(clippy::unused_unit)] #![allow(clippy::unused_unit)]
#[cfg(feature = "server")]
use std::sync::Arc;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::sync::LazyLock; use std::sync::LazyLock;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use std::num::NonZeroU32; use std::num::NonZeroU32;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use tower_governor::governor::GovernorConfigBuilder;
#[cfg(feature = "server")]
use tower_governor::GovernorLayer;
#[cfg(feature = "server")]
use tower_governor::key_extractor::SmartIpKeyExtractor;
#[cfg(feature = "server")]
use governor::middleware::NoOpMiddleware;
#[cfg(feature = "server")]
use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter};
/// 通用限流配置:每秒 1 请求,突发 30
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn general_limit() -> GovernorLayer<SmartIpKeyExtractor, NoOpMiddleware> { use axum::http::StatusCode;
let config = GovernorConfigBuilder::default()
.per_second(1) #[cfg(feature = "server")]
.burst_size(30) fn env_or(key: &str, default: u32) -> NonZeroU32 {
.key_extractor(SmartIpKeyExtractor) let val = std::env::var(key)
.finish() .ok()
.unwrap(); .and_then(|s| s.parse::<u32>().ok())
GovernorLayer { .unwrap_or(default);
config: Arc::new(config), NonZeroU32::new(val.max(1)).unwrap()
}
} }
// 严格限流:每秒 1突发 5用于登录、注册等敏感操作
#[cfg(feature = "server")] #[cfg(feature = "server")]
static STRICT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| { static STRICT_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed( RateLimiter::keyed(
Quota::per_second(NonZeroU32::new(1).unwrap()) Quota::per_second(env_or("RATE_LIMIT_STRICT_PER_SEC", 1))
.allow_burst(NonZeroU32::new(5).unwrap()) .allow_burst(env_or("RATE_LIMIT_STRICT_BURST", 5)),
) )
}); });
// 上传限流:每秒 1突发 10
#[cfg(feature = "server")] #[cfg(feature = "server")]
static UPLOAD_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| { static UPLOAD_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed( RateLimiter::keyed(
Quota::per_second(NonZeroU32::new(1).unwrap()) Quota::per_second(env_or("RATE_LIMIT_UPLOAD_PER_SEC", 2))
.allow_burst(NonZeroU32::new(10).unwrap()) .allow_burst(env_or("RATE_LIMIT_UPLOAD_BURST", 15)),
) )
}); });
/// 从请求 headers 中提取客户端 IP #[cfg(feature = "server")]
static IMAGE_LIMITER: LazyLock<DefaultKeyedRateLimiter<String>> = LazyLock::new(|| {
RateLimiter::keyed(
Quota::per_second(env_or("RATE_LIMIT_IMAGE_PER_SEC", 10))
.allow_burst(env_or("RATE_LIMIT_IMAGE_BURST", 50)),
)
});
#[cfg(feature = "server")]
pub fn check_image_limit(ip: &str) -> Result<(), StatusCode> {
IMAGE_LIMITER
.check_key(&ip.to_string())
.map(|_| ())
.map_err(|_| StatusCode::TOO_MANY_REQUESTS)
}
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn get_client_ip(headers: &http::HeaderMap) -> String { pub fn get_client_ip(headers: &http::HeaderMap) -> String {
// 1. X-Forwarded-For
if let Some(ip) = headers if let Some(ip) = headers
.get("x-forwarded-for") .get("x-forwarded-for")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@ -60,7 +59,6 @@ pub fn get_client_ip(headers: &http::HeaderMap) -> String {
{ {
return ip.trim().to_string(); return ip.trim().to_string();
} }
// 2. X-Real-Ip
if let Some(ip) = headers if let Some(ip) = headers
.get("x-real-ip") .get("x-real-ip")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@ -70,7 +68,6 @@ pub fn get_client_ip(headers: &http::HeaderMap) -> String {
"unknown".to_string() "unknown".to_string()
} }
/// 检查严格限流(用于登录、注册等敏感操作)
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn check_strict_limit(ip: &str) -> Result<(), String> { pub fn check_strict_limit(ip: &str) -> Result<(), String> {
STRICT_LIMITER STRICT_LIMITER
@ -79,7 +76,6 @@ pub fn check_strict_limit(ip: &str) -> Result<(), String> {
.map_err(|_| "请求过于频繁,请稍后再试".to_string()) .map_err(|_| "请求过于频繁,请稍后再试".to_string())
} }
/// 检查上传限流
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn check_upload_limit(ip: &str) -> Result<(), String> { pub fn check_upload_limit(ip: &str) -> Result<(), String> {
UPLOAD_LIMITER UPLOAD_LIMITER

View File

@ -44,15 +44,22 @@ fn main() {
dioxus::server::IncrementalRendererConfig::default() dioxus::server::IncrementalRendererConfig::default()
.invalidate_after(std::time::Duration::from_secs(300)), .invalidate_after(std::time::Duration::from_secs(300)),
); );
let router = axum::Router::new() let api_routes = axum::Router::new()
.route( .route(
"/api/upload", "/api/upload",
axum::routing::post(crate::api::upload::upload_image) axum::routing::post(crate::api::upload::upload_image)
.layer(axum::extract::DefaultBodyLimit::disable()), .layer(axum::extract::DefaultBodyLimit::disable()),
) );
.route("/uploads/{*path}", axum::routing::get(crate::api::image::serve_image))
.layer(crate::api::rate_limit::general_limit()) let static_routes = axum::Router::new()
.serve_dioxus_application(config, router::AppRouter) .route("/uploads/{*path}", axum::routing::get(crate::api::image::serve_image));
let dioxus_app = axum::Router::new()
.serve_dioxus_application(config, router::AppRouter);
let router = api_routes
.merge(static_routes)
.merge(dioxus_app)
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
.make_span_with( .make_span_with(