diff --git a/README.md b/README.md index 3c6c74b71..28b8c64bf 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,9 @@ and customizable experience. It prioritizes performance and player enjoyment whi - [x] Permissions - [x] Translations - Proxy - - [x] Bungeecord - - [x] Velocity + - [x] [BungeeCord](https://github.com/SpigotMC/BungeeCord) + - [x] [BungeeGuard](https://github.com/lucko/BungeeGuard) + - [x] [Velocity](https://github.com/PaperMC/Velocity) diff --git a/crates/pumpkin-config/src/networking/proxy.rs b/crates/pumpkin-config/src/networking/proxy.rs index d17af35f5..2c2a330b0 100644 --- a/crates/pumpkin-config/src/networking/proxy.rs +++ b/crates/pumpkin-config/src/networking/proxy.rs @@ -20,6 +20,12 @@ pub struct ProxyConfig { pub struct BungeeCordConfig { /// Whether `BungeeCord` support is enabled. pub enabled: bool, + /// Shared secret for authenticating connections from the `BungeeCord` + /// proxy, as provided by the `BungeeGuard` plugin. When set, the forwarded + /// profile properties must contain a `bungeeguard-token` property holding + /// this secret, otherwise the connection is rejected. This also blocks + /// players connecting directly instead of through the proxy. + pub secret: String, } /// Configuration for Velocity proxy integration. diff --git a/crates/pumpkin/src/net/java/login/login_start.rs b/crates/pumpkin/src/net/java/login/login_start.rs index 8da7c1e26..0978500c5 100644 --- a/crates/pumpkin/src/net/java/login/login_start.rs +++ b/crates/pumpkin/src/net/java/login/login_start.rs @@ -44,6 +44,7 @@ impl PendingConnection { &self.address, &self.server_address, login_start.name.into_string(), + &proxy.bungeecord.secret, ) { Ok((_ip, profile)) => { self.gameprofile = Some(profile.clone()); diff --git a/crates/pumpkin/src/net/proxy/bungeecord.rs b/crates/pumpkin/src/net/proxy/bungeecord.rs index c68e1d371..c2cf4fe49 100644 --- a/crates/pumpkin/src/net/proxy/bungeecord.rs +++ b/crates/pumpkin/src/net/proxy/bungeecord.rs @@ -1,9 +1,16 @@ use arc_swap::ArcSwap; +use sha2::{Digest, Sha256}; use std::sync::Arc; use std::{net::IpAddr, net::SocketAddr}; use thiserror::Error; +use tracing::warn; use crate::net::{GameProfile, offline_uuid}; +use pumpkin_protocol::Property; + +/// The property name the `BungeeGuard` plugin uses to forward its shared +/// secret inside the profile properties. +const BUNGEEGUARD_TOKEN_PROPERTY: &str = "bungeeguard-token"; #[derive(Error, Debug)] pub enum BungeeCordError { @@ -15,6 +22,10 @@ pub enum BungeeCordError { FailedParseProperties, #[error("Failed to make offline UUID")] FailedMakeOfflineUUID, + #[error("No BungeeGuard token in forwarded data")] + MissingToken, + #[error("Invalid BungeeGuard token")] + InvalidToken, } /// Attempts to login a player via `BungeeCord`. @@ -27,12 +38,19 @@ pub enum BungeeCordError { /// 2. UUID (if `ip_forward` is enabled on the `BungeeCord` server) /// 3. Game profile properties (if `ip_forward` and `online_mode` are enabled on the `BungeeCord` server) /// +/// If a `secret` is configured, the properties must contain a property named +/// `bungeeguard-token` holding the secret, as injected by the `BungeeGuard` +/// plugin. The token property is stripped from the profile, and a missing or +/// mismatched token rejects the connection. This also blocks players +/// connecting directly to this server, bypassing the proxy. +/// /// If any of the optional data is missing, the function will attempt to /// determine the player's information locally. pub fn bungeecord_login( client_address: &SocketAddr, server_address: &str, name: String, + secret: &str, ) -> Result<(IpAddr, GameProfile), BungeeCordError> { let mut parts = server_address.split('\0'); @@ -53,13 +71,62 @@ pub fn bungeecord_login( _ => offline_uuid(&name).map_err(|_| BungeeCordError::FailedMakeOfflineUUID)?, }; - let properties = match parts.next() { + let mut properties: Vec = match parts.next() { Some(json_str) if !json_str.is_empty() => { serde_json::from_str(json_str).map_err(|_| BungeeCordError::FailedParseProperties)? } _ => Vec::new(), }; + // The `BungeeGuard` plugin injects the shared secret as a property named + // `bungeeguard-token` inside the forwarded profile properties. When a + // secret is configured, that property must be present and hold the + // secret; the property is then stripped so it never reaches the game + // profile. This also blocks players connecting directly instead of + // through the proxy. + if !secret.is_empty() { + let token_props: Vec<&Property> = properties + .iter() + .filter(|property| property.name.as_ref() == BUNGEEGUARD_TOKEN_PROPERTY) + .collect(); + + match token_props.as_slice() { + [token] if token.value.as_ref() == secret => { + properties.retain(|property| property.name.as_ref() != BUNGEEGUARD_TOKEN_PROPERTY); + } + [] => { + warn!( + "Rejecting login: forwarded data has no `{}` property \ + ({} parts, property names: {:?})", + BUNGEEGUARD_TOKEN_PROPERTY, + server_address.split('\0').count(), + properties + .iter() + .map(|p| p.name.as_ref()) + .collect::>() + ); + return Err(BungeeCordError::MissingToken); + } + _ => { + // Log only SHA-256 hashes: one-way, so the secret never leaks, + // but enough to tell a mismatch from duplicated tokens apart. + let token_hashes: Vec = token_props + .iter() + .map(|property| hex::encode(Sha256::digest(property.value.as_bytes()))) + .collect(); + warn!( + "Rejecting login: expected exactly one matching `{}` property, \ + found {} (token hashes: {token_hashes:?}, configured secret \ + hash: {})", + BUNGEEGUARD_TOKEN_PROPERTY, + token_props.len(), + hex::encode(Sha256::digest(secret.as_bytes())) + ); + return Err(BungeeCordError::InvalidToken); + } + } + } + Ok(( ip, GameProfile { @@ -110,6 +177,7 @@ mod tests { &client_address, &handshake.server_address, "Steve".to_string(), + "", ) .expect("the forwarded address should produce a game profile"); @@ -129,4 +197,105 @@ mod tests { assert_eq!(&*properties[0].value, textures.as_str()); assert_eq!(properties[0].signature.as_deref(), Some(signature.as_str())); } + + const SECRET: &str = "bungeeguard-token"; + const FORWARDED_HOST: &str = concat!( + // Split at the digits so the `\0` is not read as an octal escape. + "mc.example.com\0", + "192.0.2.10\0", + "d8f4a1e0-0f1b-4c3a-9f2e-1a2b3c4d5e6f" + ); + + fn client_address() -> SocketAddr { + SocketAddr::from(([10, 0, 0, 1], 51234)) + } + + /// The forwarded address with the given profile `properties` as its + /// fourth part, as `BungeeCord` puts them on the wire. + fn forwarded_address(properties: &str) -> String { + format!("{FORWARDED_HOST}\0{properties}") + } + + /// The `BungeeGuard` token property alone, as the plugin injects it into + /// the forwarded profile properties. + fn token_property(token: &str) -> String { + format!(r#"{{"name":"bungeeguard-token","value":"{token}","signature":""}}"#) + } + + /// A properties array containing the given property objects. + fn properties_array(properties: &[&str]) -> String { + format!("[{}]", properties.join(",")) + } + + #[test] + fn accepts_matching_bungeeguard_token() { + let properties = format!( + r#"[{{"name":"textures","value":"skin","signature":"sig"}},{{"name":"bungeeguard-token","value":"{SECRET}","signature":""}}]"# + ); + let address = forwarded_address(&properties); + + let (ip, profile) = + bungeecord_login(&client_address(), &address, "Steve".to_string(), SECRET) + .expect("a matching token should be accepted"); + + assert_eq!(ip, IpAddr::from([192, 0, 2, 10])); + + // The token property is stripped so it never reaches the game profile. + let properties = profile.properties.load(); + assert_eq!(properties.len(), 1); + assert_eq!(&*properties[0].name, "textures"); + } + + #[test] + fn rejects_missing_bungeeguard_token() { + let address = + forwarded_address(r#"[{"name":"textures","value":"skin","signature":"sig"}]"#); + + let result = bungeecord_login(&client_address(), &address, "Steve".to_string(), SECRET); + + assert!(matches!(result, Err(BungeeCordError::MissingToken))); + } + + #[test] + fn rejects_mismatched_bungeeguard_token() { + let address = forwarded_address(&properties_array(&[&token_property("wrong-token")])); + + let result = bungeecord_login(&client_address(), &address, "Steve".to_string(), SECRET); + + assert!(matches!(result, Err(BungeeCordError::InvalidToken))); + } + + #[test] + fn rejects_multiple_bungeeguard_tokens() { + let properties = properties_array(&[&token_property(SECRET), &token_property(SECRET)]); + let address = forwarded_address(&properties); + + let result = bungeecord_login(&client_address(), &address, "Steve".to_string(), SECRET); + + assert!(matches!(result, Err(BungeeCordError::InvalidToken))); + } + + #[test] + fn rejects_direct_connection_when_secret_is_configured() { + let result = bungeecord_login( + &client_address(), + "mc.example.com", + "Steve".to_string(), + SECRET, + ); + + assert!(matches!(result, Err(BungeeCordError::MissingToken))); + } + + #[test] + fn ignores_token_when_no_secret_is_configured() { + let address = forwarded_address(&properties_array(&[&token_property(SECRET)])); + + let result = bungeecord_login(&client_address(), &address, "Steve".to_string(), ""); + + assert!( + result.is_ok(), + "an unconfigured secret must not reject logins" + ); + } }