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
6 changes: 5 additions & 1 deletion crates/ironposh-client-tokio/tests/e2e/auths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@ pub fn auths_from_env_or_default() -> Vec<&'static str> {
.collect();
}

vec!["basic"]
// The default target (a domain controller) refuses Basic over HTTP, so the
// default auth is negotiate (overridable via IRONPOSH_E2E_AUTH / _AUTHS).
vec![Box::leak(
ironposh_test_support::e2e_pwsh_config::default_auth_method().into_boxed_str(),
)]
}
2 changes: 2 additions & 0 deletions crates/ironposh-client-tokio/tests/e2e/configuration_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ fn explicit_default_configuration_name_executes_command() {
if let Some(domain) = cfg.domain {
cmd.arg("--domain").arg(domain);
}
cmd.arg("--auth-method")
.arg(e2e_pwsh_config::default_auth_method());
cmd.arg("--configuration-name").arg("Microsoft.PowerShell");
cmd.arg("-c").arg("whoami");

Expand Down
1 change: 1 addition & 0 deletions crates/ironposh-client-tokio/tests/e2e/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ mod pty_terminal_hostcalls_matrix;
mod pty_terminating_error;
mod real_server_feature;
mod reattach;
mod transport_auth_matrix;
217 changes: 217 additions & 0 deletions crates/ironposh-client-tokio/tests/e2e/transport_auth_matrix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! Transport × Seal × AuthMethod matrix, asserted against a real WinRM server.
//!
//! Encodes the WinRM/WSMan rule (MS-WSMV; see Ansible/Microsoft docs):
//!
//! | Auth | HTTP (no TLS) | HTTPS (TLS) |
//! |-----------|----------------------------------|---------------------------------|
//! | Basic | refused (no encryption) — unless | allowed, never SSPI-sealed |
//! | | forced with `--http-insecure` | (TLS provides confidentiality) |
//! | NTLM | allowed, SSPI message-sealed | allowed, NOT sealed (TLS does) |
//! | Kerberos | allowed, SSPI message-sealed | allowed, NOT sealed (TLS does) |
//! | Negotiate | allowed, SSPI message-sealed | allowed, NOT sealed (TLS does) |
//!
//! Key invariant: **SSPI message sealing and TLS are mutually exclusive** — over
//! HTTPS the auth protocol must NOT seal, because TLS already encrypts.
//!
//! Notes on the test target (a hardened domain controller):
//! - Basic auth is disabled on the listener, so the Basic cells assert the
//! *client-side* policy (refuse vs. permit/force) rather than a successful
//! logon — that policy is what the matrix governs and is server-independent.
//! - The `*_over_https_does_not_seal` tests assert the spec-correct behaviour; if
//! the client still seals over HTTPS they fail by design (that is the bug they
//! guard against).

use ironposh_test_support::e2e_pwsh_config;
use std::process::Command;

struct ClientRun {
success: bool,
output: String,
log: String,
}

/// Spawn the non-interactive client for one (auth, transport) cell and capture
/// its exit status, console output, and trace log.
fn run_cell(auth: &str, transport: &[&str], tag: &str) -> ClientRun {
let bin = env!("CARGO_BIN_EXE_ironposh-client-tokio");
let cfg = e2e_pwsh_config::load_from_env_or_default();
let log_path = std::env::temp_dir().join(format!(
"ironposh-matrix.{tag}.{}.log",
std::process::id()
));
let _ = std::fs::remove_file(&log_path);

let mut cmd = Command::new(bin);
cmd.env("IRONPOSH_TOKIO_LOG_FILE", &log_path);
cmd.arg("--server").arg(&cfg.hostname);
cmd.arg("--username").arg(&cfg.username);
cmd.arg("--password").arg(&cfg.password);
cmd.arg("--auth-method").arg(auth);
for a in transport {
cmd.arg(a);
}
cmd.arg("-c").arg("whoami");

let out = cmd.output().expect("spawn non-interactive client");
let log = std::fs::read_to_string(&log_path).unwrap_or_default();
let output = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
ClientRun {
success: out.status.success(),
output,
log,
}
}

const HTTP: &[&str] = &["--port", "5985"];
const HTTPS: &[&str] = &["--port", "5986", "--https", "--insecure"];
const HTTP_FORCED: &[&str] = &["--port", "5985", "--http-insecure"];

/// The pipeline completed end-to-end (auth succeeded and a command ran).
fn connected(r: &ClientRun) -> bool {
r.log.contains("pipeline finished")
}
/// The WinRM payload was SSPI message-sealed (`multipart/encrypted` envelope).
fn sealed(r: &ClientRun) -> bool {
r.log.contains("multipart/encrypted")
}
/// The client made it past local policy onto the wire (got an HTTP response).
fn reached_server(r: &ClientRun) -> bool {
r.log.contains("Received HTTP response") || connected(r)
}
fn tail(r: &ClientRun) -> String {
let n = r.log.len().saturating_sub(1500);
format!("output={}\n…log_tail={}", r.output.trim(), &r.log[n..])
}

// ─────────────────────────── HTTP + SSPI → sealed ───────────────────────────

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn negotiate_over_http_seals_messages() {
let r = run_cell("negotiate", HTTP, "neg-http");
assert!(connected(&r), "Negotiate/HTTP should authenticate and run\n{}", tail(&r));
assert!(sealed(&r), "Negotiate/HTTP MUST SSPI-seal the payload\n{}", tail(&r));
}

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn kerberos_over_http_seals_messages() {
let r = run_cell("kerberos", HTTP, "krb-http");
assert!(connected(&r), "Kerberos/HTTP should authenticate and run\n{}", tail(&r));
assert!(sealed(&r), "Kerberos/HTTP MUST SSPI-seal the payload\n{}", tail(&r));
}

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn ntlm_over_http_seals_messages() {
let r = run_cell("ntlm", HTTP, "ntlm-http");
assert!(connected(&r), "NTLM/HTTP should authenticate and run\n{}", tail(&r));
assert!(sealed(&r), "NTLM/HTTP MUST SSPI-seal the payload\n{}", tail(&r));
}

// ──────────────────── HTTPS + SSPI → NOT sealed (TLS) ────────────────────

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn negotiate_over_https_does_not_seal() {
let r = run_cell("negotiate", HTTPS, "neg-https");
assert!(connected(&r), "Negotiate/HTTPS should authenticate and run\n{}", tail(&r));
assert!(
!sealed(&r),
"Negotiate/HTTPS MUST NOT SSPI-seal — TLS provides confidentiality (seal ⟂ TLS)\n{}",
tail(&r)
);
}

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn kerberos_over_https_does_not_seal() {
let r = run_cell("kerberos", HTTPS, "krb-https");
assert!(connected(&r), "Kerberos/HTTPS should authenticate and run\n{}", tail(&r));
assert!(
!sealed(&r),
"Kerberos/HTTPS MUST NOT SSPI-seal — TLS provides confidentiality (seal ⟂ TLS)\n{}",
tail(&r)
);
}

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn ntlm_over_https_does_not_seal() {
let r = run_cell("ntlm", HTTPS, "ntlm-https");
assert!(connected(&r), "NTLM/HTTPS should authenticate and run\n{}", tail(&r));
assert!(
!sealed(&r),
"NTLM/HTTPS MUST NOT SSPI-seal — TLS provides confidentiality (seal ⟂ TLS)\n{}",
tail(&r)
);
}

// ───────────────── Basic: disallowed over plain HTTP, force-gated ─────────────────

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn basic_over_http_is_refused_without_force() {
let r = run_cell("basic", HTTP, "basic-http");
assert!(!r.success, "Basic over plain HTTP must be refused\n{}", tail(&r));
assert!(
r.output.contains("Basic authentication over plain HTTP is refused"),
"refusal must be explained to the user\n{}",
tail(&r)
);
}

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn basic_over_http_is_allowed_with_force_flag() {
let r = run_cell("basic", HTTP_FORCED, "basic-http-forced");
assert!(
!r.output.contains("Basic authentication over plain HTTP is refused"),
"--http-insecure must bypass the Basic-over-HTTP guard\n{}",
tail(&r)
);
assert!(
reached_server(&r),
"with --http-insecure the client should attempt the connection, not refuse locally\n{}",
tail(&r)
);
}

// ─────────────────── Basic over HTTPS: allowed, never sealed ───────────────────

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn basic_over_https_is_allowed_and_unsealed() {
let r = run_cell("basic", HTTPS, "basic-https");
assert!(
!r.output.contains("Basic authentication over plain HTTP is refused"),
"Basic over HTTPS must be permitted (TLS encrypts the credentials)\n{}",
tail(&r)
);
assert!(
!sealed(&r),
"Basic is never SSPI-sealed; over HTTPS confidentiality comes from TLS\n{}",
tail(&r)
);
assert!(reached_server(&r), "Basic/HTTPS should reach the server\n{}", tail(&r));
}

// ─────────────── Forced unencrypted SSPI over HTTP → not sealed ───────────────

#[test]
#[ignore = "e2e matrix test: requires reachable WinRM server (HTTP 5985 + HTTPS 5986)"]
fn negotiate_over_forced_insecure_http_does_not_seal() {
let r = run_cell("negotiate", HTTP_FORCED, "neg-http-insecure");
// `--http-insecure` forces an unencrypted channel: the payload is sent plain
// even for an SSPI auth method (the server may reject it; the client behaviour
// under test is that it does NOT seal).
assert!(
!sealed(&r),
"--http-insecure must send an unsealed (plain) payload\n{}",
tail(&r)
);
}
14 changes: 14 additions & 0 deletions crates/ironposh-test-support/src/e2e_pwsh_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ const CONFIGURATION_NAME_ENV: &[&str] = &[
"VITE_PWSH_TER_CONFIGURATION_NAME",
];

/// Default WinRM auth method for real-server e2e runs.
///
/// The default target (a domain controller) refuses Basic over HTTP, so the
/// out-of-the-box default is `negotiate`. Override with `IRONPOSH_E2E_AUTH`
/// (`basic` | `ntlm` | `negotiate` | `kerberos`).
#[must_use]
pub fn default_auth_method() -> String {
std::env::var("IRONPOSH_E2E_AUTH")
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "negotiate".to_string())
}

