Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
flate2 = "1"
sha2 = "0.10"
hex = "0.4"
subtle = "2.5" # Constant-time comparison for security-sensitive operations

# S3-compatible object storage (rust-s3 is lighter than AWS SDK)
rust-s3 = { version = "0.35", default-features = false, features = ["tokio-rustls-tls"] }
Expand Down
127 changes: 127 additions & 0 deletions api/src/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Shared authentication utilities for API routes.
//!
//! This module provides common auth functions used across routes to ensure
//! consistent security practices like constant-time comparison.

use axum::http::HeaderMap;
use sha2::Digest;
use subtle::ConstantTimeEq;

/// Extracts and parses a Bearer token from the Authorization header.
///
/// Returns `Some(token)` if a valid "Bearer <token>" header is found,
/// `None` otherwise.
pub fn parse_bearer_token(headers: &HeaderMap) -> Option<String> {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.trim();
let prefix = "bearer ";
if auth.len() <= prefix.len() {
return None;
}
if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
Some(auth[prefix.len()..].trim().to_string())
}

/// Computes SHA-256 hash of the input and returns it as a hex string.
pub fn sha256_hex(input: &str) -> String {
let mut h = sha2::Sha256::new();
h.update(input.as_bytes());
let out = h.finalize();
hex::encode(out)
}

/// Performs a constant-time comparison of two strings to prevent timing attacks.
///
/// Returns `true` if the strings are equal, `false` otherwise.
/// The comparison time is constant regardless of how many characters match.
pub fn constant_time_eq(a: &str, b: &str) -> bool {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();

// Length check is unavoidable, but we still do a comparison
// to maintain constant time behavior
let len_match = a_bytes.len() == b_bytes.len();

if len_match {
a_bytes.ct_eq(b_bytes).into()
} else {
// Do a dummy comparison to maintain constant time
let dummy = vec![0u8; b_bytes.len()];
let _ = dummy.as_slice().ct_eq(b_bytes);
false
}
}

/// Validates a token hash against a stored hash using constant-time comparison.
///
/// Returns `true` if the hashes match, `false` otherwise.
pub fn validate_token_hash(provided_hash: &str, stored_hash: &str) -> bool {
constant_time_eq(provided_hash, stored_hash)
}

/// Extract server address for dashboard ping feature.
/// Priority:
/// 1. X-Server-Address header (explicit config from plugin)
/// 2. X-Forwarded-For header (if behind a proxy)
/// 3. X-Real-IP header (common proxy header)
pub fn extract_server_address(headers: &HeaderMap) -> Option<String> {
// 1. Explicit header from plugin config takes priority
if let Some(addr) = headers
.get("x-server-address")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
tracing::debug!(address = %addr, "using explicit X-Server-Address");
return Some(addr);
}

// 2. X-Forwarded-For (first IP in the chain, closest to client)
if let Some(forwarded) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
// X-Forwarded-For can be comma-separated: "client, proxy1, proxy2"
if let Some(first_ip) = forwarded.split(',').next().map(|s| s.trim()) {
if !first_ip.is_empty() && !is_local_ip(first_ip) {
tracing::debug!(ip = %first_ip, "using X-Forwarded-For for server address");
// Add default MC port
return Some(format!("{}:25565", first_ip));
}
}
}

// 3. X-Real-IP (single IP header, common with nginx)
if let Some(real_ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim())
.filter(|s| !s.is_empty() && !is_local_ip(s))
{
tracing::debug!(ip = %real_ip, "using X-Real-IP for server address");
return Some(format!("{}:25565", real_ip));
}

None
}

