Allow restrictions based on Authorization header (#428)

* Allow restrictions based on Authorization header

Currently, server has only 2 types of restriction matcher: PathPrefix
and Any.

Lets augment the RestrictionConfig to also allow an Authorization Header
matcher; if such matcher is present, the Auth header in the websocket
upgrade request must match the regex set in the matcher.

This provides additional security benefit than using the PathPrefix
matcher in setups where wstunnel server sits behind a load-balancer or
a reverse proxy, where the request's path is logged by such systems.

* server/utils tests: Add test_validate_tunnel_with_auth

Tests MatchConfig::Authorization based restrictions.
This commit is contained in:
Shmulik Ladkani
2025-05-07 18:41:22 +03:00
committed by GitHub
parent a4b99b3fa6
commit e1205b72b8
4 changed files with 67 additions and 14 deletions

View File

@@ -10,6 +10,9 @@ restrictions:
# The regex does a match, so if you want to match exactly you need to bound the pattern with ^ $
# I.e: "tesotron" is going to match "XXXtesotronXXX", but "^tesotron$" is going to match only "tesotron"
- !PathPrefix "^.*$"
# This match applies only if it succeeds to match the Authentication Header with the given regex.
# If present, Authentication Header must exists and must match the regex.
# - !Authorization "^[Bb]earer +actual_bearer_token_to_match$"
# The only other possible match type for now is !Any, that match everything/any request
# - !Any

View File

@@ -23,6 +23,8 @@ pub enum MatchConfig {
Any,
#[serde(with = "serde_regex")]
PathPrefix(Regex),
#[serde(with = "serde_regex")]
Authorization(Regex),
}
#[derive(Debug, Clone, Deserialize)]

View File

@@ -31,8 +31,8 @@ use crate::tunnel::server::handler_http2::http_server_upgrade;
use crate::tunnel::server::handler_websocket::ws_server_upgrade;
use crate::tunnel::server::reverse_tunnel::ReverseTunnelServer;
use crate::tunnel::server::utils::{
HttpResponse, bad_request, extract_path_prefix, extract_tunnel_info, extract_x_forwarded_for, find_mapped_port,
validate_tunnel,
HttpResponse, bad_request, extract_authorization, extract_path_prefix, extract_tunnel_info,
extract_x_forwarded_for, find_mapped_port, validate_tunnel,
};
use crate::tunnel::tls_reloader::TlsReloader;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
@@ -121,7 +121,9 @@ impl WsServer {
bad_request()
})?;
let restriction = validate_tunnel(&remote, path_prefix, &restrictions).ok_or_else(|| {
let authorization = extract_authorization(req);
let restriction = validate_tunnel(&remote, path_prefix, authorization, &restrictions).ok_or_else(|| {
warn!("Rejecting connection with not allowed destination: {remote:?}");
bad_request()
})?;

View File

@@ -49,6 +49,12 @@ pub(super) fn find_mapped_port(req_port: u16, restriction: &RestrictionConfig) -
remote_port
}
#[inline]
pub(super) fn extract_authorization(req: &Request<Incoming>) -> Option<&str> {
let val = req.headers().get("Authorization")?;
val.to_str().ok()
}
#[inline]
pub(super) fn extract_x_forwarded_for(req: &Request<Incoming>) -> Option<(IpAddr, &str)> {
let x_forward_for = req.headers().get("X-Forwarded-For")?;
@@ -104,12 +110,13 @@ pub(super) fn extract_tunnel_info(req: &Request<Incoming>) -> anyhow::Result<Tok
}
impl RestrictionConfig {
/// Returns true if the path prefix matches the restriction or if the restriction is set to allow any path.
/// Returns true if the parameters match the restriction config
#[inline]
fn for_path(self: &RestrictionConfig, path_prefix: &str) -> bool {
fn filter(self: &RestrictionConfig, path_prefix: &str, authorization: Option<&str>) -> bool {
self.r#match.iter().all(|m| match m {
MatchConfig::Any => true,
MatchConfig::PathPrefix(path) => path.is_match(path_prefix),
MatchConfig::Authorization(auth) => authorization.is_some_and(|val| auth.is_match(val)),
})
}
}
@@ -186,12 +193,13 @@ impl AllowConfig {
pub(super) fn validate_tunnel<'a>(
remote: &RemoteAddr,
path_prefix: &str,
authorization: Option<&str>,
restrictions: &'a RestrictionsRules,
) -> Option<&'a RestrictionConfig> {
restrictions
.restrictions
.iter()
.filter(|restriction| restriction.for_path(path_prefix))
.filter(|restriction| restriction.filter(path_prefix, authorization))
.find(|restriction| restriction.allow.iter().any(|allow| allow.is_allowed(remote)))
}
@@ -208,7 +216,7 @@ pub(super) fn inject_cookie(response: &mut http::Response<impl Body>, remote_add
#[cfg(test)]
mod tests {
use super::*;
use crate::restrictions::types::{AllowReverseTunnelConfig, AllowTunnelConfig};
use crate::restrictions::types::{AllowReverseTunnelConfig, AllowTunnelConfig, default_cidr, default_host};
use crate::tunnel::LocalProtocol;
use ipnet::{IpNet, Ipv4Net};
use regex::Regex;
@@ -249,7 +257,9 @@ mod tests {
port: 80,
};
assert_eq!(
validate_tunnel(&remote, "/doesnt/matter", &restrictions).unwrap().name,
validate_tunnel(&remote, "/doesnt/matter", None, &restrictions)
.unwrap()
.name,
restrictions.restrictions[0].name
);
@@ -259,7 +269,9 @@ mod tests {
port: 80,
};
assert_eq!(
validate_tunnel(&remote, "/doesnt/matter", &restrictions).unwrap().name,
validate_tunnel(&remote, "/doesnt/matter", None, &restrictions)
.unwrap()
.name,
restrictions.restrictions[1].name
);
@@ -268,14 +280,14 @@ mod tests {
host: Host::Ipv4([127, 0, 0, 1].into()),
port: 81,
};
assert!(validate_tunnel(&remote, "/doesnt/matter", &restrictions).is_none());
assert!(validate_tunnel(&remote, "/doesnt/matter", None, &restrictions).is_none());
let remote = RemoteAddr {
protocol: LocalProtocol::Tcp { proxy_protocol: false },
host: Host::Ipv4([127, 0, 1, 1].into()),
port: 80,
};
assert!(validate_tunnel(&remote, "/doesnt/matter", &restrictions).is_none());
assert!(validate_tunnel(&remote, "/doesnt/matter", None, &restrictions).is_none());
let remote = RemoteAddr {
protocol: LocalProtocol::Tcp { proxy_protocol: false },
@@ -283,7 +295,9 @@ mod tests {
port: 80,
};
assert_eq!(
validate_tunnel(&remote, "/doesnt/matter", &restrictions).unwrap().name,
validate_tunnel(&remote, "/doesnt/matter", None, &restrictions)
.unwrap()
.name,
restrictions.restrictions[0].name
);
@@ -292,14 +306,46 @@ mod tests {
host: Host::Domain("not.com".into()),
port: 80,
};
assert!(validate_tunnel(&remote, "/doesnt/matter", &restrictions).is_none());
assert!(validate_tunnel(&remote, "/doesnt/matter", None, &restrictions).is_none());
let remote = RemoteAddr {
protocol: LocalProtocol::Tcp { proxy_protocol: false },
host: Host::Ipv6(Ipv6Addr::LOCALHOST),
port: 80,
};
assert!(validate_tunnel(&remote, "/doesnt/matter", &restrictions).is_none());
assert!(validate_tunnel(&remote, "/doesnt/matter", None, &restrictions).is_none());
}
#[test]
fn test_validate_tunnel_with_auth() {
let restrictions = RestrictionsRules {
restrictions: vec![RestrictionConfig {
name: "restrict1".into(),
r#match: vec![MatchConfig::Authorization(
Regex::new("^[Bb]earer +the-bearer-token$").unwrap(),
)],
allow: vec![AllowConfig::Tunnel(AllowTunnelConfig {
protocol: vec![],
port: vec![],
cidr: default_cidr(),
host: default_host(),
})],
}],
};
let remote = RemoteAddr {
protocol: LocalProtocol::Tcp { proxy_protocol: false },
host: Host::Ipv4([127, 0, 0, 1].into()),
port: 80,
};
assert_eq!(
validate_tunnel(&remote, "/doesnt/matter", Some("Bearer the-bearer-token"), &restrictions)
.unwrap()
.name,
restrictions.restrictions[0].name
);
assert!(validate_tunnel(&remote, "/doesnt/matter", Some("Bearer other-bearer-token"), &restrictions).is_none());
assert!(validate_tunnel(&remote, "/doesnt/matter", None, &restrictions).is_none());
}
#[test]