Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dist/
/output/
/config/
*.DotSettings
*.csproj.lscache

# Downloaded build dependencies
tun2socks.exe
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions devolutions-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32
embed-resource = "3.0"

[dev-dependencies]
base64 = "0.22"
tokio-test = "0.4"
proptest = "1.7"
tempfile = "3"
Expand Down
223 changes: 149 additions & 74 deletions devolutions-gateway/src/api/kdc_proxy.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
use std::io;
use std::net::SocketAddr;

use axum::Router;
use axum::extract::{self, ConnectInfo, State};
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::post;
use kdc::handle_kdc_proxy_message;
use picky_krb::messages::KdcProxyMessage;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};

use crate::DgwState;
use crate::credential_injection_kdc::{
CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, CredentialInjectionKdcResolveError,
kdc_proxy_message_realm,
};
use crate::extract::KdcToken;
use crate::http::{HttpError, HttpErrorBuilder};
use crate::target_addr::TargetAddr;
use crate::token::{AccessTokenClaims, KdcDestination};
use crate::token::{KdcDestination, KdcTokenClaims};

pub fn make_router<S>(state: DgwState) -> Router<S> {
Router::new().route("/{token}", post(kdc_proxy)).with_state(state)
Expand All @@ -22,106 +25,144 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
async fn kdc_proxy(
State(DgwState {
conf_handle,
token_cache,
jrl,
recordings,
credentials,
..
}): State<DgwState>,
extract::Path(token): extract::Path<String>,
ConnectInfo(source_addr): ConnectInfo<SocketAddr>,
KdcToken(KdcTokenClaims { destination }): KdcToken,
body: axum::body::Bytes,
) -> Result<Vec<u8>, HttpError> {
let conf = conf_handle.get_conf();

let claims = crate::middleware::auth::authenticate(
source_addr,
&token,
&conf,
&token_cache,
&jrl,
&recordings.active_recordings,
None,
)
.map_err(HttpError::unauthorized().err())?;

let AccessTokenClaims::Kdc(claims) = claims else {
return Err(HttpError::forbidden().msg("token not allowed (expected KDC token)"));
};

let kdc_proxy_message = KdcProxyMessage::from_raw(&body).map_err(HttpError::bad_request().err())?;

trace!(?kdc_proxy_message, "Received KDC message");

debug!(
?kdc_proxy_message.target_domain,
?kdc_proxy_message.dclocator_hint,
"KDC message",
);

let realm = if let Some(realm) = &kdc_proxy_message.target_domain.0 {
realm.0.to_string()
} else {
return Err(HttpError::bad_request().msg("realm is missing from KDC request"));
};

debug!("Request is for realm (target_domain): {realm}");
match destination {
KdcDestination::Inject { jti } => {
enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?;

let (claims_realm, claims_kdc) = match &claims.destination {
KdcDestination::Real { krb_realm, krb_kdc } => (krb_realm, krb_kdc),
KdcDestination::Inject { .. } => {
// TODO(DGW-378): dispatch credential-injection KDC requests to the in-process
// sspi-rs server backed by the credentials provisioned at session establishment.
return Err(HttpError::internal().msg("credential injection KDC dispatch is not implemented yet"));
}
};
let kdc = credentials.kdc_for(jti).map_err(credential_injection_resolve_error)?;

if !claims_realm.eq_ignore_ascii_case(&realm) {
if conf.debug.disable_token_validation {
warn!(
token_realm = %claims_realm,
request_realm = %realm,
"**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token"
debug!(
jti = %kdc.jti(),
"Proxy-based credential injection with Kerberos. Processing KdcProxy message internally"
);
} else {
let error_message = format!("expected: {}, got: {}", claims_realm, realm);

return Err(HttpError::bad_request()
.with_msg("requested domain is not allowed")
.err()(error_message));
match kdc
.handle_kdc_proxy_request(CredentialInjectionKdcRequest::from_token(kdc_proxy_message))
.map_err(HttpError::internal().err())?
{
CredentialInjectionKdcInterception::Intercepted(reply) => Ok(reply),
CredentialInjectionKdcInterception::NotInjectionRealm(mismatch) => {
Err(HttpError::bad_request()
.with_msg("requested domain is not allowed")
.err()(mismatch))
}
CredentialInjectionKdcInterception::NotInjectionRequest => {
Err(HttpError::internal().msg("credential-injection KDC did not handle the KDC proxy request"))
}
}
}
KdcDestination::Real { krb_realm, krb_kdc } => {
let envelope_realm = kdc_proxy_message_realm(&kdc_proxy_message);
forward_to_real_kdc(
kdc_proxy_message,
envelope_realm,
&krb_realm,
&krb_kdc,
conf.debug.override_kdc.as_ref(),
conf.debug.disable_token_validation,
)
.await
}
}
}

let gateway_id = conf
.id
.ok_or_else(|| HttpError::internal().build("Gateway ID is missing"))?;
if let Some(krb_config) = &conf.debug.kerberos
&& realm.eq_ignore_ascii_case(&krb_config.kerberos_server.realm(gateway_id))
&& conf.debug.enable_unstable
{
debug!("Proxy-based credential injection with Kerberos. Processing KdcProxy message internally...");

let config = krb_config.kerberos_server.clone().into_kdc_kerberos_config(gateway_id);
let kdc_reply_message = handle_kdc_proxy_message(kdc_proxy_message, &config, &conf.hostname)
.map_err(HttpError::internal().err())?;

return kdc_reply_message.to_vec().map_err(HttpError::internal().err());
fn credential_injection_resolve_error(error: CredentialInjectionKdcResolveError) -> HttpError {
match error {
CredentialInjectionKdcResolveError::BuildKdcConfig { .. } => HttpError::internal()
.with_msg("credential-injection KDC could not be initialized")
.build(error),
_ => HttpError::bad_request()
.with_msg("credential-injection state is not available")
.build(error),
}
}

let kdc_addr = if let Some(kdc_addr) = &conf.debug.override_kdc {
warn!("**DEBUG OPTION** KDC address has been overridden with {kdc_addr}");
kdc_addr
} else {
claims_kdc
// Forwards the request to the real KDC indicated by the token (or by the debug override) and
// returns the response wrapped as a `KdcProxyMessage`.
//
// The forward path requires the envelope realm to be set: there is no fallback since this is
// not a credential-injection session. After resolving, validates the realm against the
// token's `krb_realm` claim before forwarding anything.
async fn forward_to_real_kdc(
kdc_proxy_message: KdcProxyMessage,
envelope_realm: Option<String>,
token_realm: &str,
token_kdc_addr: &TargetAddr,
override_kdc: Option<&TargetAddr>,
bypass_realm_check: bool,
) -> Result<Vec<u8>, HttpError> {
let realm = envelope_realm.ok_or_else(|| HttpError::bad_request().msg("realm is missing from KDC request"))?;
debug!(resolved_realm = %realm, "Forward-to-real-KDC realm resolved");
enforce_realm_token_match(token_realm, &realm, bypass_realm_check)?;

let kdc_addr = match override_kdc {
Some(override_addr) => {
warn!(%override_addr, "**DEBUG OPTION** KDC address has been overridden");
override_addr
}
None => token_kdc_addr,
};

let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;

let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message)
let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes)
.map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?;

trace!(?kdc_reply_message, "Sending back KDC reply");
trace!(?reply, "Sending back KDC reply");

reply.to_vec().map_err(HttpError::internal().err())
}

fn enforce_credential_injection_enabled(jet_cred_id: uuid::Uuid, enable_unstable: bool) -> Result<(), HttpError> {
if enable_unstable {
return Ok(());
}

warn!(
%jet_cred_id,
"Credential-injection KDC token rejected because unstable Kerberos injection is disabled"
);
Err(HttpError::bad_request().msg("credential-injection KDC proxy is not enabled"))
}

/// Refuses to forward a KDC request whose realm disagrees with the realm the token was issued for.
///
/// `bypass=true` (only when `__debug__.disable_token_validation` is on) downgrades the mismatch
/// to a warning. Production never opts into this.
fn enforce_realm_token_match(token_realm: &str, request_realm: &str, bypass: bool) -> Result<(), HttpError> {
if token_realm.eq_ignore_ascii_case(request_realm) {
return Ok(());
}

if bypass {
warn!(
%token_realm,
%request_realm,
"**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token"
);
return Ok(());
}

kdc_reply_message.to_vec().map_err(HttpError::internal().err())
Err(HttpError::bad_request()
.with_msg("requested domain is not allowed")
.err()(format!("expected: {token_realm}, got: {request_realm}")))
}

async fn read_kdc_reply_message(connection: &mut TcpStream) -> io::Result<Vec<u8>> {
Expand Down Expand Up @@ -221,3 +262,37 @@ pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result<V
Ok(reply_buf)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn enforce_realm_match_accepts_case_insensitive_match() {
assert!(enforce_realm_token_match("ad.example", "AD.EXAMPLE", false).is_ok());
}

#[test]
fn enforce_realm_mismatch_rejects_without_bypass() {
assert!(enforce_realm_token_match("ad.example", "evil.example", false).is_err());
}

#[test]
fn enforce_realm_mismatch_passes_under_bypass() {
// `bypass=true` is the `__debug__.disable_token_validation` downgrade. CBenoit asked
// for explicit coverage of this branch because it is the only place the realm
// authorization is intentionally weakened, and slipping the gate (e.g. by inverting the
// condition) would only surface in production.
assert!(enforce_realm_token_match("ad.example", "evil.example", true).is_ok());
}

#[test]
fn credential_injection_gate_allows_jet_cred_id_when_enabled() {
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), true).is_ok());
}

#[test]
fn credential_injection_gate_rejects_jet_cred_id_when_disabled() {
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), false).is_err());
}
}
Loading
Loading