Skip to content

Commit 5abe183

Browse files
authored
fix(agent,agent-installer): avoid leaking tunnel enrollment JWT via process cmdline (#1798)
1 parent 0d1ea25 commit 5abe183

8 files changed

Lines changed: 298 additions & 4 deletions

File tree

devolutions-agent/src/main.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ extern crate tracing;
3636
mod service;
3737

3838
use std::env;
39+
use std::io::{self, BufRead};
3940
use std::sync::mpsc;
4041

4142
use anyhow::{Context as _, Result, bail};
@@ -143,6 +144,10 @@ fn parse_advertise_subnets(value: &str) -> Vec<String> {
143144
}
144145

145146
fn parse_up_command_args(args: &[String]) -> Result<UpCommand> {
147+
parse_up_command_args_with_reader(args, io::stdin().lock())
148+
}
149+
150+
fn parse_up_command_args_with_reader<R: BufRead>(args: &[String], mut stdin_reader: R) -> Result<UpCommand> {
146151
let mut gateway_url = None;
147152
let mut enrollment_token = None;
148153
let mut agent_name = None;
@@ -168,6 +173,21 @@ fn parse_up_command_args(args: &[String]) -> Result<UpCommand> {
168173
}
169174

170175
if let Some(enrollment_string) = enrollment_string {
176+
// A single hyphen means "read the enrollment string from stdin".
177+
let enrollment_string = if enrollment_string == "-" {
178+
let mut line = String::new();
179+
stdin_reader
180+
.read_line(&mut line)
181+
.context("failed to read enrollment string from stdin")?;
182+
let trimmed = line.trim().to_owned();
183+
if trimmed.is_empty() {
184+
bail!("enrollment string read from stdin is empty");
185+
}
186+
trimmed
187+
} else {
188+
enrollment_string
189+
};
190+
171191
let claims = parse_enrollment_jwt(&enrollment_string)?;
172192

173193
// The JWT itself is the Bearer token; the Gateway verifies the signature.

devolutions-gateway/src/api/tunnel.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,43 @@ fn validate_enrollment_jwt(token: &str, provisioner_key: &picky::key::PublicKey)
4444
)
4545
}
4646

47+
#[deprecated = "make sure this is never used without a deliberate action"]
48+
mod unsafe_debug {
49+
// Any function in this module should only be used at development stage when deliberately
50+
// enabling debugging options.
51+
52+
use picky::jose::jws::RawJws;
53+
use picky::jose::jwt::{self, JwtSig};
54+
55+
use crate::token::{AccessScope, EnrollmentTokenClaims};
56+
57+
/// Dangerous enrollment token validation procedure.
58+
///
59+
/// Like [`validate_enrollment_jwt`], but skips signature and `exp`/`nbf` checks.
60+
///
61+
/// Skips signature verification and `exp`/`nbf` checks. Only the scope
62+
/// (`AgentEnroll` or `Wildcard`) is still enforced, so test tokens still
63+
/// have to carry the right intent.
64+
pub(super) fn dangerous_validate_enrollment_jwt(token: &str) -> bool {
65+
warn!(
66+
"**DEBUG OPTION** Using dangerous enrollment token validation for testing purposes. Make sure this is not happening in production!"
67+
);
68+
69+
let Ok(jwt) = RawJws::decode(token).map(RawJws::discard_signature).map(JwtSig::from) else {
70+
return false;
71+
};
72+
73+
let Ok(validated) = jwt.validate::<EnrollmentTokenClaims>(&jwt::NO_CHECK_VALIDATOR) else {
74+
return false;
75+
};
76+
77+
matches!(
78+
validated.state.claims.scope,
79+
AccessScope::AgentEnroll | AccessScope::Wildcard
80+
)
81+
}
82+
}
83+
4784
#[derive(Deserialize)]
4885
pub struct EnrollRequest {
4986
/// Agent-generated UUID (the agent owns its identity).
@@ -122,7 +159,14 @@ async fn enroll_agent(
122159
.as_ref()
123160
.ok_or_else(|| HttpError::not_found().msg("agent enrollment is not configured"))?;
124161

125-
if !validate_enrollment_jwt(provided_token, &conf.provisioner_public_key) {
162+
let token_is_valid = if conf.debug.disable_token_validation {
163+
#[allow(deprecated, reason = "properly gated by disable_token_validation debug option")]
164+
unsafe_debug::dangerous_validate_enrollment_jwt(provided_token)
165+
} else {
166+
validate_enrollment_jwt(provided_token, &conf.provisioner_public_key)
167+
};
168+
169+
if !token_is_valid {
126170
return Err(HttpError::forbidden().msg("invalid enrollment token"));
127171
}
128172

package/AgentWindowsManaged/Actions/CustomActions.cs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,10 +366,13 @@ ActionResult Fail(string msg)
366366
// else (advertise subnets, advertise domains) is patched into agent.json *after*
367367
// enrollment, so we don't accumulate parallel CLI surfaces for what is ultimately
368368
// configuration data.
369-
string arguments = $"up --enrollment-string \"{enrollmentString}\"";
369+
//
370+
// The JWT is passed via stdin (sentinel `-`) to avoid exposing it in the process
371+
// command line (visible to any local process via WMI / Process Explorer / ETW).
372+
string arguments = "up --enrollment-string -";
370373
if (resolvedName.Length != 0)
371374
{
372-
arguments += $" --name \"{resolvedName}\"";
375+
arguments += $" --name {EscapeArg(resolvedName)}";
373376
}
374377

375378
string Redact(string s) => s.Replace(enrollmentString, "***");
@@ -380,11 +383,17 @@ ActionResult Fail(string msg)
380383
UseShellExecute = false,
381384
RedirectStandardOutput = true,
382385
RedirectStandardError = true,
386+
RedirectStandardInput = true,
383387
CreateNoWindow = true,
384388
WorkingDirectory = ProgramDataDirectory,
385389
};
386390

387391
using Process process = Process.Start(startInfo);
392+
393+
// Write the JWT to stdin and close it so the child sees EOF.
394+
process.StandardInput.Write(enrollmentString);
395+
process.StandardInput.Close();
396+
388397
if (!process.WaitForExit(60_000))
389398
{
390399
try
@@ -447,6 +456,51 @@ private static bool JwtHasAgentName(string jwt)
447456
}
448457
}
449458

459+
/// <summary>
460+
/// Escape a single argument for the Windows command line using the
461+
/// <c>CommandLineToArgvW</c> rules: internal double-quotes are escaped
462+
/// as <c>\"</c>, backslash runs immediately before a quote are doubled,
463+
/// and the whole value is wrapped in double quotes.
464+
/// </summary>
465+
private static string EscapeArg(string arg)
466+
{
467+
StringBuilder sb = new();
468+
sb.Append('"');
469+
470+
for (int i = 0; i < arg.Length; i++)
471+
{
472+
int backslashes = 0;
473+
while (i < arg.Length && arg[i] == '\\')
474+
{
475+
backslashes++;
476+
i++;
477+
}
478+
479+
if (i == arg.Length)
480+
{
481+
// Trailing backslashes must be doubled because the
482+
// closing quote follows immediately.
483+
sb.Append('\\', backslashes * 2);
484+
break;
485+
}
486+
else if (arg[i] == '"')
487+
{
488+
// Backslashes before a double-quote must be doubled,
489+
// plus one extra to escape the quote itself.
490+
sb.Append('\\', backslashes * 2 + 1);
491+
sb.Append('"');
492+
}
493+
else
494+
{
495+
sb.Append('\\', backslashes);
496+
sb.Append(arg[i]);
497+
}
498+
}
499+
500+
sb.Append('"');
501+
return sb.ToString();
502+
}
503+
450504
/// <summary>
451505
/// Patch the freshly-written agent.json's <c>Tunnel</c> section with the operator's
452506
/// advertised subnets and DNS suffixes from the wizard. Keeping this out of the

testsuite/src/cli.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ pub fn dgw_tokio_cmd() -> tokio::process::Command {
6565
cmd
6666
}
6767

68+
static AGENT_BIN_PATH: LazyLock<std::path::PathBuf> = LazyLock::new(|| {
69+
escargot::CargoBuild::new()
70+
.manifest_path("../devolutions-agent/Cargo.toml")
71+
.bin("devolutions-agent")
72+
.current_release()
73+
.current_target()
74+
.run()
75+
.expect("build Devolutions Agent")
76+
.path()
77+
.to_path_buf()
78+
});
79+
80+
pub fn agent_assert_cmd() -> assert_cmd::Command {
81+
let mut cmd = assert_cmd::Command::new(&*AGENT_BIN_PATH);
82+
cmd.env("RUST_BACKTRACE", "0");
83+
cmd
84+
}
85+
6886
pub fn assert_stderr_eq(output: &assert_cmd::assert::Assert, expected: expect_test::Expect) {
6987
let stderr = std::str::from_utf8(&output.get_output().stderr).unwrap();
7088
expected.assert_eq(stderr);

testsuite/src/dgw_config.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ pub struct AiGatewayConfig {
3838
pub openai_api_key: Option<String>,
3939
}
4040

41+
/// Configuration for the agent tunnel feature in tests.
42+
#[derive(Clone, TypedBuilder)]
43+
pub struct AgentTunnelConfig {
44+
/// Whether the agent tunnel is enabled.
45+
#[builder(default = true)]
46+
pub enabled: bool,
47+
/// UDP port for the QUIC listener.
48+
#[builder(default, setter(into))]
49+
pub listen_port: Option<u16>,
50+
}
51+
4152
#[derive(TypedBuilder)]
4253
pub struct DgwConfig {
4354
#[builder(default, setter(into))]
@@ -60,6 +71,9 @@ pub struct DgwConfig {
6071
/// Pass a path that does not yet exist to test behaviour before the folder is created.
6172
#[builder(default, setter(into))]
6273
recording_path: Option<std::path::PathBuf>,
74+
/// Agent tunnel (QUIC) configuration.
75+
#[builder(default, setter(into))]
76+
agent_tunnel: Option<AgentTunnelConfig>,
6377
}
6478

6579
fn find_unused_port() -> u16 {
@@ -92,6 +106,7 @@ impl DgwConfigHandle {
92106
ai_gateway,
93107
enable_unstable,
94108
recording_path,
109+
agent_tunnel,
95110
} = config;
96111

97112
let tempdir = tempfile::tempdir().context("create tempdir")?;
@@ -155,6 +170,20 @@ impl DgwConfigHandle {
155170
String::new()
156171
};
157172

173+
let agent_tunnel_json = if let Some(at_config) = agent_tunnel {
174+
let listen_port = at_config.listen_port.unwrap_or_else(find_unused_port);
175+
format!(
176+
r#",
177+
"AgentTunnel": {{
178+
"Enabled": {},
179+
"ListenPort": {listen_port}
180+
}}"#,
181+
at_config.enabled
182+
)
183+
} else {
184+
String::new()
185+
};
186+
158187
let config = format!(
159188
r#"{{
160189
"ProvisionerPublicKeyData": {{
@@ -174,7 +203,7 @@ impl DgwConfigHandle {
174203
"__debug__": {{
175204
"disable_token_validation": {disable_token_validation},
176205
"enable_unstable": {enable_unstable}
177-
}}{ai_gateway_json}{recording_path_json}
206+
}}{ai_gateway_json}{recording_path_json}{agent_tunnel_json}
178207
}}"#
179208
);
180209

testsuite/tests/cli/agent/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mod up;

0 commit comments

Comments
 (0)