Rename transport binding to WIP over HTTP
This commit is contained in:
@@ -939,7 +939,7 @@ mod tests {
|
||||
// A request identity has no public constructor; derive one through the runtime.
|
||||
let mut client = wip_client::Client::new(
|
||||
wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(),
|
||||
wip_https::Limits::new(1024, 1024, 8, 8).unwrap(),
|
||||
wip_http::Limits::new(1024, 1024, 8, 8).unwrap(),
|
||||
);
|
||||
let session = client
|
||||
.open_session(
|
||||
|
||||
+34
-110
@@ -1,10 +1,10 @@
|
||||
//! Secure-by-default command-line and environment configuration.
|
||||
//! Command-line and environment configuration for the HTTP client.
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt::{self, Debug, Display, Formatter};
|
||||
use std::time::Duration;
|
||||
|
||||
use wip_https::url::Url;
|
||||
use wip_http::Endpoint;
|
||||
|
||||
/// Credential wrapper whose debug output is always redacted.
|
||||
#[derive(Clone)]
|
||||
@@ -25,45 +25,33 @@ impl Debug for Secret {
|
||||
/// Validated runtime configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
/// Canonical logical HTTPS endpoint used by `wip-client`.
|
||||
logical_endpoint: String,
|
||||
/// Actual request origin; differs only for explicit loopback development HTTP.
|
||||
transport_endpoint: String,
|
||||
/// Canonical endpoint used for request construction, transport, and cache partitioning.
|
||||
endpoint: String,
|
||||
/// Non-secret cache partition identity.
|
||||
security_context: String,
|
||||
/// Bounded network timeout.
|
||||
timeout: Duration,
|
||||
/// Optional complete Authorization header value, never rendered or logged.
|
||||
authorization: Option<Secret>,
|
||||
/// Whether loopback request URIs are deliberately downgraded to HTTP.
|
||||
development_http: bool,
|
||||
}
|
||||
|
||||
impl Debug for Config {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("Config")
|
||||
.field("logical_endpoint", &self.logical_endpoint)
|
||||
.field("transport_endpoint", &self.transport_endpoint)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("security_context", &self.security_context)
|
||||
.field("timeout", &self.timeout)
|
||||
.field("authorization", &self.authorization)
|
||||
.field("development_http", &self.development_http)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Returns the canonical HTTPS endpoint used for protocol request construction.
|
||||
/// Returns the canonical endpoint used by the client and HTTP transport.
|
||||
#[must_use]
|
||||
pub fn logical_endpoint(&self) -> &str {
|
||||
&self.logical_endpoint
|
||||
}
|
||||
|
||||
/// Returns the validated effective transport endpoint shown in the UI.
|
||||
#[must_use]
|
||||
pub fn transport_endpoint(&self) -> &str {
|
||||
&self.transport_endpoint
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// Returns the non-secret session cache partition identity.
|
||||
@@ -78,12 +66,6 @@ impl Config {
|
||||
self.timeout
|
||||
}
|
||||
|
||||
/// Whether explicit loopback development HTTP is enabled.
|
||||
#[must_use]
|
||||
pub const fn development_http(&self) -> bool {
|
||||
self.development_http
|
||||
}
|
||||
|
||||
pub(crate) fn authorization(&self) -> Option<&Secret> {
|
||||
self.authorization.as_ref()
|
||||
}
|
||||
@@ -115,7 +97,6 @@ impl Config {
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
let mut endpoint = None;
|
||||
let mut allow_http = false;
|
||||
let mut timeout = Duration::from_secs(30);
|
||||
let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into());
|
||||
let mut authorization_env = "WIP_AUTHORIZATION".to_owned();
|
||||
@@ -123,7 +104,6 @@ impl Config {
|
||||
while let Some(argument) = iter.next() {
|
||||
match argument.as_str() {
|
||||
"--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?),
|
||||
"--allow-http" => allow_http = true,
|
||||
"--security-context" => {
|
||||
security_context = next_value(&mut iter, "--security-context")?;
|
||||
}
|
||||
@@ -149,52 +129,22 @@ impl Config {
|
||||
_ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())),
|
||||
}
|
||||
}
|
||||
let endpoint = endpoint
|
||||
let supplied = endpoint
|
||||
.or(endpoint_env)
|
||||
.ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?;
|
||||
let mut actual = Url::parse(&endpoint)
|
||||
.map_err(|_| ConfigError::Invalid("endpoint is not a valid absolute URL".into()))?;
|
||||
if actual.query().is_some()
|
||||
|| actual.fragment().is_some()
|
||||
|| actual.host_str().is_none()
|
||||
|| !actual.username().is_empty()
|
||||
|| actual.password().is_some()
|
||||
{
|
||||
let endpoint =
|
||||
Endpoint::parse(&supplied).map_err(|error| ConfigError::Invalid(error.to_string()))?;
|
||||
if !endpoint.as_url().username().is_empty() || endpoint.as_url().password().is_some() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"endpoint must be an absolute URL without userinfo, query, or fragment".into(),
|
||||
"endpoint userinfo is not accepted; supply credentials through the configured environment variable"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !actual.path().ends_with('/') {
|
||||
let path = format!("{}/", actual.path());
|
||||
actual.set_path(&path);
|
||||
if !matches!(endpoint.as_url().scheme(), "http" | "https") {
|
||||
return Err(ConfigError::Invalid(
|
||||
"this HTTP client supports http and https endpoint schemes".into(),
|
||||
));
|
||||
}
|
||||
let development_http = actual.scheme() == "http";
|
||||
match actual.scheme() {
|
||||
"https" => {}
|
||||
"http" if allow_http && loopback_host(&actual) => {}
|
||||
"http" if !allow_http => {
|
||||
return Err(ConfigError::Invalid(
|
||||
"plain HTTP requires the explicit --allow-http development option".into(),
|
||||
));
|
||||
}
|
||||
"http" => {
|
||||
return Err(ConfigError::Invalid(
|
||||
"development HTTP is restricted to loopback endpoints".into(),
|
||||
));
|
||||
}
|
||||
_ => return Err(ConfigError::Invalid("endpoint must use HTTPS".into())),
|
||||
}
|
||||
let transport_endpoint = actual.to_string();
|
||||
let mut logical = actual;
|
||||
if development_http {
|
||||
logical
|
||||
.set_scheme("https")
|
||||
.expect("http and https are interchangeable hierarchical schemes");
|
||||
}
|
||||
// Let the binding enforce all remaining canonical endpoint rules.
|
||||
let logical_endpoint = wip_https::Endpoint::parse(logical.as_str())
|
||||
.map_err(|error| ConfigError::Invalid(error.to_string()))?
|
||||
.to_string();
|
||||
if security_context.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"security context must not be empty".into(),
|
||||
@@ -204,20 +154,18 @@ impl Config {
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(Secret);
|
||||
Ok(Self {
|
||||
logical_endpoint,
|
||||
transport_endpoint,
|
||||
endpoint: endpoint.to_string(),
|
||||
security_context,
|
||||
timeout,
|
||||
authorization,
|
||||
development_http,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns concise command usage without mentioning credential values.
|
||||
#[must_use]
|
||||
pub const fn usage() -> &'static str {
|
||||
"usage: wip-tui [--endpoint] URL [--allow-http] [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
|
||||
URL may also be supplied as WIP_ENDPOINT; HTTPS is required unless loopback HTTP is explicitly enabled."
|
||||
"usage: wip-tui [--endpoint] URL [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\
|
||||
URL may also be supplied as WIP_ENDPOINT; the HTTP adapter supports http and https."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,15 +177,6 @@ fn next_value(
|
||||
.ok_or_else(|| ConfigError::Invalid(format!("{option} requires a value")))
|
||||
}
|
||||
|
||||
fn loopback_host(url: &Url) -> bool {
|
||||
match url.host() {
|
||||
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup configuration failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConfigError {
|
||||
@@ -263,7 +202,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn https_is_default_and_secrets_are_redacted() {
|
||||
fn endpoint_is_canonical_and_secrets_are_redacted() {
|
||||
let config = Config::parse(
|
||||
["https://example.test/wip", "--authorization-env", "TOKEN"],
|
||||
None,
|
||||
@@ -271,7 +210,7 @@ mod tests {
|
||||
|name| (name == "TOKEN").then(|| "Bearer secret-value".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.logical_endpoint(), "https://example.test/wip/");
|
||||
assert_eq!(config.endpoint(), "https://example.test/wip/");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(!debug.contains("secret-value"));
|
||||
@@ -279,37 +218,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() {
|
||||
for arguments in [
|
||||
vec!["https://user:password@example.test/wip"],
|
||||
vec!["http://user:password@localhost:3000/wip", "--allow-http"],
|
||||
for endpoint in [
|
||||
"https://user:password@example.test/wip",
|
||||
"http://user:password@localhost:3000/wip",
|
||||
] {
|
||||
let error = Config::parse(arguments, None, None, |_| None).unwrap_err();
|
||||
assert!(error.to_string().contains("without userinfo"));
|
||||
let error = Config::parse([endpoint], None, None, |_| None).unwrap_err();
|
||||
assert!(error.to_string().contains("userinfo"));
|
||||
assert!(!error.to_string().contains("password"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_requires_explicit_loopback_development_mode() {
|
||||
assert!(Config::parse(["http://localhost:3000/wip"], None, None, |_| None).is_err());
|
||||
let config = Config::parse(
|
||||
["http://127.0.0.1:3000/wip", "--allow-http"],
|
||||
None,
|
||||
None,
|
||||
|_| None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.logical_endpoint(), "https://127.0.0.1:3000/wip/");
|
||||
assert_eq!(config.transport_endpoint(), "http://127.0.0.1:3000/wip/");
|
||||
assert!(config.development_http());
|
||||
assert!(
|
||||
Config::parse(
|
||||
["http://example.test/wip", "--allow-http"],
|
||||
None,
|
||||
None,
|
||||
|_| None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
fn http_and_https_are_preserved_as_the_single_endpoint() {
|
||||
for expected in ["http://example.test:3000/wip/", "https://example.test/wip/"] {
|
||||
let supplied = expected.trim_end_matches('/');
|
||||
let config = Config::parse([supplied], None, None, |_| None).unwrap();
|
||||
assert_eq!(config.endpoint(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ use wip_client::{
|
||||
DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext,
|
||||
SessionId,
|
||||
};
|
||||
use wip_https::Limits;
|
||||
use wip_https::http::{Request, Response};
|
||||
use wip_http::Limits;
|
||||
use wip_http::http::{Request, Response};
|
||||
use wip_protocol::{InterfaceDescriptor, TypeExpr, Value};
|
||||
|
||||
use crate::app::{
|
||||
@@ -68,18 +68,17 @@ pub trait Transport: Send + Sync + 'static {
|
||||
fn execute(&self, request: Self::Prepared) -> Result<Response<Vec<u8>>, TransportError>;
|
||||
}
|
||||
|
||||
/// HTTPS transport with redirects disabled and bounded response bodies.
|
||||
/// HTTP transport with redirects disabled and bounded response bodies.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpTransport {
|
||||
agent: ureq::Agent,
|
||||
logical_endpoint: wip_https::Endpoint,
|
||||
transport_endpoint: wip_https::url::Url,
|
||||
authorization: Option<wip_https::http::HeaderValue>,
|
||||
endpoint: wip_http::Endpoint,
|
||||
authorization: Option<wip_http::http::HeaderValue>,
|
||||
max_response_bytes: usize,
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
/// Builds the default TLS transport and revalidates its immutable origins.
|
||||
/// Builds the HTTP transport and revalidates its immutable endpoint.
|
||||
pub fn new(config: &Config, max_response_bytes: usize) -> Result<Self, TransportError> {
|
||||
if max_response_bytes == 0 {
|
||||
return Err(TransportError {
|
||||
@@ -87,29 +86,18 @@ impl HttpTransport {
|
||||
detail: "transport response limit must be nonzero".into(),
|
||||
});
|
||||
}
|
||||
let logical_endpoint =
|
||||
wip_https::Endpoint::parse(config.logical_endpoint()).map_err(|_| TransportError {
|
||||
let endpoint =
|
||||
wip_http::Endpoint::parse(config.endpoint()).map_err(|_| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "logical endpoint failed transport validation".into(),
|
||||
detail: "endpoint failed transport validation".into(),
|
||||
})?;
|
||||
let transport_endpoint =
|
||||
wip_https::url::Url::parse(config.transport_endpoint()).map_err(|_| {
|
||||
TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "transport endpoint failed validation".into(),
|
||||
}
|
||||
})?;
|
||||
validate_transport_origin(config, &logical_endpoint, &transport_endpoint)?;
|
||||
let authorization = config
|
||||
.authorization()
|
||||
.map(|secret| {
|
||||
wip_https::http::HeaderValue::from_str(secret.expose()).map_err(|_| {
|
||||
TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail:
|
||||
"authorization environment variable is not a valid HTTP header value"
|
||||
.into(),
|
||||
}
|
||||
wip_http::http::HeaderValue::from_str(secret.expose()).map_err(|_| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "authorization environment variable is not a valid HTTP header value"
|
||||
.into(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
@@ -121,67 +109,24 @@ impl HttpTransport {
|
||||
.into();
|
||||
Ok(Self {
|
||||
agent,
|
||||
logical_endpoint,
|
||||
transport_endpoint,
|
||||
endpoint,
|
||||
authorization,
|
||||
max_response_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn effective_uri(&self, uri: &str) -> Result<String, TransportError> {
|
||||
let candidate = wip_https::url::Url::parse(uri).map_err(|_| TransportError {
|
||||
let candidate = wip_http::url::Url::parse(uri).map_err(|_| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "refused a request with an invalid absolute URI".into(),
|
||||
})?;
|
||||
let route = self
|
||||
.logical_endpoint
|
||||
self.endpoint
|
||||
.recognize(&candidate)
|
||||
.ok_or_else(|| TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "refused a request outside the configured WIP origin and routes".into(),
|
||||
})?;
|
||||
Ok(self
|
||||
.transport_endpoint
|
||||
.join(route.relative_reference())
|
||||
.expect("validated endpoint joins a fixed route")
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_transport_origin(
|
||||
config: &Config,
|
||||
logical: &wip_https::Endpoint,
|
||||
transport: &wip_https::url::Url,
|
||||
) -> Result<(), TransportError> {
|
||||
let common_invalid = transport.query().is_some()
|
||||
|| transport.fragment().is_some()
|
||||
|| transport.host_str().is_none()
|
||||
|| !transport.username().is_empty()
|
||||
|| transport.password().is_some();
|
||||
let valid = if config.development_http() {
|
||||
let loopback = match transport.host() {
|
||||
Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(),
|
||||
Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
|
||||
None => false,
|
||||
};
|
||||
let mut upgraded = transport.clone();
|
||||
let changed = upgraded.set_scheme("https").is_ok();
|
||||
!common_invalid
|
||||
&& transport.scheme() == "http"
|
||||
&& loopback
|
||||
&& changed
|
||||
&& &upgraded == logical.as_url()
|
||||
} else {
|
||||
!common_invalid && transport == logical.as_url()
|
||||
};
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "transport endpoint does not match the validated logical origin".into(),
|
||||
})
|
||||
Ok(candidate.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,10 +141,9 @@ impl Transport for HttpTransport {
|
||||
detail: "transport could not construct the configured request URI".into(),
|
||||
})?;
|
||||
if let Some(authorization) = &self.authorization {
|
||||
parts.headers.insert(
|
||||
wip_https::http::header::AUTHORIZATION,
|
||||
authorization.clone(),
|
||||
);
|
||||
parts
|
||||
.headers
|
||||
.insert(wip_http::http::header::AUTHORIZATION, authorization.clone());
|
||||
}
|
||||
Ok(Request::from_parts(parts, body))
|
||||
}
|
||||
@@ -242,7 +186,7 @@ fn classify_ureq_error(error: ureq::Error) -> TransportError {
|
||||
},
|
||||
_ => TransportError {
|
||||
kind: TransportErrorKind::Disconnect,
|
||||
detail: "HTTP/TLS transport failed".into(),
|
||||
detail: "HTTP transport failed".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -274,7 +218,7 @@ impl<T: Transport> Runtime<T> {
|
||||
);
|
||||
let session = client
|
||||
.open_session(
|
||||
config.logical_endpoint(),
|
||||
config.endpoint(),
|
||||
SecurityContext::new(config.security_context()),
|
||||
)
|
||||
.map_err(|error| RuntimeError(error.to_string()))?;
|
||||
@@ -282,7 +226,7 @@ impl<T: Transport> Runtime<T> {
|
||||
Ok(Self {
|
||||
client,
|
||||
session,
|
||||
app: App::new(config.transport_endpoint()),
|
||||
app: App::new(config.endpoint()),
|
||||
transport: Arc::new(transport),
|
||||
sender,
|
||||
receiver,
|
||||
@@ -1046,6 +990,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_preserves_the_configured_endpoint_scheme_for_requests() {
|
||||
for scheme in ["http", "https"] {
|
||||
let configured = format!("{scheme}://example.test/wip");
|
||||
let config = Config::parse([configured], None, None, |_| None).unwrap();
|
||||
let transport = HttpTransport::new(&config, 1024).unwrap();
|
||||
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
|
||||
let expected = endpoint.route(wip_http::Route::Observe).to_string();
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(&expected)
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
|
||||
let prepared = transport.prepare(request).unwrap();
|
||||
|
||||
assert_eq!(prepared.uri().to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_is_attached_only_after_exact_origin_and_route_validation() {
|
||||
let config = Config::parse(
|
||||
@@ -1065,15 +1029,15 @@ mod tests {
|
||||
assert!(error.detail.contains("outside"));
|
||||
assert!(!error.detail.contains("sensitive"));
|
||||
|
||||
let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap();
|
||||
let endpoint = wip_http::Endpoint::parse(config.endpoint()).unwrap();
|
||||
let valid = Request::builder()
|
||||
.method("POST")
|
||||
.uri(endpoint.route(wip_https::Route::Observe).as_str())
|
||||
.uri(endpoint.route(wip_http::Route::Observe).as_str())
|
||||
.body(Vec::new())
|
||||
.unwrap();
|
||||
let prepared = transport.prepare(valid).unwrap();
|
||||
assert_eq!(
|
||||
prepared.headers()[wip_https::http::header::AUTHORIZATION],
|
||||
prepared.headers()[wip_http::http::header::AUTHORIZATION],
|
||||
"Bearer sensitive"
|
||||
);
|
||||
}
|
||||
@@ -1081,7 +1045,7 @@ mod tests {
|
||||
#[test]
|
||||
fn transport_error_messages_do_not_echo_underlying_data() {
|
||||
let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into()));
|
||||
assert_eq!(error.detail, "HTTP/TLS transport failed");
|
||||
assert_eq!(error.detail, "HTTP transport failed");
|
||||
assert!(!error.detail.contains("secret-token"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user