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
2 changes: 2 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions crates/trusted-server-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ x509-parser = { workspace = true }

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
assert_cmd = { workspace = true }
edgezero-adapter-axum = { workspace = true, features = ["axum"] }
flate2 = { workspace = true }
predicates = { workspace = true }
temp-env = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
trusted-server-adapter-axum = { path = "../trusted-server-adapter-axum" }
3 changes: 3 additions & 0 deletions crates/trusted-server-cli/src/commands/dev/proxy/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ mod tests {

#[test]
fn leaf_cache_returns_same_arc_for_same_host() {
crate::tls::install_crypto_provider();
let dir = tempfile::tempdir().expect("should create tempdir");
let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate");
let a = ca
Expand All @@ -322,6 +323,7 @@ mod tests {

#[test]
fn leaf_cache_normalizes_dns_case() {
crate::tls::install_crypto_provider();
let dir = tempfile::tempdir().expect("should create tempdir");
let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate");
let lower = ca
Expand All @@ -335,6 +337,7 @@ mod tests {

#[test]
fn mints_leaf_for_ip_literal_host() {
crate::tls::install_crypto_provider();
// An IP-literal host must mint successfully (IP-type SAN, not DNS) — spec §8.3.
let dir = tempfile::tempdir().expect("should create tempdir");
let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate");
Expand Down
194 changes: 194 additions & 0 deletions crates/trusted-server-cli/src/commands/dev/proxy/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ pub enum ConfigError {
can reach the proxy. Bind a loopback address, or drop --basic-auth."
)]
BasicAuthNonLoopback { value: String },
/// The forwarding token file could not be read.
#[display("cannot read --forwarder-secret-file")]
ForwarderSecretFile,
/// The forwarding token was invalid.
#[display("--forwarder-secret-file must contain at least 32 ASCII graphic bytes on one line")]
ForwarderSecret,
/// Credential injection requires a loopback listener.
#[display("--forwarder-secret-file requires a loopback --listen address")]
ForwarderSecretNonLoopback,
/// An unknown or unsupported browser was passed to `--launch`.
#[display("unsupported browser `{value}` (use chrome|firefox|all, plus safari on macOS)")]
Browser { value: String },
Expand Down Expand Up @@ -91,6 +100,41 @@ impl core::fmt::Debug for BasicAuth {
}
}

/// Authentication header shared with Trusted Server's trusted-forwarder configuration.
pub const FORWARDER_AUTH_HEADER: &str = "x-ts-forwarder-auth";

/// A validated forwarding token whose debug representation is redacted.
#[derive(Clone)]
pub struct ForwarderSecret(HeaderValue);

impl ForwarderSecret {
fn parse(raw: &[u8]) -> Result<Self, ConfigError> {
let token = raw
.strip_suffix(b"\r\n")
.or_else(|| raw.strip_suffix(b"\n"))
.unwrap_or(raw);
if token.len() < 32 || !token.iter().all(u8::is_ascii_graphic) {
return Err(ConfigError::ForwarderSecret);
}
let mut header =
HeaderValue::from_bytes(token).map_err(|_| ConfigError::ForwarderSecret)?;
header.set_sensitive(true);
Ok(Self(header))
}

/// The prevalidated sensitive authentication header value.
#[must_use]
pub fn header_value(&self) -> &HeaderValue {
&self.0
}
}

impl core::fmt::Debug for ForwarderSecret {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter.write_str("ForwarderSecret([REDACTED])")
}
}

/// A browser the proxy can launch and configure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Browser {
Expand Down Expand Up @@ -143,6 +187,8 @@ pub struct ResolvedConfig {
pub launch: Vec<Browser>,
pub insecure: bool,
pub basic_auth: Option<BasicAuth>,
/// Optional credential for authenticating browser-facing forwarding metadata.
pub forwarder_secret: Option<ForwarderSecret>,
pub ca_dir: PathBuf,
/// DNS pins from `--resolve`: lowercase hostname → connection address. When
/// an upstream host is present here, the proxy dials this IP instead of
Expand Down Expand Up @@ -309,6 +355,18 @@ pub fn resolve(args: &ProxyArgs) -> Result<ResolvedConfig, Report<ConfigError>>
value: args.listen.clone(),
}));
}
if !is_loopback && args.forwarder_secret_file.is_some() {
return Err(Report::new(ConfigError::ForwarderSecretNonLoopback));
}
let forwarder_secret = args
.forwarder_secret_file
.as_ref()
.map(|path| {
let raw = std::fs::read(path).map_err(|_| ConfigError::ForwarderSecretFile)?;
ForwarderSecret::parse(&raw)
})
.transpose()
.map_err(Report::from)?;
let ca_dir = ca_dir(args);
let resolve = build_resolve(args).map_err(Report::from)?;

