Skip to content
Open
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 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
235 changes: 170 additions & 65 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::{
CredentialInjectionKdc, 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;
use crate::token::KdcTokenClaims;

pub fn make_router<S>(state: DgwState) -> Router<S> {
Router::new().route("/{token}", post(kdc_proxy)).with_state(state)
Expand All @@ -22,97 +25,160 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
async fn kdc_proxy(
State(DgwState {
conf_handle,
token_cache,
jrl,
recordings,
credential_store,
credential_injection_context_store,
..
}): State<DgwState>,
extract::Path(token): extract::Path<String>,
ConnectInfo(source_addr): ConnectInfo<SocketAddr>,
KdcToken(KdcTokenClaims {
krb_realm,
krb_kdc,
jet_cred_id,
}): 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}");
enforce_credential_injection_enabled(jet_cred_id, conf.debug.enable_unstable)?;

if !claims.krb_realm.eq_ignore_ascii_case(&realm) {
if conf.debug.disable_token_validation {
warn!(
token_realm = %claims.krb_realm,
request_realm = %realm,
"**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token"
match CredentialInjectionKdc::resolve(jet_cred_id, &credential_store, &credential_injection_context_store)
.map_err(credential_injection_resolve_error)?
{
Some(kdc) => {
debug!(
jti = %kdc.jti(),
"Proxy-based credential injection with Kerberos. Processing KdcProxy message internally"
);
} else {
let error_message = format!("expected: {}, got: {}", claims.krb_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,
&krb_realm,
conf.debug.disable_token_validation,
))
.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"))
}
}
}
None => {
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...");
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),
}
}

// 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_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;

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

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

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())?;
reply.to_vec().map_err(HttpError::internal().err())
}

return kdc_reply_message.to_vec().map_err(HttpError::internal().err());
fn enforce_credential_injection_enabled(
jet_cred_id: Option<uuid::Uuid>,
enable_unstable: bool,
) -> Result<(), HttpError> {
if enable_unstable {
return Ok(());
}

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.krb_kdc
let Some(jet_cred_id) = jet_cred_id else {
return Ok(());
};

let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
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"))
}

let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message)
.map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?;
/// 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(());
}

trace!(?kdc_reply_message, "Sending back KDC reply");
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 @@ -212,3 +278,42 @@ 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_plain_kdc_when_disabled() {
assert!(enforce_credential_injection_enabled(None, false).is_ok());
}

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

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