/// Check if an IP string represents a local/loopback address.
fn is_local_ip(ip: &str) -> bool {
ip == "127.0.0.1"
|| ip == "::1"
|| ip == "localhost"
|| ip.starts_with("10.")
|| ip.starts_with("192.168.")
|| ip.starts_with("172.16.")
|| ip.starts_with("172.17.")
|| ip.starts_with("172.18.")
|| ip.starts_with("172.19.")
|| ip.starts_with("172.2")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public IPs incorrectly classified as private addresses

The is_local_ip function uses ip.starts_with("172.2") which incorrectly matches public IP addresses in the 172.2.x.x range. The RFC 1918 private range is 172.16.0.0/12 (172.16.0.0 - 172.31.255.255), so 172.2.x.x addresses are public. The pattern was intended to match 172.20.x.x through 172.29.x.x but the missing dot means it also catches 172.2.x.x. Servers with public IPs like 172.2.1.50 would have their address incorrectly filtered out, preventing the dashboard ping feature from working.

Fix in Cursor Fix in Web

|| ip.starts_with("172.30.")
|| ip.starts_with("172.31.")
}
9 changes: 8 additions & 1 deletion api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub struct Config {
pub local_store_dir: String,
// CORS
pub cors_allow_origins: Vec<String>,
/// Explicitly opt-in to permissive CORS (for development only).
/// SECURITY: Must be explicitly set to true; defaults to false.
pub cors_permissive_dev: bool,
}