Expand Down Expand Up @@ -336,6 +394,7 @@ pub fn resolve(args: &ProxyArgs) -> Result<ResolvedConfig, Report<ConfigError>>
launch,
insecure: args.insecure,
basic_auth,
forwarder_secret,
ca_dir,
resolve,
connect_timeout: std::time::Duration::from_secs(args.connect_timeout),
Expand Down Expand Up @@ -384,6 +443,141 @@ mod tests {
W::try_parse_from(argv).map(|w| w.a)
}

#[test]
fn forwarder_secret_file_is_accepted_by_cli() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("token");
std::fs::write(&path, "example-forwarder-token-0123456789\n").expect("should write token");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
]);
assert!(
resolve(&args).is_ok(),
"should load a valid forwarder secret"
);
}

#[test]
fn forwarder_secret_files_reject_invalid_contents_without_disclosure() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("token");
for raw in [
"",
"example-short-token-0123456789",
"1234567890123456789012345678901",
"example-forwarder-token-0123456789 ",
" example-forwarder-token-0123456789",
"example-forwarder-token-0123456789\n\n",
"example-forwarder-token-0123456789\r",
"example-forwarder-token-0123456789\nsecond",
"example-forwarder-token-0123456789é",
] {
std::fs::write(&path, raw).expect("should write invalid token");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
]);
let error = resolve(&args).expect_err("should reject invalid token");
if !raw.is_empty() {
assert!(
!format!("{error:?}").contains(raw),
"should redact invalid token"
);
}
}
}

#[test]
fn non_loopback_rejects_forwarder_secret_file() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("token");
std::fs::write(&path, "example-forwarder-token-0123456789").expect("should write token");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
"--listen",
"0.0.0.0:18080",
"--allow-non-loopback",
]);
let error = resolve(&args).expect_err("should reject credential injection off loopback");
assert!(
matches!(
error.current_context(),
ConfigError::ForwarderSecretNonLoopback
),
"should reject credential injection before reading the file"
);
}

#[test]
fn forwarder_credentials_are_optional_and_accept_exactly_32_bytes() {
let args = parse_args(&["ts", "--map", "a.example.com=b.example.com"]);
assert!(
resolve(&args)
.expect("should resolve default config")
.forwarder_secret
.is_none(),
"should leave forwarding authentication disabled by default"
);
assert!(
ForwarderSecret::parse(b"12345678901234567890123456789012").is_ok(),
"should accept the minimum token length"
);
}

#[test]
fn forwarding_token_is_sensitive_and_debug_redacted() {
for ending in ["", "\n", "\r\n"] {
let raw = format!("example-forwarder-token-0123456789{ending}");
let secret = ForwarderSecret::parse(raw.as_bytes()).expect("should accept token");
assert!(
secret.header_value().is_sensitive(),
"should mark credential sensitive"
);
assert_eq!(
secret.header_value(),
"example-forwarder-token-0123456789",
"should remove only line terminator"
);
assert!(
!format!("{secret:?}").contains("example-forwarder"),
"should redact wrapper Debug"
);
assert!(
!format!("{:?}", secret.header_value()).contains("example-forwarder"),
"should redact header Debug"
);
}
}

#[test]
fn forwarder_secret_file_read_failure_is_redacted() {
let dir = tempfile::tempdir().expect("should create temporary directory");
let path = dir.path().join("missing");
let args = parse_args(&[
"ts",
"--map",
"a.example.com=b.example.com",
"--forwarder-secret-file",
path.to_str().expect("should encode path"),
]);
let error = resolve(&args).expect_err("should reject missing token file");
assert!(
matches!(error.current_context(), ConfigError::ForwarderSecretFile),
"should report file error"
);
}

#[test]
fn clap_parses_rewrite_host_as_a_bool() {
assert!(
Expand Down
6 changes: 6 additions & 0 deletions crates/trusted-server-cli/src/commands/dev/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ pub struct ProxyArgs {
#[arg(long, value_name = "PATH")]
pub basic_auth_file: Option<String>,

/// Read the Trusted Server forwarding token from a file (loopback only).
#[arg(long, value_name = "PATH")]
pub forwarder_secret_file: Option<String>,

/// Skip upstream certificate verification.
#[arg(long)]
pub insecure: bool,
Expand Down Expand Up @@ -251,6 +255,8 @@ pub fn run(args: &ProxyArgs) -> core::result::Result<(), error_stack::Report<Pro
// nor changes system proxy state, so it is safe to run first.
let mut cfg = config::resolve(args).change_context(ProxyError::Config)?;

crate::tls::install_crypto_provider();

// Non-interactive so an unrelated startup never blocks on a sudo password
// prompt.
browser::restore_system_proxy_if_pending(&cfg.ca_dir, false);
Expand Down
Loading
Loading