feat(bungeecord): add BungeeGuard auth support (#3038)

* feat(bungeeguard): add BungeeGuard token authentication

Add a BungeeGuard shared secret to the proxy configuration, validate the token during the BungeeCord login flow, reject connections with invalid tokens, and prevent players from bypassing the proxy to connect directly.

* fix

* fix

* fix:ci
This commit is contained in:
Missing_Love
2026-08-25 14:35:06 +08:00
committed by GitHub
parent 2b0a20d859
commit 85d635a42b
4 changed files with 180 additions and 3 deletions

View File

@@ -89,8 +89,9 @@ and customizable experience. It prioritizes performance and player enjoyment whi
- [x] Permissions - [x] Permissions
- [x] Translations - [x] Translations
- Proxy - Proxy
- [x] Bungeecord - [x] [BungeeCord](https://github.com/SpigotMC/BungeeCord)
- [x] Velocity - [x] [BungeeGuard](https://github.com/lucko/BungeeGuard)
- [x] [Velocity](https://github.com/PaperMC/Velocity)
<!-- Check out our [Github Project](https://github.com/orgs/Pumpkin-MC/projects/3) to see current progress. --> <!-- Check out our [Github Project](https://github.com/orgs/Pumpkin-MC/projects/3) to see current progress. -->

View File

@@ -20,6 +20,12 @@ pub struct ProxyConfig {
pub struct BungeeCordConfig { pub struct BungeeCordConfig {
/// Whether `BungeeCord` support is enabled. /// Whether `BungeeCord` support is enabled.
pub enabled: bool, 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. /// Configuration for Velocity proxy integration.

View File

@@ -44,6 +44,7 @@ impl PendingConnection {
&self.address, &self.address,
&self.server_address, &self.server_address,
login_start.name.into_string(), login_start.name.into_string(),
&proxy.bungeecord.secret,
) { ) {
Ok((_ip, profile)) => { Ok((_ip, profile)) => {
self.gameprofile = Some(profile.clone()); self.gameprofile = Some(profile.clone());

View File

@@ -1,9 +1,16 @@
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use sha2::{Digest, Sha256};
use std::sync::Arc; use std::sync::Arc;
use std::{net::IpAddr, net::SocketAddr}; use std::{net::IpAddr, net::SocketAddr};
use thiserror::Error; use thiserror::Error;
use tracing::warn;
use crate::net::{GameProfile, offline_uuid}; 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)] #[derive(Error, Debug)]
pub enum BungeeCordError { pub enum BungeeCordError {
@@ -15,6 +22,10 @@ pub enum BungeeCordError {
FailedParseProperties, FailedParseProperties,
#[error("Failed to make offline UUID")] #[error("Failed to make offline UUID")]
FailedMakeOfflineUUID, FailedMakeOfflineUUID,
#[error("No BungeeGuard token in forwarded data")]
MissingToken,
#[error("Invalid BungeeGuard token")]
InvalidToken,
} }
/// Attempts to login a player via `BungeeCord`. /// 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) /// 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) /// 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 /// If any of the optional data is missing, the function will attempt to
/// determine the player's information locally. /// determine the player's information locally.
pub fn bungeecord_login( pub fn bungeecord_login(
client_address: &SocketAddr, client_address: &SocketAddr,
server_address: &str, server_address: &str,
name: String, name: String,
secret: &str,
) -> Result<(IpAddr, GameProfile), BungeeCordError> { ) -> Result<(IpAddr, GameProfile), BungeeCordError> {
let mut parts = server_address.split('\0'); let mut parts = server_address.split('\0');
@@ -53,13 +71,62 @@ pub fn bungeecord_login(
_ => offline_uuid(&name).map_err(|_| BungeeCordError::FailedMakeOfflineUUID)?, _ => offline_uuid(&name).map_err(|_| BungeeCordError::FailedMakeOfflineUUID)?,
}; };
let properties = match parts.next() { let mut properties: Vec<Property> = match parts.next() {
Some(json_str) if !json_str.is_empty() => { Some(json_str) if !json_str.is_empty() => {
serde_json::from_str(json_str).map_err(|_| BungeeCordError::FailedParseProperties)? serde_json::from_str(json_str).map_err(|_| BungeeCordError::FailedParseProperties)?
} }
_ => Vec::new(), _ => 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::<Vec<_>>()
);
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<String> = 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(( Ok((
ip, ip,
GameProfile { GameProfile {
@@ -110,6 +177,7 @@ mod tests {
&client_address, &client_address,
&handshake.server_address, &handshake.server_address,
"Steve".to_string(), "Steve".to_string(),
"",
) )
.expect("the forwarded address should produce a game profile"); .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].value, textures.as_str());
assert_eq!(properties[0].signature.as_deref(), Some(signature.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"
);
}
} }