fn parse_bool_env(key: &str, default: bool) -> bool {
Expand Down Expand Up @@ -111,7 +114,7 @@ impl Config {
let local_store_dir =
env::var("LOCAL_STORE_DIR").unwrap_or_else(|_| "./data/object_store".to_string());

// Comma-separated list of allowed origins. Empty => permissive (dev) CORS.
// Comma-separated list of allowed origins.
let cors_allow_origins = env::var("CORS_ALLOW_ORIGINS")
.map(|v| {
v.split(',')
Expand All @@ -121,6 +124,9 @@ impl Config {
})
.unwrap_or_default();

// SECURITY: Permissive CORS must be explicitly enabled. Defaults to false.
let cors_permissive_dev = parse_bool_env("CORS_PERMISSIVE_DEV", false);

Self {
host,
port,
Expand All @@ -144,6 +150,7 @@ impl Config {
s3_secret_key,
local_store_dir,
cors_allow_origins,
cors_permissive_dev,
}
}
}
1 change: 1 addition & 0 deletions api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod auth;
pub mod builtin_modules;
pub mod config;
pub mod db;
Expand Down
22 changes: 20 additions & 2 deletions api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,28 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}

/// Build a CORS layer. If `CORS_ALLOW_ORIGINS` is empty, fall back to permissive (dev).
/// Build a CORS layer.
/// SECURITY: Permissive CORS is only allowed when CORS_PERMISSIVE_DEV=true is explicitly set.
/// This prevents accidental permissive CORS in production.
fn cors_layer(cfg: &Config) -> CorsLayer {
if cfg.cors_allow_origins.is_empty() {
return CorsLayer::permissive();
if cfg.cors_permissive_dev {
tracing::warn!(
"CORS_PERMISSIVE_DEV=true: using permissive CORS. DO NOT USE IN PRODUCTION!"
);
return CorsLayer::permissive();
} else {
// In production with no origins configured, use restrictive defaults
// This allows same-origin requests only
tracing::info!(
"CORS_ALLOW_ORIGINS is empty and CORS_PERMISSIVE_DEV is not set. \
Using restrictive CORS (same-origin only). Set CORS_ALLOW_ORIGINS \
or CORS_PERMISSIVE_DEV=true for cross-origin requests."
);
return CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([CONTENT_TYPE, AUTHORIZATION]);
}
}

let origins: Vec<HeaderValue> = cfg
Expand Down
24 changes: 23 additions & 1 deletion api/src/routes/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::collections::HashSet;
use subtle::ConstantTimeEq;
use uuid::Uuid;

use crate::{error::ApiError, webhooks, AppState};
Expand Down Expand Up @@ -40,7 +41,28 @@ fn require_callback_auth(state: &AppState, headers: &HeaderMap) -> Result<(), Ap
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected = format!("Bearer {}", state.module_callback_token);
if state.module_callback_token.is_empty() || auth != expected {

// Use constant-time comparison to prevent timing attacks
if state.module_callback_token.is_empty() {
return Err(ApiError::Unauthorized);
}

let auth_bytes = auth.as_bytes();
let expected_bytes = expected.as_bytes();

// Constant-time comparison: both length check and content check
// are done in a way that doesn't leak timing information
let len_match = auth_bytes.len() == expected_bytes.len();
let content_match = if len_match {
auth_bytes.ct_eq(expected_bytes).into()
} else {
// Still do a comparison to maintain constant time even on length mismatch
let dummy = vec![0u8; expected_bytes.len()];
let _ = dummy.as_slice().ct_eq(expected_bytes);
false
};

if !content_match {
return Err(ApiError::Unauthorized);
}
Ok(())
Expand Down
63 changes: 27 additions & 36 deletions api/src/routes/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use axum::{
};
use serde::Serialize;

use crate::{error::ApiError, AppState};
use crate::{auth, error::ApiError, AppState};

#[derive(Debug, Serialize)]
pub struct HandshakeResponse {
Expand All @@ -15,40 +15,17 @@ pub struct HandshakeResponse {
pub server_id: String,
}

fn parse_bearer_token(headers: &HeaderMap) -> Option<String> {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.trim();
let prefix = "bearer ";
if auth.len() <= prefix.len() {
return None;
}
if !auth[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
Some(auth[prefix.len()..].trim().to_string())
}

fn sha256_hex(input: &str) -> String {
use sha2::Digest;
let mut h = sha2::Sha256::new();
h.update(input.as_bytes());
let out = h.finalize();
hex::encode(out)
}

/// POST /handshake
///
/// Lightweight "hello" endpoint used by the plugin on startup.
/// - Stores the server_id + token hash the first time we see a server.
/// - Returns `waiting_for_registration` until the server is linked to an account (owner_user_id set).
/// - Optionally stores server address for dashboard ping feature (auto-detected or from X-Server-Address header).
pub async fn handshake(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<(StatusCode, Json<HandshakeResponse>), ApiError> {
let token = parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?;
let token = auth::parse_bearer_token(&headers).ok_or(ApiError::Unauthorized)?;

let server_id = headers
.get("x-server-id")
Expand All @@ -66,7 +43,10 @@ pub async fn handshake(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());

let token_hash = sha256_hex(&token);
// Extract server address for ping feature (explicit header > forwarded-for > real-ip)
let server_address = auth::extract_server_address(&headers);

let token_hash = auth::sha256_hex(&token);

// Load or create server row.
let row: Option<(
Expand Down Expand Up @@ -94,17 +74,19 @@ pub async fn handshake(
sqlx::query(
r#"
insert into public.servers
(id, platform, first_seen_at, last_seen_at, auth_token_hash, auth_token_first_seen_at)
(id, platform, first_seen_at, last_seen_at, auth_token_hash, auth_token_first_seen_at, callback_url)
values
($1, $2, now(), now(), $3, now())
($1, $2, now(), now(), $3, now(), $4)
on conflict (id) do update set
platform = coalesce(excluded.platform, servers.platform),
last_seen_at = now()
last_seen_at = now(),
callback_url = coalesce(excluded.callback_url, servers.callback_url)
"#,
)
.bind(&server_id)
.bind(platform.as_deref())
.bind(&token_hash)
.bind(server_address.as_deref())
.execute(&state.db)
.await
.map_err(|e| {
Expand All @@ -124,17 +106,26 @@ pub async fn handshake(
Some((stored_hash_opt, owner_user_id, registered_at)) => {
// Validate token FIRST before updating any state.
// This prevents attackers from spoofing last_seen_at with invalid tokens.
// Uses constant-time comparison to prevent timing attacks.
if let Some(stored_hash) = &stored_hash_opt {
if stored_hash != &token_hash {
if !auth::validate_token_hash(&token_hash, stored_hash) {
return Err(ApiError::Unauthorized);
}
}

// Token is valid (or no token stored yet) - now bump last_seen_at.
let _ = sqlx::query("update public.servers set last_seen_at = now() where id = $1")
.bind(&server_id)
.execute(&state.db)
.await;
// Token is valid (or no token stored yet) - now bump last_seen_at and callback_url.
let _ = sqlx::query(
r#"
update public.servers
set last_seen_at = now(),
callback_url = coalesce($2, callback_url)
where id = $1
"#,
)
.bind(&server_id)
.bind(server_address.as_deref())
.execute(&state.db)
.await;

// If no token was stored, save this one.
if stored_hash_opt.is_none() {
Expand Down
Loading