chore(history): squash 67 commit(s) from 2026-09-25

- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints
- feat(frontend): app shell, routing and landing page with the chat-command hero
- feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion
- docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans
- feat(accounts): internal gRPC AuthenticateDevice guarded by internal key
- feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch
- feat(accounts): GET /me profile endpoint
- feat(gateway): scaffold crate with config validation and health check
- feat(gateway): reverse proxy to accounts and configs services
- feat(gateway): resolve identity once from access JWT or device token via gRPC
- feat(gateway): per-route and global rate limits with Retry-After
- feat(gateway): CORS for the site origin; docs for gateway and internal contract
- feat(configs): scaffold service with schema, config validation and health check
- feat(configs): four config slots per account with list, get and save
- feat(configs): permanent share codes with regenerate and public load-by-code
- feat(accounts): GetPublicProfiles gRPC for showcase author info
- style(accounts,common): apply rustfmt to existing sources
- feat(configs): public showcase with publish, browse, detail and copy-to-slot
- feat(backend): public profile endpoint and showcase author filter
- fix(gateway): silence clippy collapsible-if and needless-ref warnings
- docs(backend): configs-service implemented; Подсистема 1 backend complete
- feat(mod): add Optimize module skeleton with OptimizeState holder
- feat(mod): gate glass blur behind Optimize no_glass knob
- feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob
- feat(mod): trim procedural sky noise behind lite_sky knob
- feat(mod): drop fade gradients and digit rolls behind lean_hud knob
- docs(todo): mark Optimize module phase 9.2 complete
- refactor(mod): drop dead Renderer2D compatibility shims
- refactor(mod): prune unreachable Renderer2D overload towers
- refactor(mod): remove unused Renderer2D overloads and imports
- docs(todo): mark Renderer2D giant-splitting done (2179 to 1597)
- refactor(mod): extract shader id constants from LoVisualRenderPipelines
- docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines)
- refactor(mod): move Renderer2D instance state into base class
- refactor(mod): extract Renderer2DRounded drawing family
- refactor(mod): extract Renderer2DPath connector and chamfer family
- refactor(mod): extract Renderer2DShapes circle line and texture primitives
- refactor(mod): extract Renderer2DGlass and Renderer2DItem families
- refactor(mod): prune Renderer2D imports after facade split
- docs(todo): record Renderer2D facade inheritance split (1597 to 475)
- docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot
- feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer)
- refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants)
- refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec
- feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution
- feat(mod): flat fallback for no-glass optimize mode and persist global HUD config
- feat(mod): default HUD bg effects to Auto so the global surface style drives widgets
- feat(mod): add global cycle-style hotkey with surface style notification
- feat(mod): add surface style swatch strip under the global style picker
- feat(gateway): reject ambiguous paths and answer .env probes with a honeypot
- fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth
- feat(frontend): ClickGui theme pipeline generated from the mod, live site theming
- feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip
- docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9
- feat(gateway): answer /coffee with a 418 teapot
- feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer
- feat(frontend): one-click download from GitHub releases, changelog page, release CI
- feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links
- fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping)
- style(frontend): apply ClickGui glass effect to landing HUD playground widgets
- fix(frontend): prevent color field row overflow in theme editor grid
- fix(frontend): never attach stale bearer token to /auth/* requests
- fix(configs): unpublish/publish can no longer bypass moderation
- refactor(accounts): shrink auth/handlers.rs under the 250-line cap
- fix(accounts): tolerate concurrent refresh without killing every session
- fix(gateway): minor hardening from the backend review
- feat(configs): IDDQD easter egg config
This commit is contained in:
loki5512344 2026-09-25 20:22:13 +02:00
parent 72bc4c7148
commit 7f4b532f99
257 changed files with 13085 additions and 6582 deletions

View file

@ -0,0 +1,29 @@
[package]
name = "gateway"
version = "0.1.0"
edition = "2024"
[lib]
name = "gateway"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
axum = "0.8"
http-body-util = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tower-http = { version = "0.7", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
tonic = "0.14"
governor = "0.10"
anyhow = "1"
dotenvy = "0.15"
[dev-dependencies]
axum-test = "21"
tower = { version = "0.5", features = ["util"] }
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }

View file

@ -1531,7 +1531,7 @@ git commit -m "feat(gateway): scaffold crate with config validation and health c
**Interfaces:**
- Produces: `proxy::routes::{Upstream, upstream_for(&str) -> Option<Upstream>}`; `proxy::forward::{Upstreams, proxy}` where `Upstreams { client: reqwest::Client, accounts: String, configs: String, internal_key: HeaderValue }` and `Upstreams::new(&Config) -> anyhow::Result<Self>`; `async fn proxy(State<Arc<Upstreams>>, Request) -> Response` mounted as the router `fallback`.
- Routing table: `auth, device, avatars, me` → accounts; `configs, showcase` → configs; anything else → 404 JSON.
- Routing table: `auth, device, avatars, me, users` → accounts; `configs, showcase` → configs; anything else → 404 JSON.
- [ ] **Step 1: Echo upstream helper (tests/common/mod.rs)**
@ -1627,7 +1627,7 @@ pub enum Upstream {
pub fn upstream_for(path: &str) -> Option<Upstream> {
match path.trim_start_matches('/').split('/').next()? {
"auth" | "device" | "avatars" | "me" => Some(Upstream::Accounts),
"auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts),
"configs" | "showcase" => Some(Upstream::Configs),
_ => None,
}
@ -2337,6 +2337,13 @@ git commit -m "feat(gateway): CORS for the site origin; docs for gateway and int
---
### Task 12: Easter eggs in the gateway
- `.env` honeypot — done together with the dot-segment fix (see `.superpowers` fix list; commit "feat(gateway): .env honeypot easter egg for traversal scanners").
- `GET /coffee` (any method) → `418 I'm a teapot`, `text/plain; charset=utf-8`: «Я чайник. Кофе не варю, зато LoVisual бесплатный: https://github.com/loki5512344/LoVisual-/releases». Handled in the gateway before identity/rate limits, not forwarded. Test + commit `feat(gateway): 418 teapot at /coffee`.
---
## Deferred (not in this plan)
- Admin role enforcement at the gateway (`/admin/*`) — Подсистема 3 plan; `Identity` will then carry the role (add `role` to the access-token claims).

View file

@ -0,0 +1,89 @@
use anyhow::{Context, Result};
#[derive(Clone)]
pub struct Config {
pub port: u16,
pub jwt_secret: String,
pub internal_key: String,
pub accounts_http_url: String,
pub accounts_grpc_url: String,
pub configs_http_url: String,
pub site_origin: String,
/// Behind a reverse proxy (nginx/caddy) that appends the client IP to
/// X-Forwarded-For. Never enable when the gateway is exposed directly.
pub trust_proxy: bool,
}
fn var(name: &str) -> Result<String> {
std::env::var(name).with_context(|| format!("{name} not set"))
}
impl Config {
pub fn from_env() -> Result<Config> {
Ok(Config {
port: std::env::var("GATEWAY_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.context("GATEWAY_PORT")?,
jwt_secret: var("JWT_SECRET")?,
internal_key: var("INTERNAL_KEY")?,
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
configs_http_url: var("CONFIGS_HTTP_URL")?,
site_origin: var("SITE_ORIGIN")?,
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
})
}
pub fn validate(&self) -> Result<()> {
for (name, value) in [
("JWT_SECRET", &self.jwt_secret),
("INTERNAL_KEY", &self.internal_key),
] {
if value.len() < 32 {
anyhow::bail!("{name} must be at least 32 bytes");
}
}
// Both are sent as raw header values (INTERNAL_KEY on every upstream
// call, SITE_ORIGIN in the CORS layer); checked here so a bad value
// is a startup error, not a panic deep in request handling.
axum::http::HeaderValue::from_str(&self.internal_key)
.context("INTERNAL_KEY is not a valid header value")?;
axum::http::HeaderValue::from_str(&self.site_origin)
.context("SITE_ORIGIN is not a valid header value")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
pub fn sample() -> Config {
Config {
port: 0,
jwt_secret: "j".repeat(32),
internal_key: "k".repeat(32),
accounts_http_url: String::new(),
accounts_grpc_url: String::new(),
configs_http_url: String::new(),
site_origin: "http://localhost:5173".into(),
trust_proxy: false,
}
}
#[test]
fn valid_config_passes() {
assert!(sample().validate().is_ok());
}
#[test]
fn weak_secrets_are_rejected() {
let mut c = sample();
c.internal_key = "short".into();
assert!(c.validate().is_err());
let mut c = sample();
c.jwt_secret = "short".into();
assert!(c.validate().is_err());
}
}

View file

@ -0,0 +1,60 @@
//! Easter egg for `.env` scanners: instead of a 400 they get a fake file.
//! Never forwarded upstream; answered before identity and rate limiting.
use axum::{
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
const FAKE_ENV: &str = "\
# LoVisual production secrets — не благодари
DATABASE_URL=postgres://idi_naxui:daun_ebani@localhost:5432/tvoya_mamka
JWT_SECRET=nice_try_skiddie_tvoy_ip_uzhe_v_bane
INTERNAL_KEY=0000-0000-0000-0000-otvali
ADMIN_PASSWORD=hunter2
S3_SECRET_KEY=lovisual_luchshe_chem_tvoy_chit
# P.S. лучше скачай мод: https://github.com/loki5512344/LoVisual-/releases
";
/// Any segment named `.env` or `.env.<suffix>` (dots may be `%2e`-encoded).
pub fn is_env_probe(path: &str) -> bool {
super::segments(path).any(|raw| {
let name = super::decode_dots(raw);
name == ".env" || name.starts_with(".env.")
})
}
pub fn fake_env() -> Response {
(
StatusCode::OK,
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
FAKE_ENV,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_env_segments_only() {
for hit in [
"/.env",
"/../../.env",
"/api/.env",
"/.env.local",
"/%2e%2e/%2Eenv",
] {
assert!(is_env_probe(hit), "{hit}");
}
for miss in [
"/auth/login",
"/configs/.environment",
"/x.env",
"/showcase/env",
] {
assert!(!is_env_probe(miss), "{miss}");
}
}
}

View file

@ -0,0 +1,111 @@
//! Outermost request filter: answers the gateway's own jokes and refuses paths
//! whose meaning changes once the upstream URL is built. reqwest/`url` (WHATWG)
//! resolves `..`, `%2e%2e` and backslashes, while rate-limit rules match the raw
//! path — so without this, `POST /auth/x/../login` reaches `/auth/login` without
//! its 5/min limit.
pub mod honeypot;
use crate::rate_limit::client_ip;
use axum::{
Json,
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use serde_json::json;
/// A path segment with `%2e`/`%2E` decoded — the only escape WHATWG URL
/// parsing treats as a dot when resolving dot-segments.
fn decode_dots(segment: &str) -> String {
segment.to_ascii_lowercase().replace("%2e", ".")
}
fn segments(path: &str) -> impl Iterator<Item = &str> {
path.strip_prefix('/').unwrap_or(path).split('/')
}
/// True when the upstream could see a different path than the one the
/// gateway routed and rate-limited.
pub fn is_ambiguous(path: &str) -> bool {
if path == "/" {
return false;
}
segments(path).any(|raw| {
let lower = raw.to_ascii_lowercase();
let dots = decode_dots(raw);
raw.is_empty()
|| dots == "."
|| dots == ".."
|| raw.contains('\\')
|| lower.contains("%2f")
|| lower.contains("%5c")
})
}
const TEAPOT: &str = "Я чайник. Кофе не варю, зато LoVisual бесплатный: https://github.com/loki5512344/LoVisual-/releases\n";
fn teapot() -> Response {
(
StatusCode::IM_A_TEAPOT,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
TEAPOT,
)
.into_response()
}
pub async fn reject_ambiguous_paths(
State(trust_proxy): State<bool>,
req: Request,
next: Next,
) -> Response {
let path = req.uri().path();
if path == "/coffee" {
return teapot();
}
if honeypot::is_env_probe(path) {
tracing::info!(ip = %client_ip(&req, trust_proxy), path, "honeypot hit");
return honeypot::fake_env();
}
if is_ambiguous(path) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "bad path" })),
)
.into_response();
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_paths() {
for bad in [
"/a/../b",
"/a/%2E%2e/b",
"/a/.%2E",
"/a//b",
"/a%2Fb",
"/a\\b",
"/a/",
] {
assert!(is_ambiguous(bad), "{bad}");
}
for ok in [
"/",
"/auth/login",
"/configs/shared/AB2C",
"/a/..b",
"/a/.x",
] {
assert!(!is_ambiguous(ok), "{ok}");
}
}
}

View file

@ -0,0 +1,66 @@
use common::internal::GrpcKeyAttach;
use common::pb::accounts::{
AuthenticateDeviceRequest, accounts_internal_client::AccountsInternalClient,
};
use std::{future::Future, pin::Pin};
use tonic::{
Code,
service::interceptor::InterceptedService,
transport::{Channel, Endpoint},
};
use uuid::Uuid;
pub enum DeviceAuth {
Valid(Uuid),
Invalid,
/// accounts-service unreachable — the gateway answers 503, not 401,
/// so the mod doesn't wrongly forget its token.
Unavailable,
}
pub type DeviceAuthFuture<'a> = Pin<Box<dyn Future<Output = DeviceAuth> + Send + 'a>>;
pub trait DeviceAuthenticator: Send + Sync + 'static {
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a>;
}
pub struct GrpcDevices {
client: AccountsInternalClient<InterceptedService<Channel, GrpcKeyAttach>>,
}
impl GrpcDevices {
/// Lazy: the gateway boots even if accounts-service is still starting.
pub fn connect_lazy(url: &str, internal_key: &str) -> anyhow::Result<Self> {
let channel = Endpoint::from_shared(url.to_owned())?
.timeout(std::time::Duration::from_secs(5))
.connect_lazy();
Ok(GrpcDevices {
client: AccountsInternalClient::with_interceptor(
channel,
GrpcKeyAttach::new(internal_key)?,
),
})
}
}
impl DeviceAuthenticator for GrpcDevices {
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a> {
Box::pin(async move {
let mut client = self.client.clone();
match client
.authenticate_device(AuthenticateDeviceRequest {
device_token: token.to_owned(),
})
.await
{
Ok(reply) => Uuid::parse_str(&reply.into_inner().account_id)
.map_or(DeviceAuth::Invalid, DeviceAuth::Valid),
Err(status) if status.code() == Code::Unauthenticated => DeviceAuth::Invalid,
Err(status) => {
tracing::warn!("AuthenticateDevice failed: {status}");
DeviceAuth::Unavailable
}
}
})
}
}

View file

@ -0,0 +1,114 @@
pub mod device;
use axum::{
Json,
extract::{Request, State},
http::{HeaderValue, StatusCode, header::AUTHORIZATION},
middleware::Next,
response::{IntoResponse, Response},
};
use common::internal::{ACCOUNT_ID_HEADER, DEVICE_TOKEN_PREFIX, INTERNAL_KEY_HEADER};
use common::jwt::{TokenType, bearer_token, verify_token};
use device::{DeviceAuth, DeviceAuthenticator};
use serde_json::json;
use std::sync::Arc;
use uuid::Uuid;
/// Who made the request, as far as the gateway could verify.
#[derive(Clone, Copy)]
pub struct Identity(pub Option<Uuid>);
/// How the caller's credentials resolved. Recorded by `identify`, acted on by
/// `authorize` — resolution must not reject on its own, because it sits before
/// rate limiting and an unknown device token already cost a gRPC round trip.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Credentials {
/// No `Authorization` header at all.
Anonymous,
/// Verified; `Identity` carries the account.
Valid,
/// Present but not verifiable (expired/garbage/unknown device token).
Invalid,
/// Device lookup failed; the caller is neither allowed nor blamed.
Unavailable,
}
#[derive(Clone)]
pub struct IdentityState {
pub jwt_secret: Arc<str>,
pub devices: Arc<dyn DeviceAuthenticator>,
}
fn reject(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
/// Public auth endpoints: stale credentials there must not lock the caller
/// out of logout/refresh — they are treated as anonymous instead of 401.
fn is_auth_path(path: &str) -> bool {
path.starts_with("/auth/")
}
/// Resolves the caller into `Identity` + `Credentials` extensions, never
/// rejecting; the gate is `authorize`.
pub async fn identify(
State(state): State<IdentityState>,
mut req: Request,
next: Next,
) -> Response {
// Client-supplied copies of trusted headers are never forwarded.
req.headers_mut().remove(ACCOUNT_ID_HEADER);
req.headers_mut().remove(INTERNAL_KEY_HEADER);
let (account, credentials) = match bearer_token(req.headers()) {
None => (None, Credentials::Anonymous),
Some(token) if token.starts_with(DEVICE_TOKEN_PREFIX) => {
match state.devices.authenticate(token).await {
DeviceAuth::Valid(id) => (Some(id), Credentials::Valid),
DeviceAuth::Invalid => (None, Credentials::Invalid),
DeviceAuth::Unavailable => (None, Credentials::Unavailable),
}
}
Some(token) => {
let id = verify_token(token, &state.jwt_secret, TokenType::Access)
.and_then(|claims| Uuid::parse_str(&claims.sub).ok());
match id {
Some(id) => (Some(id), Credentials::Valid),
None => (None, Credentials::Invalid),
}
}
};
// The client's bearer token stops here either way.
req.headers_mut().remove(AUTHORIZATION);
if let Some(id) = account
&& let Ok(value) = HeaderValue::from_str(&id.to_string())
{
req.headers_mut().insert(ACCOUNT_ID_HEADER, value);
}
req.extensions_mut().insert(Identity(account));
req.extensions_mut().insert(credentials);
next.run(req).await
}
/// Turns a failed resolution into an HTTP rejection — positioned after rate
/// limiting so that a rejected credential still costs the caller a token.
pub async fn authorize(req: Request, next: Next) -> Response {
let credentials = req
.extensions()
.get::<Credentials>()
.copied()
.unwrap_or(Credentials::Anonymous);
let allowed = credentials == Credentials::Anonymous
|| credentials == Credentials::Valid
|| is_auth_path(req.uri().path());
if !allowed {
return match credentials {
Credentials::Unavailable => {
reject(StatusCode::SERVICE_UNAVAILABLE, "auth backend unavailable")
}
_ => reject(StatusCode::UNAUTHORIZED, "unauthorized"),
};
}
next.run(req).await
}

View file

@ -0,0 +1,76 @@
pub mod config;
pub mod guard;
pub mod identity;
pub mod proxy;
pub mod rate_limit;
use axum::{
Router,
http::{
HeaderValue, Method,
header::{AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER},
},
routing::get,
};
use config::Config;
use identity::device::DeviceAuthenticator;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config"));
let identity = identity::IdentityState {
jwt_secret: cfg.jwt_secret.as_str().into(),
devices,
};
let limits = rate_limit::RateLimits::new(cfg.trust_proxy);
spawn_purger(Arc::clone(&limits));
let cors = CorsLayer::new()
// Array form = AllowOrigin::list: the header is only sent back when the
// request's Origin actually matches (the HeaderValue form would echo
// the configured origin unconditionally).
.allow_origin([
HeaderValue::from_str(&cfg.site_origin).expect("SITE_ORIGIN is a valid origin")
])
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([AUTHORIZATION, CONTENT_TYPE])
// Without this, browser JS can't read Retry-After on a 429 despite
// the response carrying it — cross-origin responses only expose a
// fixed default header set unless the server opts more in.
.expose_headers([RETRY_AFTER]);
// Layers run bottom-up: the last `.layer` is outermost. Request order is
// therefore cors → guard → identify (resolve only) → rate limit →
// authorize → upstream: a bad credential is counted against the caller's
// limit before it gets rejected, and CORS preflights skip all of it.
Router::new()
.route("/health", get(|| async { "ok" }))
.fallback(proxy::forward::proxy)
.with_state(upstreams)
.layer(axum::middleware::from_fn(identity::authorize))
.layer(axum::middleware::from_fn_with_state(
limits,
rate_limit::enforce,
))
.layer(axum::middleware::from_fn_with_state(
identity,
identity::identify,
))
.layer(axum::middleware::from_fn_with_state(
cfg.trust_proxy,
guard::reject_ambiguous_paths,
))
.layer(cors)
}
fn spawn_purger(limits: Arc<rate_limit::RateLimits>) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
limits.purge();
}
});
}
}

View file

@ -0,0 +1,23 @@
use gateway::identity::device::GrpcDevices;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let cfg = gateway::config::Config::from_env()?;
cfg.validate()?;
let devices = Arc::new(GrpcDevices::connect_lazy(
&cfg.accounts_grpc_url,
&cfg.internal_key,
)?);
let app = gateway::build_app(&cfg, devices);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("gateway listening on {}", cfg.port);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}

View file

@ -0,0 +1,124 @@
use super::routes::{Upstream, upstream_for};
use crate::config::Config;
use axum::{
Json,
body::Body,
extract::{Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use common::internal::INTERNAL_KEY_HEADER;
use serde_json::json;
use std::{sync::Arc, time::Duration};
/// 5 MB avatar + multipart overhead; configs are far smaller.
pub const MAX_BODY_BYTES: usize = 6 * 1024 * 1024;
/// A slow/stalled client body must not hold a connection open forever.
const BODY_READ_TIMEOUT: Duration = Duration::from_secs(30);
const HOP_BY_HOP: [HeaderName; 7] = [
header::CONNECTION,
header::PROXY_AUTHENTICATE,
header::PROXY_AUTHORIZATION,
header::TE,
header::TRAILER,
header::TRANSFER_ENCODING,
header::UPGRADE,
];
pub struct Upstreams {
pub client: reqwest::Client,
pub accounts: String,
pub configs: String,
pub internal_key: HeaderValue,
}
impl Upstreams {
pub fn new(cfg: &Config) -> anyhow::Result<Self> {
Ok(Upstreams {
client: reqwest::Client::builder()
// Never follow redirects on behalf of the client — pass them through.
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()?,
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
})
}
}
fn error(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
fn strip_hop_by_hop(headers: &mut HeaderMap) {
for name in &HOP_BY_HOP {
headers.remove(name);
}
headers.remove("keep-alive");
}
pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
let Some(target) = upstream_for(req.uri().path()) else {
return error(StatusCode::NOT_FOUND, "not found");
};
let base = match target {
Upstream::Accounts => &up.accounts,
Upstream::Configs => &up.configs,
};
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
let url = format!("{base}{path_and_query}");
let (parts, body) = req.into_parts();
let bytes = match tokio::time::timeout(
BODY_READ_TIMEOUT,
axum::body::to_bytes(body, MAX_BODY_BYTES),
)
.await
{
Ok(Ok(bytes)) => bytes,
Ok(Err(err)) => {
// `to_bytes` reports both "over the limit" and "the connection
// broke while reading" the same way; only the former is 413.
let over_limit = std::error::Error::source(&err)
.is_some_and(|e| e.is::<http_body_util::LengthLimitError>());
return if over_limit {
error(StatusCode::PAYLOAD_TOO_LARGE, "payload too large")
} else {
error(StatusCode::BAD_REQUEST, "invalid request body")
};
}
Err(_) => return error(StatusCode::REQUEST_TIMEOUT, "request body read timed out"),
};
let mut headers = parts.headers;
strip_hop_by_hop(&mut headers);
headers.remove(header::HOST);
headers.insert(INTERNAL_KEY_HEADER, up.internal_key.clone());
let upstream = match up
.client
.request(parts.method, url)
.headers(headers)
.body(bytes)
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
tracing::warn!("upstream {target:?} failed: {err}");
return error(StatusCode::BAD_GATEWAY, "upstream unavailable");
}
};
let mut response = Response::builder().status(upstream.status());
for (name, value) in upstream.headers() {
if !HOP_BY_HOP.contains(name) && name != "keep-alive" {
response = response.header(name, value);
}
}
response
.body(Body::from_stream(upstream.bytes_stream()))
.unwrap_or_else(|_| error(StatusCode::BAD_GATEWAY, "bad upstream response"))
}

View file

@ -0,0 +1,2 @@
pub mod forward;
pub mod routes;

View file

@ -0,0 +1,29 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Upstream {
Accounts,
Configs,
}
pub fn upstream_for(path: &str) -> Option<Upstream> {
match path.trim_start_matches('/').split('/').next()? {
"auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts),
"configs" | "showcase" => Some(Upstream::Configs),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_by_first_segment_only() {
assert_eq!(upstream_for("/auth/login"), Some(Upstream::Accounts));
assert_eq!(upstream_for("/me"), Some(Upstream::Accounts));
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
assert_eq!(upstream_for("/authx"), None);
assert_eq!(upstream_for("/"), None);
assert_eq!(upstream_for("/health"), None);
}
}

View file

@ -0,0 +1,111 @@
pub mod rules;
use crate::identity::Identity;
use axum::{
Json,
extract::{ConnectInfo, Request, State},
http::{HeaderValue, StatusCode, header::RETRY_AFTER},
middleware::Next,
response::{IntoResponse, Response},
};
use governor::{
DefaultKeyedRateLimiter, RateLimiter,
clock::{Clock, DefaultClock},
};
use rules::{KeyBy, Rule};
use serde_json::json;
use std::{net::SocketAddr, sync::Arc};
pub struct RateLimits {
rules: Vec<(Rule, DefaultKeyedRateLimiter<String>)>,
global: DefaultKeyedRateLimiter<String>,
trust_proxy: bool,
clock: DefaultClock,
}
impl RateLimits {
pub fn new(trust_proxy: bool) -> Arc<Self> {
Arc::new(RateLimits {
rules: rules::rules()
.into_iter()
.map(|r| {
let l = RateLimiter::keyed(r.quota);
(r, l)
})
.collect(),
global: RateLimiter::keyed(rules::global_quota()),
trust_proxy,
clock: DefaultClock::default(),
})
}
/// Drops idle keys so the maps don't grow forever. Call periodically.
pub fn purge(&self) {
for (_, limiter) in &self.rules {
limiter.retain_recent();
limiter.shrink_to_fit();
}
self.global.retain_recent();
self.global.shrink_to_fit();
}
fn client_ip(&self, req: &Request) -> String {
client_ip(req, self.trust_proxy)
}
}
/// The caller's IP: the rightmost X-Forwarded-For entry (the one our own
/// proxy appended) when `trust_proxy`, else the socket peer.
pub fn client_ip(req: &Request, trust_proxy: bool) -> String {
if trust_proxy
&& let Some(ip) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit(',').next())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return ip.to_owned();
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map_or_else(|| "unknown".to_owned(), |c| c.0.ip().to_string())
}
fn too_many(wait: std::time::Duration) -> Response {
let secs = wait.as_secs_f64().ceil().max(1.0) as u64;
let mut res = (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({ "error": "too many requests" })),
)
.into_response();
res.headers_mut()
.insert(RETRY_AFTER, HeaderValue::from(secs));
res
}
pub async fn enforce(State(limits): State<Arc<RateLimits>>, req: Request, next: Next) -> Response {
let ip_key = format!("ip:{}", limits.client_ip(&req));
let account_key = req
.extensions()
.get::<Identity>()
.and_then(|i| i.0)
.map(|id| format!("acc:{id}"));
let caller_key = account_key.unwrap_or_else(|| ip_key.clone());
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
if let Some((rule, limiter)) = limits.rules.iter().find(|(r, _)| r.matches(&method, &path)) {
let key = match rule.key_by {
KeyBy::Ip => &ip_key,
KeyBy::Account => &caller_key,
};
if let Err(not_until) = limiter.check_key(key) {
return too_many(not_until.wait_time_from(limits.clock.now()));
}
}
if let Err(not_until) = limits.global.check_key(&caller_key) {
return too_many(not_until.wait_time_from(limits.clock.now()));
}
next.run(req).await
}

View file

@ -0,0 +1,85 @@
use axum::http::Method;
use governor::Quota;
use std::num::NonZeroU32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyBy {
Ip,
Account,
}
pub struct Rule {
pub method: Method,
/// Exact path, or a prefix when it ends with '*'.
pub pattern: &'static str,
pub quota: Quota,
pub key_by: KeyBy,
}
impl Rule {
pub fn matches(&self, method: &Method, path: &str) -> bool {
if self.method != *method {
return false;
}
match self.pattern.strip_suffix('*') {
Some(prefix) => path.starts_with(prefix),
None => path == self.pattern,
}
}
}
fn n(v: u32) -> NonZeroU32 {
NonZeroU32::new(v).expect("rate-limit constants are non-zero")
}
fn rule(method: Method, pattern: &'static str, quota: Quota, key_by: KeyBy) -> Rule {
Rule {
method,
pattern,
quota,
key_by,
}
}
pub fn rules() -> Vec<Rule> {
use KeyBy::*;
vec![
rule(Method::POST, "/auth/login", Quota::per_minute(n(5)), Ip),
rule(Method::POST, "/auth/register", Quota::per_hour(n(3)), Ip),
rule(Method::POST, "/auth/refresh", Quota::per_minute(n(30)), Ip),
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.
rule(Method::POST, "/device/token", Quota::per_minute(n(30)), Ip),
rule(Method::PUT, "/configs/*", Quota::per_minute(n(20)), Account),
rule(
Method::GET,
"/configs/shared/*",
Quota::per_minute(n(30)),
Ip,
),
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
]
}
pub fn global_quota() -> Quota {
Quota::per_minute(n(300))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_and_prefix_matching() {
let all = rules();
let find = |m: Method, p: &str| all.iter().position(|r| r.matches(&m, p));
assert_eq!(find(Method::POST, "/auth/login"), Some(0));
assert_eq!(find(Method::GET, "/auth/login"), None);
assert_eq!(find(Method::POST, "/auth/login/x"), None);
assert!(find(Method::PUT, "/configs/3").is_some());
assert!(find(Method::GET, "/configs/shared/ABCD").is_some());
assert!(find(Method::GET, "/showcase").is_some());
assert!(find(Method::GET, "/showcase/11111111-1111-1111-1111-111111111111").is_some());
}
}

View file

@ -0,0 +1,141 @@
#![allow(dead_code)]
use axum::{Json, Router, body::Bytes, extract::Request, routing::any};
use gateway::config::Config;
use gateway::identity::device::{DeviceAuth, DeviceAuthFuture, DeviceAuthenticator};
use std::sync::Arc;
use uuid::Uuid;
pub const JWT_SECRET: &str = "gateway-test-secret-gateway-test!!";
pub const INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
#[allow(unused_imports)] // used by identity.rs; other test crates don't need it
pub use ::common::jwt;
pub fn config(accounts: &str, configs: &str) -> Config {
Config {
port: 0,
jwt_secret: JWT_SECRET.into(),
internal_key: INTERNAL_KEY.into(),
accounts_http_url: accounts.into(),
accounts_grpc_url: String::new(),
configs_http_url: configs.into(),
site_origin: "http://localhost:5173".into(),
trust_proxy: true,
}
}
/// Accepts exactly one device token, mapped to one account.
pub struct FakeDevices {
pub token: String,
pub account: Uuid,
}
impl DeviceAuthenticator for FakeDevices {
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a> {
Box::pin(async move {
if token == self.token {
DeviceAuth::Valid(self.account)
} else {
DeviceAuth::Invalid
}
})
}
}
/// accounts-service unreachable: every device token lookup fails.
pub struct DownDevices;
impl DeviceAuthenticator for DownDevices {
fn authenticate<'a>(&'a self, _token: &'a str) -> DeviceAuthFuture<'a> {
Box::pin(async { DeviceAuth::Unavailable })
}
}
/// An access JWT signed with the right key but already expired.
pub fn expired_access_token(account: Uuid) -> String {
let exp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_secs() as usize
- 60;
let claims = ::common::jwt::Claims {
sub: account.to_string(),
exp,
token_type: ::common::jwt::TokenType::Access,
};
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
&claims,
&jsonwebtoken::EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.expect("encode test jwt")
}
pub fn no_devices() -> Arc<dyn DeviceAuthenticator> {
Arc::new(FakeDevices {
token: "lvd_none".into(),
account: Uuid::nil(),
})
}
/// Starts a fake service that echoes what it received as JSON; returns its base URL.
pub async fn spawn_echo() -> String {
async fn echo(req: Request) -> Json<serde_json::Value> {
let (parts, body) = req.into_parts();
let body: Bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
let header = |name: &str| {
parts
.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
};
Json(serde_json::json!({
"method": parts.method.as_str(),
"path": parts.uri.path(),
"query": parts.uri.query(),
"body": String::from_utf8_lossy(&body),
"account_id": header("x-lovisual-account-id"),
"internal_key": header("x-lovisual-internal-key"),
"authorization": header("authorization"),
}))
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(any(echo)))
.await
.unwrap()
});
format!("http://{addr}")
}
/// Sends a request straight into the router, bypassing the test client's URL
/// handling, so the raw path (`..`, `%2e`, `//`) reaches the gateway as-is.
pub async fn raw(
app: &axum::Router,
method: &str,
uri: &str,
forwarded_for: &str,
) -> (axum::http::StatusCode, axum::http::HeaderMap, String) {
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method(method)
.uri(uri)
.header("x-forwarded-for", forwarded_for)
.body(axum::body::Body::empty())
.expect("valid raw request");
let res = app.clone().oneshot(req).await.expect("infallible router");
let (parts, body) = res.into_parts();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
(
parts.status,
parts.headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}

View file

@ -0,0 +1,111 @@
mod common;
use axum::http::StatusCode;
use std::sync::Arc;
use uuid::Uuid;
async fn server(devices: common::FakeDevices) -> axum_test::TestServer {
let echo = common::spawn_echo().await;
let app = gateway::build_app(&common::config(&echo, &echo), Arc::new(devices));
axum_test::TestServer::new(app)
}
fn devices() -> common::FakeDevices {
common::FakeDevices {
token: "lvd_good".into(),
account: Uuid::new_v4(),
}
}
#[tokio::test]
async fn access_jwt_becomes_account_header_and_authorization_is_dropped() {
let account = Uuid::new_v4();
let token = common::jwt::issue_access_token(account, common::JWT_SECRET);
let echo: serde_json::Value = server(devices())
.await
.get("/me")
.authorization_bearer(token)
.await
.json();
assert_eq!(echo["account_id"], account.to_string());
assert!(echo["authorization"].is_null());
}
#[tokio::test]
async fn device_token_is_resolved_via_authenticator() {
let d = devices();
let account = d.account;
let echo: serde_json::Value = server(d)
.await
.get("/configs")
.authorization_bearer("lvd_good")
.await
.json();
assert_eq!(echo["account_id"], account.to_string());
}
#[tokio::test]
async fn bad_credentials_are_rejected_at_the_gateway() {
let s = server(devices()).await;
s.get("/me")
.authorization_bearer("lvd_bad")
.await
.assert_status(StatusCode::UNAUTHORIZED);
s.get("/me")
.authorization_bearer("not.a.jwt")
.await
.assert_status(StatusCode::UNAUTHORIZED);
let wrong_key =
common::jwt::issue_access_token(Uuid::new_v4(), "another-secret-another-secret-12345");
s.get("/me")
.authorization_bearer(wrong_key)
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn anonymous_requests_pass_without_identity() {
let echo: serde_json::Value = server(devices()).await.post("/auth/login").await.json();
assert!(echo["account_id"].is_null());
}
#[tokio::test]
async fn spoofed_identity_header_never_reaches_the_service() {
let echo: serde_json::Value = server(devices())
.await
.get("/me")
.add_header("x-lovisual-account-id", Uuid::new_v4().to_string())
.await
.json();
assert!(echo["account_id"].is_null());
}
#[tokio::test]
async fn stale_credentials_on_auth_paths_are_treated_as_anonymous() {
let s = server(devices()).await;
let expired = common::expired_access_token(Uuid::new_v4());
for token in [expired.as_str(), "lvd_bad", "not.a.jwt"] {
let res = s.post("/auth/logout").authorization_bearer(token).await;
res.assert_status_ok();
let echo: serde_json::Value = res.json();
assert_eq!(echo["path"], "/auth/logout", "reached upstream");
assert!(echo["account_id"].is_null());
assert!(echo["authorization"].is_null());
}
// Outside /auth/ the same token is still rejected.
s.get("/me")
.authorization_bearer(expired)
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unreachable_device_backend_is_503() {
let echo = common::spawn_echo().await;
let app = gateway::build_app(&common::config(&echo, &echo), Arc::new(common::DownDevices));
axum_test::TestServer::new(app)
.get("/configs")
.authorization_bearer("lvd_whatever")
.await
.assert_status(StatusCode::SERVICE_UNAVAILABLE);
}

View file

@ -0,0 +1,89 @@
mod common;
use axum::http::StatusCode;
async fn app() -> axum::Router {
let echo = common::spawn_echo().await;
gateway::build_app(&common::config(&echo, &echo), common::no_devices())
}
#[tokio::test]
async fn dot_segments_encoded_slashes_and_empty_segments_are_400() {
let app = app().await;
for uri in [
"/auth/x/../login",
"/auth/./login",
"/auth/%2e%2e/device/token",
"/auth/%2E%2E/login",
"/auth/x/.%2e/login",
"/auth/x/%2e./login",
"/auth/%2e/login",
"/configs%2f1",
"/configs/1%5Cx",
"/configs/1\\..\\2",
"//evil.com/x",
"/auth//login",
] {
let (status, _, body) = common::raw(&app, "POST", uri, "203.0.113.50").await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}");
assert!(body.contains("bad path"), "{uri}: {body}");
}
}
#[tokio::test]
async fn normal_paths_are_unaffected() {
let app = app().await;
for uri in ["/auth/login", "/configs/2?x=../y", "/showcase", "/health"] {
let (status, _, _) = common::raw(&app, "POST", uri, "203.0.113.51").await;
assert_ne!(status, StatusCode::BAD_REQUEST, "{uri}");
}
let (status, _, _) = common::raw(&app, "GET", "/", "203.0.113.51").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn traversal_cannot_dodge_the_login_limit() {
let app = app().await;
for _ in 0..8 {
let (status, _, _) = common::raw(&app, "POST", "/auth/x/../login", "203.0.113.52").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
for _ in 0..5 {
let (status, _, _) = common::raw(&app, "POST", "/auth/login", "203.0.113.52").await;
assert_eq!(status, StatusCode::OK);
}
let (status, _, _) = common::raw(&app, "POST", "/auth/login", "203.0.113.52").await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn env_probes_get_the_honeypot_instead_of_400() {
let app = app().await;
for uri in [
"/../../.env",
"/%2e%2e/.env",
"/.env",
"/api/.env",
"/.env.local",
"/.env.production",
] {
let (status, headers, body) = common::raw(&app, "GET", uri, "203.0.113.53").await;
assert_eq!(status, StatusCode::OK, "{uri}");
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
assert!(body.contains("nice_try_skiddie"), "{uri}: {body}");
assert!(!body.contains("\"path\""), "never forwarded upstream");
}
}
#[tokio::test]
async fn coffee_is_a_teapot_for_every_method() {
let app = app().await;
for method in ["GET", "POST", "PUT"] {
let (status, headers, body) = common::raw(&app, method, "/coffee", "203.0.113.54").await;
assert_eq!(status, StatusCode::IM_A_TEAPOT, "{method}");
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
assert!(body.contains("Я чайник"), "{method}: {body}");
assert!(body.contains("LoVisual-/releases"), "{method}: {body}");
assert!(!body.contains("\"path\""), "never forwarded upstream");
}
}

View file

@ -0,0 +1,110 @@
mod common;
use axum::http::StatusCode;
async fn server() -> axum_test::TestServer {
let accounts = common::spawn_echo().await;
let configs = common::spawn_echo().await;
let app = gateway::build_app(&common::config(&accounts, &configs), common::no_devices());
axum_test::TestServer::new(app)
}
#[tokio::test]
async fn health_is_ok() {
server().await.get("/health").await.assert_status_ok();
}
#[tokio::test]
async fn forwards_method_path_query_and_body() {
let res = server().await.put("/configs/2?x=1").text("payload").await;
res.assert_status_ok();
let echo: serde_json::Value = res.json();
assert_eq!(echo["method"], "PUT");
assert_eq!(echo["path"], "/configs/2");
assert_eq!(echo["query"], "x=1");
assert_eq!(echo["body"], "payload");
}
#[tokio::test]
async fn adds_internal_key_and_strips_spoofed_identity() {
let res = server()
.await
.post("/auth/login")
.add_header("x-lovisual-account-id", uuid::Uuid::new_v4().to_string())
.add_header("x-lovisual-internal-key", "spoofed")
.await;
let echo: serde_json::Value = res.json();
assert_eq!(echo["internal_key"], common::INTERNAL_KEY);
assert!(echo["account_id"].is_null());
}
#[tokio::test]
async fn unknown_prefix_is_404() {
server()
.await
.get("/nope")
.await
.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dead_upstream_is_502() {
let app = gateway::build_app(
&common::config("http://127.0.0.1:1", "http://127.0.0.1:1"),
common::no_devices(),
);
axum_test::TestServer::new(app)
.get("/me")
.await
.assert_status(StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn oversized_body_is_413() {
let big = "x".repeat(6 * 1024 * 1024 + 1);
server()
.await
.put("/configs/1")
.text(big)
.await
.assert_status(StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn cors_preflight_allows_only_the_site_origin() {
let app = gateway::build_app(
&common::config("http://127.0.0.1:1", "http://127.0.0.1:1"),
common::no_devices(),
);
let s = axum_test::TestServer::new(app);
let ok = s
.method(axum::http::Method::OPTIONS, "/auth/login")
.add_header("origin", "http://localhost:5173")
.add_header("access-control-request-method", "POST")
.await;
assert_eq!(
ok.header("access-control-allow-origin"),
"http://localhost:5173"
);
assert_eq!(ok.header("access-control-allow-credentials"), "true");
let evil = s
.method(axum::http::Method::OPTIONS, "/auth/login")
.add_header("origin", "https://evil.example")
.add_header("access-control-request-method", "POST")
.await;
assert!(evil.maybe_header("access-control-allow-origin").is_none());
}
#[tokio::test]
async fn cors_exposes_retry_after_so_js_can_read_it() {
let s = server().await;
let res = s
.post("/auth/login")
.add_header("origin", "http://localhost:5173")
.await;
assert_eq!(
res.header("access-control-expose-headers"),
"retry-after"
);
}

View file

@ -0,0 +1,78 @@
mod common;
use axum::http::StatusCode;
async fn server() -> axum_test::TestServer {
let echo = common::spawn_echo().await;
axum_test::TestServer::new(gateway::build_app(
&common::config(&echo, &echo),
common::no_devices(),
))
}
#[tokio::test]
async fn sixth_login_in_a_minute_from_one_ip_is_429_with_retry_after() {
let s = server().await;
for _ in 0..5 {
s.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.7")
.await
.assert_status_ok();
}
let res = s
.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.7")
.await;
res.assert_status(StatusCode::TOO_MANY_REQUESTS);
let retry: u64 = res.header("retry-after").to_str().unwrap().parse().unwrap();
assert!((1..=60).contains(&retry));
}
#[tokio::test]
async fn limits_are_per_ip() {
let s = server().await;
for _ in 0..5 {
s.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.8")
.await;
}
s.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.9")
.await
.assert_status_ok();
}
#[tokio::test]
async fn rightmost_forwarded_for_entry_is_used() {
// A client can prepend fake entries; only the one our proxy appended counts.
let s = server().await;
for i in 0..5 {
s.post("/auth/login")
.add_header("x-forwarded-for", format!("10.0.0.{i}, 203.0.113.10"))
.await
.assert_status_ok();
}
s.post("/auth/login")
.add_header("x-forwarded-for", "1.1.1.1, 203.0.113.10")
.await
.assert_status(StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn failed_credentials_count_against_the_ip_limit() {
// Every bad device token costs accounts-service a gRPC call + DB query,
// so the per-IP global limit must apply before identity rejects it.
let s = server().await;
for _ in 0..300 {
s.get("/configs")
.authorization_bearer("lvd_bad")
.add_header("x-forwarded-for", "203.0.113.20")
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
s.get("/configs")
.authorization_bearer("lvd_bad")
.add_header("x-forwarded-for", "203.0.113.20")
.await
.assert_status(StatusCode::TOO_MANY_REQUESTS);
}