mirror of
https://github.com/Pumpkin-MC/Pumpkin.git
synced 2026-08-30 20:14:23 +00:00
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:
@@ -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. -->
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user