From 961ccf7cf28a2b028b464b289a87986fdcd0675f Mon Sep 17 00:00:00 2001 From: Bryan Stitt Date: Fri, 23 Sep 2022 05:22:33 +0000 Subject: [PATCH] ip, origin, referer, and user agent checks --- Cargo.lock | 1 + TODO.md | 2 +- entities/src/user_keys.rs | 6 +- migration/src/m20220921_181610_log_reverts.rs | 16 +- web3_proxy/Cargo.toml | 1 + web3_proxy/src/app.rs | 15 +- web3_proxy/src/bin/web3_proxy.rs | 4 +- .../src/bin/web3_proxy_cli/create_user.rs | 10 +- web3_proxy/src/config.rs | 9 +- web3_proxy/src/frontend/authorization.rs | 174 +++++++++++++----- web3_proxy/src/frontend/rpc_proxy_http.rs | 4 +- web3_proxy/src/frontend/rpc_proxy_ws.rs | 4 +- web3_proxy/src/rpcs/request.rs | 6 +- 13 files changed, 171 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d338b94..a0657e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5580,6 +5580,7 @@ dependencies = [ "handlebars", "hashbrown", "http", + "ipnet", "metered", "migration", "moka", diff --git a/TODO.md b/TODO.md index fde4bd48..258081c0 100644 --- a/TODO.md +++ b/TODO.md @@ -160,7 +160,7 @@ These are roughly in order of completition - [-] opt-in debug mode that inspects responses for reverts and saves the request to the database for the user. - [-] let them choose a % to log (or maybe x/second). someone like curve logging all reverts will be a BIG database very quickly - this must be opt-in or spawned since it will slow things down and will make their calls less private -- [-] Api keys need option to lock to IP, cors header, referer, etc +- [-] Api keys need option to lock to IP, cors header, referer, user agent, etc - [ ] active requests per second per api key - [ ] distribution of methods per api key (eth_call, eth_getLogs, etc.) - [-] add configurable size limits to all the Caches diff --git a/entities/src/user_keys.rs b/entities/src/user_keys.rs index 41ce1d28..f93e98e2 100644 --- a/entities/src/user_keys.rs +++ b/entities/src/user_keys.rs @@ -15,7 +15,11 @@ pub struct Model { pub description: Option, pub private_txs: bool, pub active: bool, - pub requests_per_minute: u64, + pub requests_per_minute: Option, + pub allowed_ips: Option, + pub allowed_origins: Option, + pub allowed_referers: Option, + pub allowed_user_agents: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/migration/src/m20220921_181610_log_reverts.rs b/migration/src/m20220921_181610_log_reverts.rs index 606ccb53..34357929 100644 --- a/migration/src/m20220921_181610_log_reverts.rs +++ b/migration/src/m20220921_181610_log_reverts.rs @@ -16,15 +16,20 @@ impl MigrationTrait for Migration { .modify_column( ColumnDef::new(UserKeys::RequestsPerMinute) .big_unsigned() - .not_null(), + .null(), ) // add a column for logging reverts in the RevertLogs table .add_column( ColumnDef::new(UserKeys::LogReverts) - .boolean() + .decimal_len(5, 4) .not_null() - .default(false), + .default("0.0"), ) + // add columns for more advanced authorization + .add_column(ColumnDef::new(UserKeys::AllowedIps).text().null()) + .add_column(ColumnDef::new(UserKeys::AllowedOrigins).text().null()) + .add_column(ColumnDef::new(UserKeys::AllowedReferers).text().null()) + .add_column(ColumnDef::new(UserKeys::AllowedUserAgents).text().null()) .to_owned(), ) .await?; @@ -97,6 +102,7 @@ impl MigrationTrait for Migration { pub enum UserKeys { Table, Id, + // we don't touch some of the columns // UserId, // ApiKey, // Description, @@ -104,6 +110,10 @@ pub enum UserKeys { // Active, RequestsPerMinute, LogReverts, + AllowedIps, + AllowedOrigins, + AllowedReferers, + AllowedUserAgents, } #[derive(Iden)] diff --git a/web3_proxy/Cargo.toml b/web3_proxy/Cargo.toml index b17a5f88..6463a839 100644 --- a/web3_proxy/Cargo.toml +++ b/web3_proxy/Cargo.toml @@ -36,6 +36,7 @@ flume = "0.10.14" futures = { version = "0.3.24", features = ["thread-pool"] } hashbrown = { version = "0.12.3", features = ["serde"] } http = "0.2.8" +ipnet = "*" metered = { version = "0.9.0", features = ["serialize"] } moka = { version = "0.9.4", default-features = false, features = ["future"] } notify = "5.0.0" diff --git a/web3_proxy/src/app.rs b/web3_proxy/src/app.rs index 9ba59801..9a5d5a9b 100644 --- a/web3_proxy/src/app.rs +++ b/web3_proxy/src/app.rs @@ -24,6 +24,7 @@ use futures::stream::FuturesUnordered; use futures::stream::StreamExt; use futures::Future; use hashbrown::HashMap; +use ipnet::IpNet; use metered::{metered, ErrorCount, HitCount, ResponseTime, Throughput}; use migration::{Migrator, MigratorTrait}; use moka::future::Cache; @@ -66,14 +67,16 @@ pub type AnyhowJoinHandle = JoinHandle>; /// TODO: rename this? pub struct UserKeyData { pub user_key_id: u64, - /// if None, allow unlimited queries - pub user_count_per_period: Option, + /// if u64::MAX, allow unlimited queries + pub user_max_requests_per_period: Option, + /// if None, allow any Origin + pub allowed_origins: Option>, /// if None, allow any Referer - pub allowed_referer: Option, + pub allowed_referers: Option>, /// if None, allow any UserAgent - pub allowed_user_agent: Option, - /// if None, allow any IpAddr - pub allowed_ip: Option, + pub allowed_user_agents: Option>, + /// if None, allow any IP Address + pub allowed_ips: Option>, } /// The application diff --git a/web3_proxy/src/bin/web3_proxy.rs b/web3_proxy/src/bin/web3_proxy.rs index 9524739f..dd5a7481 100644 --- a/web3_proxy/src/bin/web3_proxy.rs +++ b/web3_proxy/src/bin/web3_proxy.rs @@ -210,10 +210,10 @@ mod tests { let app_config = TopConfig { app: AppConfig { chain_id: 31337, - default_requests_per_minute: 6_000_000, + default_requests_per_minute: Some(6_000_000), min_sum_soft_limit: 1, min_synced_rpcs: 1, - public_rate_limit_per_minute: 6_000_000, + public_rate_limit_per_minute: 1_000_000, response_cache_max_bytes: 10_usize.pow(7), redirect_public_url: "example.com/".to_string(), redirect_user_url: "example.com/{{user_id}}".to_string(), diff --git a/web3_proxy/src/bin/web3_proxy_cli/create_user.rs b/web3_proxy/src/bin/web3_proxy_cli/create_user.rs index 71e50abf..dc2b8243 100644 --- a/web3_proxy/src/bin/web3_proxy_cli/create_user.rs +++ b/web3_proxy/src/bin/web3_proxy_cli/create_user.rs @@ -7,11 +7,6 @@ use tracing::info; use uuid::Uuid; use web3_proxy::users::new_api_key; -/// default to max int which the code sees as "unlimited" requests -fn default_rpm() -> u64 { - u64::MAX -} - #[derive(FromArgs, PartialEq, Debug, Eq)] /// Create a new user and api key #[argh(subcommand, name = "create_user")] @@ -29,9 +24,10 @@ pub struct CreateUserSubCommand { /// If none given, one will be generated randomly. api_key: Uuid, - #[argh(option, default = "default_rpm()")] + #[argh(option)] /// maximum requests per minute - rpm: u64, + /// default to "None" which the code sees as "unlimited" requests + rpm: Option, } impl CreateUserSubCommand { diff --git a/web3_proxy/src/config.rs b/web3_proxy/src/config.rs index c28dcb0a..823b5bf4 100644 --- a/web3_proxy/src/config.rs +++ b/web3_proxy/src/config.rs @@ -54,8 +54,7 @@ pub struct AppConfig { /// If none, the minimum * 2 is used pub db_max_connections: Option, pub influxdb_url: Option, - #[serde(default = "default_default_requests_per_minute")] - pub default_requests_per_minute: u64, + pub default_requests_per_minute: Option, pub invite_code: Option, #[serde(default = "default_min_sum_soft_limit")] pub min_sum_soft_limit: u32, @@ -76,12 +75,6 @@ pub struct AppConfig { pub redirect_user_url: String, } -/// default to unlimited requests -/// TODO: pick a lower limit so we don't get DOSd -fn default_default_requests_per_minute() -> u64 { - u64::MAX -} - fn default_min_sum_soft_limit() -> u32 { 1 } diff --git a/web3_proxy/src/frontend/authorization.rs b/web3_proxy/src/frontend/authorization.rs index 92bb2933..f53f4700 100644 --- a/web3_proxy/src/frontend/authorization.rs +++ b/web3_proxy/src/frontend/authorization.rs @@ -1,17 +1,15 @@ use super::errors::FrontendErrorResponse; use crate::app::{UserKeyData, Web3ProxyApp}; use anyhow::Context; -use axum::headers::{Referer, UserAgent}; +use axum::headers::{Origin, Referer, UserAgent}; use deferred_rate_limiter::DeferredRateLimitResult; use entities::user_keys; -use sea_orm::{ - ColumnTrait, DatabaseConnection, DeriveColumn, EntityTrait, EnumIter, IdenStatic, QueryFilter, - QuerySelect, -}; +use ipnet::IpNet; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use serde::Serialize; use std::{net::IpAddr, sync::Arc}; use tokio::time::Instant; -use tracing::{error, trace, warn}; +use tracing::{error, trace}; use uuid::Uuid; #[derive(Debug)] @@ -31,6 +29,7 @@ pub enum RateLimitResult { #[derive(Debug, Serialize)] pub struct AuthorizedKey { ip: IpAddr, + origin: Option, user_key_id: u64, // TODO: what else? } @@ -38,14 +37,64 @@ pub struct AuthorizedKey { impl AuthorizedKey { pub fn try_new( ip: IpAddr, - user_data: UserKeyData, + origin: Option, referer: Option, user_agent: Option, + user_data: UserKeyData, ) -> anyhow::Result { - warn!("todo: check referer and user_agent against user_data"); + // check ip + match &user_data.allowed_ips { + None => {} + Some(allowed_ips) => { + if !allowed_ips.iter().any(|x| x.contains(&ip)) { + return Err(anyhow::anyhow!("IP is not allowed!")); + } + } + } + + // check origin + // TODO: do this with the Origin type instead of a String? + let origin = origin.map(|x| x.to_string()); + match (&origin, &user_data.allowed_origins) { + (None, None) => {} + (Some(_), None) => {} + (None, Some(_)) => return Err(anyhow::anyhow!("Origin required")), + (Some(origin), Some(allowed_origins)) => { + let origin = origin.to_string(); + + if !allowed_origins.contains(&origin) { + return Err(anyhow::anyhow!("IP is not allowed!")); + } + } + } + + // check referer + match (referer, &user_data.allowed_referers) { + (None, None) => {} + (Some(_), None) => {} + (None, Some(_)) => return Err(anyhow::anyhow!("Referer required")), + (Some(referer), Some(allowed_referers)) => { + if !allowed_referers.contains(&referer) { + return Err(anyhow::anyhow!("Referer is not allowed!")); + } + } + } + + // check user_agent + match (user_agent, &user_data.allowed_user_agents) { + (None, None) => {} + (Some(_), None) => {} + (None, Some(_)) => return Err(anyhow::anyhow!("User agent required")), + (Some(user_agent), Some(allowed_user_agents)) => { + if !allowed_user_agents.contains(&user_agent) { + return Err(anyhow::anyhow!("User agent is not allowed!")); + } + } + } Ok(Self { ip, + origin, user_key_id: user_data.user_key_id, }) } @@ -62,14 +111,12 @@ pub enum AuthorizedRequest { } impl AuthorizedRequest { - pub fn has_db(&self) -> bool { - let db_conn = match self { - Self::Internal(db_conn) => db_conn, - Self::Ip(db_conn, _) => db_conn, - Self::User(db_conn, _) => db_conn, - }; - - db_conn.is_some() + pub fn db_conn(&self) -> Option<&DatabaseConnection> { + match self { + Self::Internal(x) => x.as_ref(), + Self::Ip(x, _) => x.as_ref(), + Self::User(x, _) => x.as_ref(), + } } } @@ -96,6 +143,7 @@ pub async fn key_is_authorized( app: &Web3ProxyApp, user_key: Uuid, ip: IpAddr, + origin: Option, referer: Option, user_agent: Option, ) -> Result { @@ -110,7 +158,7 @@ pub async fn key_is_authorized( x => unimplemented!("rate_limit_by_key shouldn't ever see these: {:?}", x), }; - let authorized_user = AuthorizedKey::try_new(ip, user_data, referer, user_agent)?; + let authorized_user = AuthorizedKey::try_new(ip, origin, referer, user_agent, user_data)?; let db = app.db_conn.clone(); @@ -159,50 +207,76 @@ impl Web3ProxyApp { let db = self.db_conn.as_ref().context("no database")?; - /// helper enum for querying just a few columns instead of the entire table - /// TODO: query more! we need allowed ips, referers, and probably other things - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] - enum QueryAs { - Id, - RequestsPerMinute, - } - // TODO: join the user table to this to return the User? we don't always need it match user_keys::Entity::find() - .select_only() - .column_as(user_keys::Column::Id, QueryAs::Id) - .column_as( - user_keys::Column::RequestsPerMinute, - QueryAs::RequestsPerMinute, - ) .filter(user_keys::Column::ApiKey.eq(user_key)) .filter(user_keys::Column::Active.eq(true)) - .into_values::<_, QueryAs>() .one(db) .await? { - Some((user_key_id, requests_per_minute)) => { - // TODO: add a column here for max, or is u64::MAX fine? - let user_count_per_period = if requests_per_minute == u64::MAX { - None - } else { - Some(requests_per_minute) - }; + Some(user_key_model) => { + let allowed_ips: Option> = + user_key_model.allowed_ips.map(|allowed_ips| { + serde_json::from_str::>(&allowed_ips) + .expect("allowed_ips should always parse") + .into_iter() + // TODO: try_for_each + .map(|x| { + x.parse::().expect("ip address should always parse") + }) + .collect() + }); + + // TODO: should this be an Option>? + let allowed_origins = + user_key_model.allowed_origins.map(|allowed_origins| { + serde_json::from_str::>(&allowed_origins) + .expect("allowed_origins should always parse") + }); + + let allowed_referers = + user_key_model.allowed_referers.map(|allowed_referers| { + serde_json::from_str::>(&allowed_referers) + .expect("allowed_referers should always parse") + .into_iter() + // TODO: try_for_each + .map(|x| { + x.parse::().expect("referer should always parse") + }) + .collect() + }); + + let allowed_user_agents = + user_key_model + .allowed_user_agents + .map(|allowed_user_agents| { + serde_json::from_str::>(&allowed_user_agents) + .expect("allowed_user_agents should always parse") + .into_iter() + // TODO: try_for_each + .map(|x| { + x.parse::() + .expect("user agent should always parse") + }) + .collect() + }); Ok(UserKeyData { - user_key_id, - user_count_per_period, - allowed_ip: None, - allowed_referer: None, - allowed_user_agent: None, + user_key_id: user_key_model.id, + user_max_requests_per_period: user_key_model.requests_per_minute, + allowed_ips, + allowed_origins, + allowed_referers, + allowed_user_agents, }) } None => Ok(UserKeyData { user_key_id: 0, - user_count_per_period: Some(0), - allowed_ip: None, - allowed_referer: None, - allowed_user_agent: None, + user_max_requests_per_period: Some(0), + allowed_ips: None, + allowed_origins: None, + allowed_referers: None, + allowed_user_agents: None, }), } }) @@ -219,7 +293,7 @@ impl Web3ProxyApp { return Ok(RateLimitResult::UnknownKey); } - let user_count_per_period = match user_data.user_count_per_period { + let user_max_requests_per_period = match user_data.user_max_requests_per_period { None => return Ok(RateLimitResult::AllowedUser(user_data)), Some(x) => x, }; @@ -227,7 +301,7 @@ impl Web3ProxyApp { // user key is valid. now check rate limits if let Some(rate_limiter) = &self.frontend_key_rate_limiter { match rate_limiter - .throttle(user_key, Some(user_count_per_period), 1) + .throttle(user_key, Some(user_max_requests_per_period), 1) .await { Ok(DeferredRateLimitResult::Allowed) => Ok(RateLimitResult::AllowedUser(user_data)), diff --git a/web3_proxy/src/frontend/rpc_proxy_http.rs b/web3_proxy/src/frontend/rpc_proxy_http.rs index 82709f20..2168bb70 100644 --- a/web3_proxy/src/frontend/rpc_proxy_http.rs +++ b/web3_proxy/src/frontend/rpc_proxy_http.rs @@ -2,7 +2,7 @@ use super::authorization::{ip_is_authorized, key_is_authorized}; use super::errors::FrontendResult; use crate::{app::Web3ProxyApp, jsonrpc::JsonRpcRequestEnum}; use axum::extract::Path; -use axum::headers::{Referer, UserAgent}; +use axum::headers::{Origin, Referer, UserAgent}; use axum::TypedHeader; use axum::{response::IntoResponse, Extension, Json}; use axum_client_ip::ClientIp; @@ -42,6 +42,7 @@ pub async fn user_proxy_web3_rpc( Extension(app): Extension>, ClientIp(ip): ClientIp, Json(payload): Json, + origin: Option>, referer: Option>, user_agent: Option>, Path(user_key): Path, @@ -53,6 +54,7 @@ pub async fn user_proxy_web3_rpc( &app, user_key, ip, + origin.map(|x| x.0), referer.map(|x| x.0), user_agent.map(|x| x.0), ) diff --git a/web3_proxy/src/frontend/rpc_proxy_ws.rs b/web3_proxy/src/frontend/rpc_proxy_ws.rs index 7061a36d..a6004494 100644 --- a/web3_proxy/src/frontend/rpc_proxy_ws.rs +++ b/web3_proxy/src/frontend/rpc_proxy_ws.rs @@ -1,6 +1,6 @@ use super::authorization::{ip_is_authorized, key_is_authorized, AuthorizedRequest}; use super::errors::FrontendResult; -use axum::headers::{Referer, UserAgent}; +use axum::headers::{Origin, Referer, UserAgent}; use axum::{ extract::ws::{Message, WebSocket, WebSocketUpgrade}, extract::Path, @@ -57,6 +57,7 @@ pub async fn user_websocket_handler( Extension(app): Extension>, ClientIp(ip): ClientIp, Path(user_key): Path, + origin: Option>, referer: Option>, user_agent: Option>, ws_upgrade: Option, @@ -65,6 +66,7 @@ pub async fn user_websocket_handler( &app, user_key, ip, + origin.map(|x| x.0), referer.map(|x| x.0), user_agent.map(|x| x.0), ) diff --git a/web3_proxy/src/rpcs/request.rs b/web3_proxy/src/rpcs/request.rs index 14de40e6..f81f021d 100644 --- a/web3_proxy/src/rpcs/request.rs +++ b/web3_proxy/src/rpcs/request.rs @@ -2,6 +2,7 @@ use super::connection::Web3Connection; use super::provider::Web3Provider; use crate::frontend::authorization::AuthorizedRequest; use crate::metered::{JsonRpcErrorCount, ProviderErrorCount}; +use anyhow::Context; use ethers::providers::{HttpClientError, ProviderError, WsClientError}; use metered::metered; use metered::HitCount; @@ -63,6 +64,8 @@ impl AuthorizedRequest { where T: Clone + fmt::Debug + serde::Serialize + Send + Sync + 'static, { + let db_conn = self.db_conn().context("db_conn needed to save reverts")?; + todo!("save the revert to the database"); } } @@ -158,7 +161,8 @@ impl OpenRequestHandle { let error_handler = if let RequestErrorHandler::SaveReverts(save_chance) = error_handler { if ["eth_call", "eth_estimateGas"].contains(&method) - && self.authorization.has_db() + && self.authorization.db_conn().is_some() + && save_chance != 0.0 && (save_chance == 1.0 || rand::thread_rng().gen_range(0.0..=1.0) <= save_chance) {