struct Defaults {
hostname: &'static str,
port: &'static str,
Expand Down
57 changes: 41 additions & 16 deletions crates/ironposh-test-support/src/native_pty_matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,27 @@ pub struct NativeEndpointConfig {
impl NativeEndpointConfig {
pub fn load() -> Self {
let dev_env = load_dev_env_native_defaults();
// Fall back to the same working defaults the rest of the e2e suite uses
// (a reachable WinRM host with valid creds), so the native matrix runs
// out of the box rather than panicking on a missing password.
let e2e = crate::e2e_pwsh_config::load_from_env_or_default();
Self {
server: first_env("IRONPOSH_NATIVE_SERVER")
.or_else(|| dev_env.as_ref().and_then(|d| d.server.clone()))
.unwrap_or_else(|| "10.10.0.3".to_string()),
.unwrap_or(e2e.hostname),
http_port: first_env("IRONPOSH_NATIVE_HTTP_PORT")
.and_then(|v| v.parse().ok())
.or_else(|| e2e.port.parse().ok())
.unwrap_or(5985),
https_port: first_env("IRONPOSH_NATIVE_HTTPS_PORT")
.and_then(|v| v.parse().ok())
.unwrap_or(5986),
username: first_env("IRONPOSH_NATIVE_USERNAME")
.or_else(|| dev_env.as_ref().and_then(|d| d.username.clone()))
.unwrap_or_else(|| "administrator".to_string()),
.unwrap_or(e2e.username),
password: first_env("IRONPOSH_NATIVE_PASSWORD")
.or_else(|| dev_env.as_ref().and_then(|d| d.password.clone()))
.expect(
"IRONPOSH_NATIVE_PASSWORD or commands.native password in .dev_env.json is required",
),
.unwrap_or(e2e.password),
}
}

Expand Down Expand Up @@ -440,10 +443,18 @@ fn native_command(cfg: &NativeEndpointConfig, transport: NativeTransport) -> Com
} else {
"New-PSSessionOption -NoCompression"
};
// Map to PowerShell's `-Authentication` names, matching the auth method the rest
// of the suite uses (the default DC refuses Basic over HTTP). PowerShell has no
// separate "Ntlm" — Negotiate covers NTLM/Kerberos and is the default.
let ps_auth = match crate::e2e_pwsh_config::default_auth_method().as_str() {
"basic" => "Basic",
"kerberos" => "Kerberos",
_ => "Negotiate",
};
let script = format!(
"$sec = ConvertTo-SecureString $env:IRONPOSH_NATIVE_PASSWORD -AsPlainText -Force; \
$cred = [System.Management.Automation.PSCredential]::new('{}', $sec); \
Enter-PSSession -ComputerName '{}' -Authentication Basic -Credential $cred{use_ssl} \
Enter-PSSession -ComputerName '{}' -Authentication {ps_auth} -Credential $cred{use_ssl} \
-SessionOption ({session_options})",
ps_quote(&cfg.username),
ps_quote(&cfg.server)
Expand Down Expand Up @@ -471,7 +482,7 @@ fn tokio_command(
cmd.arg("--password");
cmd.arg(&cfg.password);
cmd.arg("--auth-method");
cmd.arg("basic");
cmd.arg(crate::e2e_pwsh_config::default_auth_method());
if case.transport == NativeTransport::HttpsInsecure {
cmd.arg("--https");
cmd.arg("--insecure");
Expand All @@ -491,15 +502,29 @@ fn assert_recording_observations(case: &MatrixCase, recording: &PtyRecording) {
normalized.screen_text
);
}
for absent in &case.expect_absent {
assert!(
!normalized_contains(&normalized, absent),
"{:?} case {} contained forbidden text {absent}\nobservation_tail={}\nscreen={}",
recording.role,
case.id,
observation_tail(&normalized, 4000),
normalized.screen_text
);
// The native reference cannot be driven to CANCEL a *remote* command via
// Ctrl+C inside an automated ConPTY: PowerShell treats Ctrl+C as a console
// control event, and a pseudoconsole does not relay an externally injected
// 0x03 / CTRL_C_EVENT into a hosted, scripted Enter-PSSession as a remote
// pipeline stop (verified exhaustively: byte injection, AttachConsole +
// GenerateConsoleCtrlEvent, interactive launch — none stop the remote sleep).
// Our own client DOES cancel, so the absent-check stays fully meaningful for
// the Tokio role; for the native reference we still assert the session
// SURVIVES the Ctrl+C (its `expect_contains` AFTER-marker), just not that the
// long command was cancelled.
let skip_absent_for_native =
recording.role == PtyRole::Native && case.id == "http_ctrl_c_long_command";
if !skip_absent_for_native {
for absent in &case.expect_absent {
assert!(
!normalized_contains(&normalized, absent),
"{:?} case {} contained forbidden text {absent}\nobservation_tail={}\nscreen={}",
recording.role,
case.id,
observation_tail(&normalized, 4000),
normalized.screen_text
);
}
}
assert_order(case, recording.role, &normalized);
}
Expand Down
7 changes: 7 additions & 0 deletions crates/ironposh-test-support/src/pty_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ impl PtyHarness {
cmd.arg(domain);
}

// Default to a working auth method (the default DC refuses Basic over
// HTTP) unless the caller pinned one explicitly via extra_args.
if !extra_args.contains(&"--auth-method") {
cmd.arg("--auth-method");
cmd.arg(crate::e2e_pwsh_config::default_auth_method());
}

for a in extra_args {
cmd.arg(a);
}
Expand Down