mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-03 08:17:44 +08:00
* feat(providers): support stable credential placeholders Add credentials[].stable_placeholder so a running client keeps using an externally rotated static credential through the reference it already holds. Squashed replay of the earlier four commits onto upstream main 71c3cd957; re-derives the frozen schema fingerprints and adapts the provider environment revision test to the added capability argument. Signed-off-by: Shiju <shiju@nvidia.com> * fix(e2e): apply stable-placeholder supervisor image override Use ManagedGateway's per-process supervisor image override so the fixture CA reaches the supervisor when runtime image environment variables take precedence over gateway TOML. Restore the wrapper's original image after each test, following the provider-readiness fixture. Signed-off-by: Shiju <shiju@nvidia.com> * test(server): update public RPC schema fingerprint after merging main Signed-off-by: Shiju <shiju@nvidia.com> --------- Signed-off-by: Shiju <shiju@nvidia.com>
2636 lines
95 KiB
Rust
2636 lines
95 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
#[path = "secrets_body.rs"]
|
|
pub mod body;
|
|
|
|
use crate::time::now_ms;
|
|
use base64::Engine as _;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fmt;
|
|
use std::sync::Arc;
|
|
|
|
const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:";
|
|
const PROVIDER_ALIAS_MARKER: &str = "OPENSHELL-RESOLVE-ENV-";
|
|
|
|
/// Public access to the placeholder prefix for fail-closed scanning in other modules.
|
|
pub const PLACEHOLDER_PREFIX_PUBLIC: &str = PLACEHOLDER_PREFIX;
|
|
pub const PROVIDER_ALIAS_MARKER_PUBLIC: &str = PROVIDER_ALIAS_MARKER;
|
|
/// Longest wire form of a reserved marker: percent-encoding expands every
|
|
/// marker byte to three bytes (`%XX`), and detection decodes in a single pass.
|
|
const LONGEST_RESERVED_MARKER_WIRE_BYTES: usize =
|
|
3 * if PLACEHOLDER_PREFIX.len() > PROVIDER_ALIAS_MARKER.len() {
|
|
PLACEHOLDER_PREFIX.len()
|
|
} else {
|
|
PROVIDER_ALIAS_MARKER.len()
|
|
};
|
|
|
|
/// Retain this many trailing bytes when scanning a streamed request body so a
|
|
/// reserved marker split across reads cannot be forwarded before detection.
|
|
///
|
|
/// A marker is only detected while all of its wire bytes sit in the scan buffer
|
|
/// at once, so the retained window must hold every byte of the longest form but
|
|
/// the last. A window shorter than that lets a caller split a fully
|
|
/// percent-encoded marker so its leading bytes are forwarded before the rest
|
|
/// arrives, and the reassembled remainder no longer decodes to the marker.
|
|
pub const CREDENTIAL_MARKER_SCAN_TAIL_BYTES: usize = LONGEST_RESERVED_MARKER_WIRE_BYTES;
|
|
|
|
/// Characters that are valid in an env var key name (used to extract
|
|
/// placeholder boundaries within concatenated strings like path segments).
|
|
fn is_env_key_char(b: u8) -> bool {
|
|
b.is_ascii_alphanumeric() || b == b'_'
|
|
}
|
|
|
|
fn is_alias_token_char(b: u8) -> bool {
|
|
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'~')
|
|
}
|
|
|
|
fn contains_raw_reserved_marker(value: &str) -> bool {
|
|
value.contains(PLACEHOLDER_PREFIX) || value.contains(PROVIDER_ALIAS_MARKER)
|
|
}
|
|
|
|
pub fn contains_reserved_credential_marker(value: &str) -> bool {
|
|
if contains_raw_reserved_marker(value) {
|
|
return true;
|
|
}
|
|
let decoded = percent_decode(value);
|
|
contains_raw_reserved_marker(&decoded)
|
|
}
|
|
|
|
/// Return whether an HTTP header value contains syntax reserved for credential
|
|
/// placeholder rewriting, including the decoded value of Basic authentication.
|
|
pub fn header_value_contains_reserved_credential_marker(value: &str) -> bool {
|
|
let trimmed = value.trim();
|
|
if contains_reserved_credential_marker(trimmed) {
|
|
return true;
|
|
}
|
|
|
|
let Some(decoded) = decode_basic_auth_value(trimmed) else {
|
|
return false;
|
|
};
|
|
contains_raw_reserved_marker(&decoded)
|
|
}
|
|
|
|
fn basic_auth_token(value: &str) -> Option<&str> {
|
|
value
|
|
.strip_prefix("Basic ")
|
|
.or_else(|| value.strip_prefix("basic "))
|
|
.map(str::trim)
|
|
}
|
|
|
|
fn decode_basic_auth_value(value: &str) -> Option<String> {
|
|
decode_basic_auth_token(basic_auth_token(value)?)
|
|
}
|
|
|
|
fn decode_basic_auth_token(encoded: &str) -> Option<String> {
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.ok()?;
|
|
String::from_utf8(decoded).ok()
|
|
}
|
|
|
|
pub fn contains_reserved_credential_marker_bytes(value: &[u8]) -> bool {
|
|
if value.is_empty() {
|
|
return false;
|
|
}
|
|
String::from_utf8_lossy(value)
|
|
.split('\0')
|
|
.any(contains_reserved_credential_marker)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error and result types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Error returned when a placeholder cannot be resolved or a resolved secret
|
|
/// contains prohibited characters.
|
|
#[derive(Debug, miette::Diagnostic)]
|
|
pub struct UnresolvedPlaceholderError {
|
|
pub location: &'static str, // "header", "query_param", "path"
|
|
reason: UnresolvedPlaceholderReason,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum UnresolvedPlaceholderReason {
|
|
Unavailable,
|
|
EndpointMismatch,
|
|
}
|
|
|
|
impl UnresolvedPlaceholderError {
|
|
#[must_use]
|
|
pub fn unavailable(location: &'static str) -> Self {
|
|
unresolved(location)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_endpoint_mismatch(&self) -> bool {
|
|
self.reason == UnresolvedPlaceholderReason::EndpointMismatch
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for UnresolvedPlaceholderError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
f,
|
|
"{} in {}",
|
|
match self.reason {
|
|
UnresolvedPlaceholderReason::Unavailable =>
|
|
"credential placeholder could not be resolved",
|
|
UnresolvedPlaceholderReason::EndpointMismatch =>
|
|
"credential is not authorized for the request endpoint",
|
|
},
|
|
self.location,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for UnresolvedPlaceholderError {}
|
|
|
|
fn unresolved(location: &'static str) -> UnresolvedPlaceholderError {
|
|
UnresolvedPlaceholderError {
|
|
location,
|
|
reason: UnresolvedPlaceholderReason::Unavailable,
|
|
}
|
|
}
|
|
|
|
/// Result of rewriting an HTTP header block with credential resolution.
|
|
#[derive(Debug)]
|
|
pub struct RewriteResult {
|
|
/// The rewritten HTTP bytes (headers + body overflow).
|
|
pub rewritten: Vec<u8>,
|
|
/// A redacted version of the request target for logging.
|
|
/// Contains `[CREDENTIAL]` in place of resolved credential values.
|
|
/// `None` if the target was not modified.
|
|
// Kept on the public result struct as part of the API contract; consumed
|
|
// selectively by callers that emit redacted logs.
|
|
#[allow(dead_code)]
|
|
pub redacted_target: Option<String>,
|
|
}
|
|
|
|
/// Result of rewriting a request target for OPA evaluation.
|
|
#[derive(Debug)]
|
|
pub struct RewriteTargetResult {
|
|
/// The resolved target (real secrets) — for upstream forwarding only.
|
|
pub resolved: String,
|
|
/// The redacted target (`[CREDENTIAL]` in place of secrets) — for OPA + logs.
|
|
pub redacted: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SecretResolver
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct SecretResolver {
|
|
by_placeholder: HashMap<String, SecretValue>,
|
|
denied_env_keys: HashSet<String>,
|
|
identity_bound_env_keys: HashSet<String>,
|
|
revision_fallback_allowed_revisions: HashMap<String, Arc<HashSet<u64>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SecretValue {
|
|
value: Arc<str>,
|
|
expires_at_ms: i64,
|
|
}
|
|
|
|
// Manual `Debug` impl: the auto-derived `Debug` would format the
|
|
// `by_placeholder` map, exposing both placeholder keys (which reveal which
|
|
// provider env var names are configured) and the resolved secret values
|
|
// themselves. Any accidental `{:?}` in a tracing call, or a
|
|
// derived `Debug` on a containing struct, would write secrets to logs.
|
|
//
|
|
// We expose only the count of registered placeholders without leaking anything.
|
|
impl fmt::Debug for SecretResolver {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("SecretResolver")
|
|
.field("placeholders", &self.by_placeholder.len())
|
|
.finish_non_exhaustive() // Use to show that the struct is not empty
|
|
}
|
|
}
|
|
|
|
impl SecretResolver {
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub fn from_provider_env(
|
|
provider_env: HashMap<String, String>,
|
|
) -> (HashMap<String, String>, Option<Self>) {
|
|
Self::from_provider_env_for_revision(provider_env, HashMap::new(), 0)
|
|
}
|
|
|
|
pub fn from_provider_env_for_revision(
|
|
provider_env: HashMap<String, String>,
|
|
credential_expires_at_ms: HashMap<String, i64>,
|
|
revision: u64,
|
|
) -> (HashMap<String, String>, Option<Self>) {
|
|
Self::from_provider_env_for_revision_with_current_aliases(
|
|
provider_env,
|
|
credential_expires_at_ms,
|
|
revision,
|
|
false,
|
|
)
|
|
}
|
|
|
|
pub fn from_provider_env_for_current_revision(
|
|
provider_env: HashMap<String, String>,
|
|
credential_expires_at_ms: HashMap<String, i64>,
|
|
revision: u64,
|
|
) -> (HashMap<String, String>, Option<Self>, Option<Self>) {
|
|
Self::from_provider_env_for_current_revision_with_stable_handles(
|
|
provider_env,
|
|
credential_expires_at_ms,
|
|
revision,
|
|
&HashMap::new(),
|
|
)
|
|
}
|
|
|
|
/// Build workload environment and resolver snapshots with gateway-issued
|
|
/// stable handles for selected credentials.
|
|
///
|
|
/// Stable credentials are registered only in the current resolver. Their
|
|
/// previous values never enter the bounded revision-generation queue, so a
|
|
/// revoked or replaced handle cannot resolve an older secret value.
|
|
pub(crate) fn from_provider_env_for_current_revision_with_stable_handles(
|
|
provider_env: HashMap<String, String>,
|
|
credential_expires_at_ms: HashMap<String, i64>,
|
|
revision: u64,
|
|
stable_handles: &HashMap<String, String>,
|
|
) -> (HashMap<String, String>, Option<Self>, Option<Self>) {
|
|
let mut child_env = HashMap::with_capacity(provider_env.len());
|
|
let mut generation_values = HashMap::new();
|
|
let mut current_values = HashMap::new();
|
|
|
|
for (key, value) in provider_env {
|
|
if uses_reserved_revision_namespace(&key) {
|
|
tracing::warn!(
|
|
provider_env_key = %key,
|
|
"skipping provider credential env var in reserved placeholder namespace"
|
|
);
|
|
continue;
|
|
}
|
|
let secret = SecretValue {
|
|
value: Arc::from(value),
|
|
expires_at_ms: credential_expires_at_ms
|
|
.get(&key)
|
|
.copied()
|
|
.unwrap_or_default(),
|
|
};
|
|
let placeholder = stable_handles.get(&key).map_or_else(
|
|
|| {
|
|
let placeholder = placeholder_for_env_key_for_revision(&key, revision);
|
|
if revision != 0 {
|
|
generation_values.insert(placeholder.clone(), secret.clone());
|
|
}
|
|
placeholder
|
|
},
|
|
|handle| placeholder_for_env_key_for_stable_handle(&key, handle),
|
|
);
|
|
child_env.insert(key.clone(), placeholder.clone());
|
|
current_values.insert(placeholder, secret.clone());
|
|
current_values.insert(placeholder_for_env_key(&key), secret);
|
|
}
|
|
|
|
let resolver = |by_placeholder: HashMap<String, SecretValue>| {
|
|
(!by_placeholder.is_empty()).then_some(Self {
|
|
by_placeholder,
|
|
denied_env_keys: HashSet::new(),
|
|
identity_bound_env_keys: HashSet::new(),
|
|
revision_fallback_allowed_revisions: HashMap::new(),
|
|
})
|
|
};
|
|
(
|
|
child_env,
|
|
resolver(generation_values),
|
|
resolver(current_values),
|
|
)
|
|
}
|
|
|
|
fn from_provider_env_for_revision_with_current_aliases(
|
|
provider_env: HashMap<String, String>,
|
|
credential_expires_at_ms: HashMap<String, i64>,
|
|
revision: u64,
|
|
include_current_aliases: bool,
|
|
) -> (HashMap<String, String>, Option<Self>) {
|
|
if provider_env.is_empty() {
|
|
return (HashMap::new(), None);
|
|
}
|
|
|
|
let mut child_env = HashMap::with_capacity(provider_env.len());
|
|
let mut by_placeholder = HashMap::with_capacity(provider_env.len());
|
|
|
|
for (key, value) in provider_env {
|
|
if uses_reserved_revision_namespace(&key) {
|
|
tracing::warn!(
|
|
provider_env_key = %key,
|
|
"skipping provider credential env var in reserved placeholder namespace"
|
|
);
|
|
continue;
|
|
}
|
|
let placeholder = placeholder_for_env_key_for_revision(&key, revision);
|
|
let secret = SecretValue {
|
|
value: Arc::from(value),
|
|
expires_at_ms: credential_expires_at_ms
|
|
.get(&key)
|
|
.copied()
|
|
.unwrap_or_default(),
|
|
};
|
|
child_env.insert(key.clone(), placeholder.clone());
|
|
by_placeholder.insert(placeholder, secret.clone());
|
|
if include_current_aliases && revision != 0 {
|
|
by_placeholder.insert(placeholder_for_env_key(&key), secret.clone());
|
|
}
|
|
}
|
|
|
|
if by_placeholder.is_empty() {
|
|
(child_env, None)
|
|
} else {
|
|
(
|
|
child_env,
|
|
Some(Self {
|
|
by_placeholder,
|
|
denied_env_keys: HashSet::new(),
|
|
identity_bound_env_keys: HashSet::new(),
|
|
revision_fallback_allowed_revisions: HashMap::new(),
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn merge<'a>(resolvers: impl IntoIterator<Item = &'a Self>) -> Option<Self> {
|
|
let mut by_placeholder = HashMap::new();
|
|
let mut denied_env_keys = HashSet::new();
|
|
let mut identity_bound_env_keys = HashSet::new();
|
|
let mut revision_fallback_allowed_revisions = HashMap::new();
|
|
for resolver in resolvers {
|
|
by_placeholder.extend(resolver.by_placeholder.clone());
|
|
denied_env_keys.extend(resolver.denied_env_keys.iter().cloned());
|
|
identity_bound_env_keys.extend(resolver.identity_bound_env_keys.iter().cloned());
|
|
revision_fallback_allowed_revisions
|
|
.extend(resolver.revision_fallback_allowed_revisions.clone());
|
|
}
|
|
if by_placeholder.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Self {
|
|
by_placeholder,
|
|
denied_env_keys,
|
|
identity_bound_env_keys,
|
|
revision_fallback_allowed_revisions,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Return a cheap endpoint-scoped resolver view.
|
|
///
|
|
/// Secret strings are shared through cloned resolver entries. Credential
|
|
/// keys listed in `bound_keys` resolve only when also present in
|
|
/// `allowed_bound_keys`; unbound provider configuration remains available.
|
|
#[must_use]
|
|
pub fn scoped_to_env_keys(
|
|
&self,
|
|
bound_keys: &HashSet<String>,
|
|
allowed_bound_keys: &HashSet<String>,
|
|
revision_fallback_allowed_revisions: HashMap<String, Arc<HashSet<u64>>>,
|
|
) -> Self {
|
|
let denied_env_keys = bound_keys
|
|
.difference(allowed_bound_keys)
|
|
.cloned()
|
|
.collect::<HashSet<_>>();
|
|
let by_placeholder = self
|
|
.by_placeholder
|
|
.iter()
|
|
.filter(|(placeholder, _)| {
|
|
placeholder_env_key(placeholder).is_none_or(|key| !denied_env_keys.contains(key))
|
|
})
|
|
.map(|(placeholder, secret)| (placeholder.clone(), secret.clone()))
|
|
.collect();
|
|
Self {
|
|
by_placeholder,
|
|
denied_env_keys,
|
|
identity_bound_env_keys: bound_keys.clone(),
|
|
revision_fallback_allowed_revisions,
|
|
}
|
|
}
|
|
|
|
fn unresolved_for(
|
|
&self,
|
|
location: &'static str,
|
|
placeholder: &str,
|
|
) -> UnresolvedPlaceholderError {
|
|
let reason = if placeholder_env_key(placeholder)
|
|
.is_some_and(|key| self.denied_env_keys.contains(key))
|
|
{
|
|
UnresolvedPlaceholderReason::EndpointMismatch
|
|
} else {
|
|
UnresolvedPlaceholderReason::Unavailable
|
|
};
|
|
UnresolvedPlaceholderError { location, reason }
|
|
}
|
|
|
|
/// Resolve a placeholder string to the real secret value.
|
|
///
|
|
/// Returns `None` if the placeholder is unknown or the resolved value
|
|
/// contains prohibited control characters (CRLF, null byte).
|
|
pub fn resolve_placeholder(&self, value: &str) -> Option<&str> {
|
|
// Normalize aliases before identity checks so a wrapped revision or stable
|
|
// handle retains the original endpoint, epoch, and expiration constraints.
|
|
let canonical_alias = alias_env_key(value).map(placeholder_for_env_key);
|
|
let value = canonical_alias.as_deref().unwrap_or(value);
|
|
if placeholder_env_key(value).is_some_and(|key| self.identity_bound_env_keys.contains(key))
|
|
&& revisioned_placeholder_parts(value).is_none()
|
|
&& stable_placeholder_parts(value).is_none()
|
|
{
|
|
// Unversioned placeholders carry no credential identity. Endpoint-bound
|
|
// request input must retain the issued revision or stable handle so a
|
|
// stale process cannot resolve a replacement provider's credential.
|
|
return None;
|
|
}
|
|
let secret = if let Some(secret) = self.by_placeholder.get(value) {
|
|
secret
|
|
} else {
|
|
// Once an old generation ages out, fall back by key to the current
|
|
// credential so long-running child processes survive provider
|
|
// credential refresh. For endpoint-bound credentials, permit that
|
|
// fallback only when the exact opaque revision belongs to the
|
|
// current provider-identity epoch.
|
|
let key = revisioned_placeholder_env_key(value)?;
|
|
if let Some((revision, key)) = revisioned_placeholder_parts(value)
|
|
&& self.identity_bound_env_keys.contains(key)
|
|
&& self
|
|
.revision_fallback_allowed_revisions
|
|
.get(key)
|
|
.is_none_or(|revisions| !revisions.contains(&revision))
|
|
{
|
|
return None;
|
|
}
|
|
let canonical = placeholder_for_env_key(key);
|
|
self.by_placeholder.get(&canonical)?
|
|
};
|
|
resolve_secret_value(secret)
|
|
}
|
|
|
|
/// Resolve the current value for an environment key selected by trusted
|
|
/// supervisor code.
|
|
///
|
|
/// Unlike [`Self::resolve_placeholder_checked`], this method accepts an
|
|
/// environment key rather than a user-provided placeholder token. Internal
|
|
/// request transforms such as `SigV4` can therefore select the endpoint-bound
|
|
/// current credential without making identityless placeholder aliases
|
|
/// available to sandbox request input.
|
|
pub fn resolve_current_env_key_checked(
|
|
&self,
|
|
key: &str,
|
|
location: &'static str,
|
|
) -> Result<Option<&str>, UnresolvedPlaceholderError> {
|
|
if self.denied_env_keys.contains(key) {
|
|
return Err(UnresolvedPlaceholderError {
|
|
location,
|
|
reason: UnresolvedPlaceholderReason::EndpointMismatch,
|
|
});
|
|
}
|
|
let placeholder = placeholder_for_env_key(key);
|
|
Ok(self
|
|
.by_placeholder
|
|
.get(&placeholder)
|
|
.and_then(resolve_secret_value))
|
|
}
|
|
|
|
/// Resolve a placeholder while preserving endpoint-denial information.
|
|
///
|
|
/// `None` means the credential is genuinely unavailable. An endpoint-bound
|
|
/// key denied by the scoped resolver returns a typed mismatch so callers
|
|
/// can emit the security denial instead of treating it as missing config.
|
|
pub fn resolve_placeholder_checked(
|
|
&self,
|
|
value: &str,
|
|
location: &'static str,
|
|
) -> Result<Option<&str>, UnresolvedPlaceholderError> {
|
|
if placeholder_env_key(value).is_some_and(|key| self.denied_env_keys.contains(key)) {
|
|
return Err(UnresolvedPlaceholderError {
|
|
location,
|
|
reason: UnresolvedPlaceholderReason::EndpointMismatch,
|
|
});
|
|
}
|
|
Ok(self.resolve_placeholder(value))
|
|
}
|
|
|
|
pub fn expires_at_ms_for_placeholder(&self, placeholder: &str) -> Option<i64> {
|
|
self.by_placeholder
|
|
.get(placeholder)
|
|
.map(|s| s.expires_at_ms)
|
|
}
|
|
|
|
pub fn rewrite_header_value(
|
|
&self,
|
|
value: &str,
|
|
) -> Result<Option<String>, UnresolvedPlaceholderError> {
|
|
// Direct placeholder match: `x-api-key: openshell:resolve:env:KEY`
|
|
if let Some(secret) = self.resolve_placeholder(value.trim()) {
|
|
return Ok(Some(secret.to_string()));
|
|
}
|
|
|
|
let trimmed = value.trim();
|
|
|
|
// Basic auth decoding: `Basic <base64>` where the decoded content
|
|
// contains a placeholder (e.g. `user:openshell:resolve:env:PASS`).
|
|
if let Some(encoded) = basic_auth_token(trimmed)
|
|
&& let Some(rewritten) = self.rewrite_basic_auth_token(encoded)?
|
|
{
|
|
return Ok(Some(format!("Basic {rewritten}")));
|
|
}
|
|
|
|
// Prefixed placeholder: `Bearer openshell:resolve:env:KEY`
|
|
let Some(split_at) = trimmed.find(char::is_whitespace) else {
|
|
if contains_reserved_credential_marker(trimmed) {
|
|
return Err(self.unresolved_for("header", trimmed));
|
|
}
|
|
return Ok(None);
|
|
};
|
|
let prefix = &trimmed[..split_at];
|
|
let candidate = trimmed[split_at..].trim();
|
|
if let Some(secret) = self.resolve_placeholder(candidate) {
|
|
return Ok(Some(format!("{prefix} {secret}")));
|
|
}
|
|
|
|
if contains_reserved_credential_marker(candidate) {
|
|
return Err(self.unresolved_for("header", candidate));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
pub fn rewrite_text_placeholders(
|
|
&self,
|
|
text: &mut String,
|
|
location: &'static str,
|
|
) -> Result<usize, UnresolvedPlaceholderError> {
|
|
if !contains_raw_reserved_marker(text) {
|
|
return Ok(0);
|
|
}
|
|
|
|
let mut rewritten = String::with_capacity(text.len());
|
|
let mut pos = 0;
|
|
let mut replacements = 0;
|
|
|
|
while pos < text.len() {
|
|
let next_canonical = text[pos..].find(PLACEHOLDER_PREFIX).map(|p| pos + p);
|
|
let next_alias = text[pos..].find(PROVIDER_ALIAS_MARKER).map(|marker_pos| {
|
|
let marker_abs = pos + marker_pos;
|
|
alias_start_for_marker(text, marker_abs)
|
|
});
|
|
let Some(abs_start) = [next_canonical, next_alias].into_iter().flatten().min() else {
|
|
rewritten.push_str(&text[pos..]);
|
|
break;
|
|
};
|
|
|
|
rewritten.push_str(&text[pos..abs_start]);
|
|
|
|
if text[abs_start..].starts_with(PLACEHOLDER_PREFIX) {
|
|
let Some((token_end, token)) = self.credential_token_at(text, abs_start) else {
|
|
return Err(unresolved(location));
|
|
};
|
|
let Some(secret) = self.resolve_placeholder(token) else {
|
|
return Err(self.unresolved_for(location, token));
|
|
};
|
|
rewritten.push_str(secret);
|
|
replacements += 1;
|
|
pos = token_end;
|
|
continue;
|
|
}
|
|
|
|
if let Some((token_end, token)) = alias_token_at(text, abs_start) {
|
|
let Some(secret) = self.resolve_placeholder(token) else {
|
|
return Err(self.unresolved_for(location, token));
|
|
};
|
|
rewritten.push_str(secret);
|
|
replacements += 1;
|
|
pos = token_end;
|
|
continue;
|
|
}
|
|
|
|
return Err(unresolved(location));
|
|
}
|
|
|
|
if contains_raw_reserved_marker(&rewritten) {
|
|
return Err(unresolved(location));
|
|
}
|
|
|
|
*text = rewritten;
|
|
Ok(replacements)
|
|
}
|
|
|
|
/// Rewrite credential placeholders inside a WebSocket text message.
|
|
///
|
|
/// The message is mutated only after all placeholders resolve
|
|
/// successfully. The return value is the number of replacements; callers
|
|
/// must not log the rewritten text.
|
|
pub fn rewrite_websocket_text_placeholders(
|
|
&self,
|
|
text: &mut String,
|
|
) -> Result<usize, UnresolvedPlaceholderError> {
|
|
self.rewrite_text_placeholders(text, "websocket")
|
|
}
|
|
|
|
fn credential_token_at<'a>(
|
|
&'a self,
|
|
text: &'a str,
|
|
abs_start: usize,
|
|
) -> Option<(usize, &'a str)> {
|
|
self.longest_known_token_match(text, abs_start)
|
|
.or_else(|| canonical_token_at(text, abs_start))
|
|
.or_else(|| alias_token_at(text, abs_start))
|
|
}
|
|
|
|
fn longest_known_token_match<'a>(
|
|
&'a self,
|
|
text: &str,
|
|
abs_start: usize,
|
|
) -> Option<(usize, &'a str)> {
|
|
let suffix = &text[abs_start..];
|
|
self.by_placeholder
|
|
.keys()
|
|
.filter_map(|placeholder| {
|
|
if !suffix.starts_with(placeholder) {
|
|
return None;
|
|
}
|
|
let key_end = abs_start + placeholder.len();
|
|
let boundary_ok = token_boundary_ok(text, abs_start, key_end, placeholder);
|
|
boundary_ok.then_some((key_end, placeholder.as_str()))
|
|
})
|
|
.max_by_key(|(_, placeholder)| placeholder.len())
|
|
}
|
|
|
|
/// Decode a Base64-encoded Basic auth token, resolve any placeholders in
|
|
/// the decoded `username:password` string, and re-encode.
|
|
///
|
|
/// Returns `None` if decoding fails or no placeholders are found.
|
|
fn rewrite_basic_auth_token(
|
|
&self,
|
|
encoded: &str,
|
|
) -> Result<Option<String>, UnresolvedPlaceholderError> {
|
|
let b64 = base64::engine::general_purpose::STANDARD;
|
|
let Some(decoded) = decode_basic_auth_token(encoded.trim()) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if !contains_raw_reserved_marker(&decoded) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut rewritten = decoded;
|
|
let replacements = self.rewrite_text_placeholders(&mut rewritten, "header")?;
|
|
|
|
if replacements == 0 {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(b64.encode(rewritten.as_bytes())))
|
|
}
|
|
}
|
|
|
|
fn resolve_secret_value(secret: &SecretValue) -> Option<&str> {
|
|
if secret.expires_at_ms > 0 && secret.expires_at_ms <= now_ms() {
|
|
tracing::warn!(
|
|
location = "resolve_placeholder",
|
|
"credential resolution rejected: credential is expired"
|
|
);
|
|
return None;
|
|
}
|
|
match validate_resolved_secret(secret.value.as_ref()) {
|
|
Ok(s) => Some(s),
|
|
Err(reason) => {
|
|
tracing::warn!(
|
|
location = "resolve_placeholder",
|
|
reason,
|
|
"credential resolution rejected: resolved value contains prohibited characters"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn alias_start_for_marker(text: &str, marker_abs: usize) -> usize {
|
|
let mut start = marker_abs;
|
|
let bytes = text.as_bytes();
|
|
while start > 0 && is_alias_token_char(bytes[start - 1]) {
|
|
start -= 1;
|
|
}
|
|
start
|
|
}
|
|
|
|
fn canonical_token_at(text: &str, abs_start: usize) -> Option<(usize, &str)> {
|
|
if !text[abs_start..].starts_with(PLACEHOLDER_PREFIX) {
|
|
return None;
|
|
}
|
|
let key_start = abs_start + PLACEHOLDER_PREFIX.len();
|
|
let key_end = text[key_start..]
|
|
.bytes()
|
|
.position(|b| !is_env_key_char(b))
|
|
.map_or(text.len(), |p| key_start + p);
|
|
(key_end > key_start).then_some((key_end, &text[abs_start..key_end]))
|
|
}
|
|
|
|
fn alias_token_at(text: &str, abs_start: usize) -> Option<(usize, &str)> {
|
|
let suffix = &text[abs_start..];
|
|
let marker_rel = suffix.find(PROVIDER_ALIAS_MARKER)?;
|
|
if marker_rel == 0 {
|
|
return None;
|
|
}
|
|
let key_start = abs_start + marker_rel + PROVIDER_ALIAS_MARKER.len();
|
|
let key_end = text[key_start..]
|
|
.bytes()
|
|
.position(|b| !is_env_key_char(b))
|
|
.map_or(text.len(), |p| key_start + p);
|
|
if key_end == key_start {
|
|
return None;
|
|
}
|
|
let before_ok = abs_start == 0 || !is_alias_token_char(text.as_bytes()[abs_start - 1]);
|
|
let after_ok = key_end == text.len() || !is_alias_token_char(text.as_bytes()[key_end]);
|
|
(before_ok && after_ok).then_some((key_end, &text[abs_start..key_end]))
|
|
}
|
|
|
|
fn alias_env_key(token: &str) -> Option<&str> {
|
|
let marker_start = token.find(PROVIDER_ALIAS_MARKER)?;
|
|
if marker_start == 0 {
|
|
return None;
|
|
}
|
|
if !token[..marker_start].bytes().all(is_alias_token_char) {
|
|
return None;
|
|
}
|
|
let key_start = marker_start + PROVIDER_ALIAS_MARKER.len();
|
|
let key_end = token[key_start..]
|
|
.bytes()
|
|
.position(|b| !is_env_key_char(b))
|
|
.map_or(token.len(), |p| key_start + p);
|
|
(key_end == token.len() && key_end > key_start).then_some(&token[key_start..key_end])
|
|
}
|
|
|
|
fn revisioned_placeholder_env_key(token: &str) -> Option<&str> {
|
|
revisioned_placeholder_parts(token).map(|(_, key)| key)
|
|
}
|
|
|
|
fn revisioned_placeholder_parts(token: &str) -> Option<(u64, &str)> {
|
|
let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?;
|
|
let (revision, key) = split_revisioned_env_key(suffix)?;
|
|
Some((revision.parse().ok()?, key))
|
|
}
|
|
|
|
fn stable_placeholder_parts(token: &str) -> Option<(&str, &str)> {
|
|
let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?;
|
|
let (handle, key) = split_stable_env_key(suffix)?;
|
|
Some((handle, key))
|
|
}
|
|
|
|
fn placeholder_env_key(token: &str) -> Option<&str> {
|
|
stable_placeholder_parts(token)
|
|
.map(|(_, key)| key)
|
|
.or_else(|| revisioned_placeholder_env_key(token))
|
|
.or_else(|| token.strip_prefix(PLACEHOLDER_PREFIX))
|
|
.or_else(|| {
|
|
alias_env_key(token).map(|suffix| {
|
|
split_stable_env_key(suffix)
|
|
.or_else(|| split_revisioned_env_key(suffix))
|
|
.map_or(suffix, |(_, key)| key)
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn uses_reserved_revision_namespace(key: &str) -> bool {
|
|
split_revisioned_env_key(key).is_some() || split_stable_env_key(key).is_some()
|
|
}
|
|
|
|
fn split_revisioned_env_key(key: &str) -> Option<(&str, &str)> {
|
|
let suffix = key.strip_prefix('v')?;
|
|
let (revision, env_key) = suffix.split_once('_')?;
|
|
if revision.is_empty()
|
|
|| !revision.bytes().all(|b| b.is_ascii_digit())
|
|
|| env_key.is_empty()
|
|
|| !env_key.bytes().all(is_env_key_char)
|
|
{
|
|
return None;
|
|
}
|
|
Some((revision, env_key))
|
|
}
|
|
|
|
fn split_stable_env_key(key: &str) -> Option<(&str, &str)> {
|
|
let suffix = key.strip_prefix('s')?;
|
|
let (handle, env_key) = suffix.split_once('_')?;
|
|
if handle.len() != 64
|
|
|| !handle
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
|
|| env_key.is_empty()
|
|
|| !env_key.bytes().all(is_env_key_char)
|
|
{
|
|
return None;
|
|
}
|
|
Some((handle, env_key))
|
|
}
|
|
|
|
fn token_boundary_ok(text: &str, abs_start: usize, token_end: usize, token: &str) -> bool {
|
|
if token.starts_with(PLACEHOLDER_PREFIX) {
|
|
return token_end == text.len()
|
|
|| !is_env_key_char(text.as_bytes()[token_end])
|
|
|| text[token_end..].starts_with(PLACEHOLDER_PREFIX);
|
|
}
|
|
let before_ok = abs_start == 0 || !is_alias_token_char(text.as_bytes()[abs_start - 1]);
|
|
let after_ok = token_end == text.len() || !is_alias_token_char(text.as_bytes()[token_end]);
|
|
before_ok && after_ok
|
|
}
|
|
|
|
pub fn placeholder_for_env_key(key: &str) -> String {
|
|
format!("{PLACEHOLDER_PREFIX}{key}")
|
|
}
|
|
|
|
pub fn placeholder_for_env_key_for_revision(key: &str, revision: u64) -> String {
|
|
if revision == 0 {
|
|
placeholder_for_env_key(key)
|
|
} else {
|
|
format!("{PLACEHOLDER_PREFIX}v{revision}_{key}")
|
|
}
|
|
}
|
|
|
|
pub(crate) fn placeholder_for_env_key_for_stable_handle(key: &str, handle: &str) -> String {
|
|
format!("{PLACEHOLDER_PREFIX}s{handle}_{key}")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secret validation (F1 — CWE-113)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Validate that a resolved secret value does not contain characters that
|
|
/// could enable HTTP header injection or request splitting.
|
|
fn validate_resolved_secret(value: &str) -> Result<&str, &'static str> {
|
|
if value
|
|
.bytes()
|
|
.any(|b| b == b'\r' || b == b'\n' || b == b'\0')
|
|
{
|
|
return Err("resolved secret contains prohibited control characters (CR, LF, or NUL)");
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Percent encoding/decoding (RFC 3986)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Percent-encode a string for safe use in URL query parameter values.
|
|
///
|
|
/// Encodes all characters except unreserved characters (RFC 3986 Section 2.3):
|
|
/// ALPHA / DIGIT / "-" / "." / "_" / "~"
|
|
fn percent_encode_query(input: &str) -> String {
|
|
let mut encoded = String::with_capacity(input.len());
|
|
for byte in input.bytes() {
|
|
match byte {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
|
encoded.push(byte as char);
|
|
}
|
|
_ => {
|
|
use fmt::Write;
|
|
let _ = write!(encoded, "%{byte:02X}");
|
|
}
|
|
}
|
|
}
|
|
encoded
|
|
}
|
|
|
|
/// Percent-encode a string for safe use in URL path segments.
|
|
///
|
|
/// RFC 3986 §3.3: pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
|
|
/// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
|
|
///
|
|
/// Must encode: `/`, `?`, `#`, space, and other non-pchar characters.
|
|
fn percent_encode_path_segment(input: &str) -> String {
|
|
let mut encoded = String::with_capacity(input.len());
|
|
for byte in input.bytes() {
|
|
match byte {
|
|
// unreserved + sub-delims + ":" + "@"
|
|
b'A'..=b'Z'
|
|
| b'a'..=b'z'
|
|
| b'0'..=b'9'
|
|
| b'-'
|
|
| b'.'
|
|
| b'_'
|
|
| b'~'
|
|
| b'!'
|
|
| b'$'
|
|
| b'&'
|
|
| b'\''
|
|
| b'('
|
|
| b')'
|
|
| b'*'
|
|
| b'+'
|
|
| b','
|
|
| b';'
|
|
| b'='
|
|
| b':'
|
|
| b'@' => {
|
|
encoded.push(byte as char);
|
|
}
|
|
_ => {
|
|
use fmt::Write;
|
|
let _ = write!(encoded, "%{byte:02X}");
|
|
}
|
|
}
|
|
}
|
|
encoded
|
|
}
|
|
|
|
/// Percent-decode a URL-encoded string.
|
|
fn percent_decode(input: &str) -> String {
|
|
let mut decoded = Vec::with_capacity(input.len());
|
|
let mut bytes = input.bytes();
|
|
while let Some(b) = bytes.next() {
|
|
if b == b'%' {
|
|
let hi = bytes.next();
|
|
let lo = bytes.next();
|
|
if let (Some(h), Some(l)) = (hi, lo) {
|
|
let hex = [h, l];
|
|
if let Ok(s) = std::str::from_utf8(&hex)
|
|
&& let Ok(val) = u8::from_str_radix(s, 16)
|
|
{
|
|
decoded.push(val);
|
|
continue;
|
|
}
|
|
// Invalid percent encoding — preserve verbatim
|
|
decoded.push(b'%');
|
|
decoded.push(h);
|
|
decoded.push(l);
|
|
} else {
|
|
decoded.push(b'%');
|
|
if let Some(h) = hi {
|
|
decoded.push(h);
|
|
}
|
|
}
|
|
} else {
|
|
decoded.push(b);
|
|
}
|
|
}
|
|
String::from_utf8_lossy(&decoded).into_owned()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path credential validation (F3 — CWE-22)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Validate that a resolved credential value is safe for use in a URL path segment.
|
|
///
|
|
/// Operates on the raw (decoded) credential value before percent-encoding.
|
|
/// Rejects values that could enable path traversal, request splitting, or
|
|
/// URI structure breakage.
|
|
fn validate_credential_for_path(value: &str) -> Result<(), String> {
|
|
if value.contains("../") || value.contains("..\\") || value == ".." {
|
|
return Err("credential contains path traversal sequence".into());
|
|
}
|
|
if value.contains('\0') || value.contains('\r') || value.contains('\n') {
|
|
return Err("credential contains control character".into());
|
|
}
|
|
if value.contains('/') || value.contains('\\') {
|
|
return Err("credential contains path separator".into());
|
|
}
|
|
if value.contains('?') || value.contains('#') {
|
|
return Err("credential contains URI delimiter".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URI rewriting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Result of rewriting the request line.
|
|
struct RewriteLineResult {
|
|
/// The rewritten request line.
|
|
line: String,
|
|
/// Redacted target for logging (if any rewriting occurred).
|
|
redacted_target: Option<String>,
|
|
}
|
|
|
|
/// Rewrite credential placeholders in the request line's URL.
|
|
///
|
|
/// Given a request line like `GET /bot{TOKEN}/path?key={APIKEY} HTTP/1.1`,
|
|
/// resolves placeholders in both path segments and query parameter values.
|
|
// `resolver` (the credential resolver) and `resolved` (the resolved string
|
|
// output) are intentionally distinct nouns; renaming would obscure intent.
|
|
#[allow(clippy::similar_names)]
|
|
fn rewrite_request_line(
|
|
line: &str,
|
|
resolver: &SecretResolver,
|
|
) -> Result<RewriteLineResult, UnresolvedPlaceholderError> {
|
|
// Request line format: METHOD SP REQUEST-URI SP HTTP-VERSION
|
|
let mut parts = line.splitn(3, ' ');
|
|
let unchanged = || {
|
|
Ok(RewriteLineResult {
|
|
line: line.to_string(),
|
|
redacted_target: None,
|
|
})
|
|
};
|
|
let Some(method) = parts.next() else {
|
|
return unchanged();
|
|
};
|
|
let Some(uri) = parts.next() else {
|
|
return unchanged();
|
|
};
|
|
let Some(version) = parts.next() else {
|
|
return unchanged();
|
|
};
|
|
|
|
// Only rewrite if the URI contains a placeholder or a provider-shaped
|
|
// credential alias, including percent-encoded canonical placeholders.
|
|
if !contains_reserved_credential_marker(uri) {
|
|
return unchanged();
|
|
}
|
|
|
|
// Split URI into path and query
|
|
let (path, query) = match uri.split_once('?') {
|
|
Some((p, q)) => (p, Some(q)),
|
|
None => (uri, None),
|
|
};
|
|
|
|
// Rewrite path segments
|
|
let (resolved_path, redacted_path) = match rewrite_uri_path(path, resolver)? {
|
|
Some((resolved, redacted)) => (resolved, redacted),
|
|
None => (path.to_string(), path.to_string()),
|
|
};
|
|
|
|
// Rewrite query params
|
|
let (resolved_query, redacted_query) = match query {
|
|
Some(q) => match rewrite_uri_query_params(q, resolver)? {
|
|
Some((resolved, redacted)) => (Some(resolved), Some(redacted)),
|
|
None => (Some(q.to_string()), Some(q.to_string())),
|
|
},
|
|
None => (None, None),
|
|
};
|
|
|
|
// Reassemble
|
|
let resolved_uri = if let Some(q) = resolved_query.as_ref() {
|
|
format!("{resolved_path}?{q}")
|
|
} else {
|
|
resolved_path
|
|
};
|
|
let redacted_uri = match &redacted_query {
|
|
Some(q) => format!("{redacted_path}?{q}"),
|
|
None => redacted_path,
|
|
};
|
|
|
|
Ok(RewriteLineResult {
|
|
line: format!("{method} {resolved_uri} {version}"),
|
|
redacted_target: Some(redacted_uri),
|
|
})
|
|
}
|
|
|
|
/// Rewrite placeholders in URL path segments.
|
|
///
|
|
/// Handles substring matching for cases like Telegram's `/bot{TOKEN}/method`
|
|
/// where the placeholder is concatenated with literal text in a segment.
|
|
///
|
|
/// Returns `Some((resolved_path, redacted_path))` if any placeholders were found,
|
|
/// `None` if no placeholders exist in the path.
|
|
// `resolver` and `resolved` are intentionally distinct nouns; see comment at
|
|
// `rewrite_request_line`.
|
|
#[allow(clippy::similar_names)]
|
|
fn rewrite_uri_path(
|
|
path: &str,
|
|
resolver: &SecretResolver,
|
|
) -> Result<Option<(String, String)>, UnresolvedPlaceholderError> {
|
|
let segments: Vec<&str> = path.split('/').collect();
|
|
let mut resolved_segments = Vec::with_capacity(segments.len());
|
|
let mut redacted_segments = Vec::with_capacity(segments.len());
|
|
let mut any_rewritten = false;
|
|
|
|
for segment in &segments {
|
|
let decoded = percent_decode(segment);
|
|
if !contains_raw_reserved_marker(&decoded) {
|
|
resolved_segments.push(segment.to_string());
|
|
redacted_segments.push(segment.to_string());
|
|
continue;
|
|
}
|
|
|
|
let (resolved, redacted) = rewrite_path_segment(&decoded, resolver)?;
|
|
// Percent-encode the resolved segment for path context
|
|
resolved_segments.push(percent_encode_path_segment(&resolved));
|
|
redacted_segments.push(redacted);
|
|
any_rewritten = true;
|
|
}
|
|
|
|
if !any_rewritten {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some((
|
|
resolved_segments.join("/"),
|
|
redacted_segments.join("/"),
|
|
)))
|
|
}
|
|
|
|
/// Rewrite placeholders within a single path segment (already percent-decoded).
|
|
///
|
|
/// Uses the placeholder grammar `openshell:resolve:env:[A-Za-z_][A-Za-z0-9_]*`
|
|
/// to determine placeholder boundaries within concatenated text.
|
|
// `resolver` and `resolved` are intentionally distinct nouns; see comment at
|
|
// `rewrite_request_line`.
|
|
#[allow(clippy::similar_names)]
|
|
fn rewrite_path_segment(
|
|
segment: &str,
|
|
resolver: &SecretResolver,
|
|
) -> Result<(String, String), UnresolvedPlaceholderError> {
|
|
let mut resolved = String::with_capacity(segment.len());
|
|
let mut redacted = String::with_capacity(segment.len());
|
|
let mut pos = 0;
|
|
let bytes = segment.as_bytes();
|
|
|
|
while pos < bytes.len() {
|
|
let next_canonical = segment[pos..].find(PLACEHOLDER_PREFIX).map(|p| pos + p);
|
|
let next_alias = segment[pos..]
|
|
.find(PROVIDER_ALIAS_MARKER)
|
|
.map(|marker_pos| {
|
|
let marker_abs = pos + marker_pos;
|
|
alias_start_for_marker(segment, marker_abs)
|
|
});
|
|
if let Some(abs_start) = [next_canonical, next_alias].into_iter().flatten().min() {
|
|
// Copy literal prefix before the placeholder
|
|
resolved.push_str(&segment[pos..abs_start]);
|
|
redacted.push_str(&segment[pos..abs_start]);
|
|
|
|
let Some((token_end, full_placeholder)) = canonical_token_at(segment, abs_start)
|
|
.or_else(|| alias_token_at(segment, abs_start))
|
|
else {
|
|
return Err(unresolved("path"));
|
|
};
|
|
if let Some(secret) = resolver.resolve_placeholder(full_placeholder) {
|
|
validate_credential_for_path(secret).map_err(|reason| {
|
|
tracing::warn!(
|
|
location = "path",
|
|
%reason,
|
|
"credential resolution rejected: resolved value unsafe for path"
|
|
);
|
|
unresolved("path")
|
|
})?;
|
|
resolved.push_str(secret);
|
|
redacted.push_str("[CREDENTIAL]");
|
|
} else {
|
|
return Err(resolver.unresolved_for("path", full_placeholder));
|
|
}
|
|
pos = token_end;
|
|
} else {
|
|
// No more placeholders in remainder
|
|
resolved.push_str(&segment[pos..]);
|
|
redacted.push_str(&segment[pos..]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok((resolved, redacted))
|
|
}
|
|
|
|
/// Rewrite placeholders in query parameter values.
|
|
///
|
|
/// Returns `Some((resolved_query, redacted_query))` if any placeholders were found.
|
|
fn rewrite_uri_query_params(
|
|
query: &str,
|
|
resolver: &SecretResolver,
|
|
) -> Result<Option<(String, String)>, UnresolvedPlaceholderError> {
|
|
if !contains_reserved_credential_marker(query) {
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut resolved_params = Vec::new();
|
|
let mut redacted_params = Vec::new();
|
|
let mut any_rewritten = false;
|
|
|
|
for param in query.split('&') {
|
|
if let Some((key, value)) = param.split_once('=') {
|
|
let decoded_value = percent_decode(value);
|
|
if contains_raw_reserved_marker(&decoded_value) {
|
|
let mut rewritten = decoded_value.clone();
|
|
let replacements =
|
|
resolver.rewrite_text_placeholders(&mut rewritten, "query_param")?;
|
|
if replacements == 0 || contains_raw_reserved_marker(&rewritten) {
|
|
return Err(unresolved("query_param"));
|
|
}
|
|
resolved_params.push(format!("{key}={}", percent_encode_query(&rewritten)));
|
|
redacted_params.push(format!("{key}=[CREDENTIAL]"));
|
|
any_rewritten = true;
|
|
} else {
|
|
resolved_params.push(param.to_string());
|
|
redacted_params.push(param.to_string());
|
|
}
|
|
} else {
|
|
resolved_params.push(param.to_string());
|
|
redacted_params.push(param.to_string());
|
|
}
|
|
}
|
|
|
|
if !any_rewritten {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some((resolved_params.join("&"), redacted_params.join("&"))))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public rewrite API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Rewrite credential placeholders in an HTTP header block.
|
|
///
|
|
/// Resolves `openshell:resolve:env:*` placeholders in the request line
|
|
/// (path segments and query parameter values), header values (including
|
|
/// Basic auth tokens), and returns a `RewriteResult` with the rewritten
|
|
/// bytes and a redacted target for logging.
|
|
///
|
|
/// Returns `Err` if any placeholder is detected but cannot be resolved
|
|
/// (fail-closed behavior).
|
|
pub fn rewrite_http_header_block(
|
|
raw: &[u8],
|
|
resolver: Option<&SecretResolver>,
|
|
) -> Result<RewriteResult, UnresolvedPlaceholderError> {
|
|
let Some(resolver) = resolver else {
|
|
// Fail-closed: with no resolver there is nothing that can rewrite a
|
|
// credential placeholder, so forwarding the block verbatim would leak
|
|
// the reserved token to the upstream. Scan the header region (mirroring
|
|
// the resolved-path scan below) and reject if any reserved marker is
|
|
// present. Marker-free traffic still passes through unchanged, which is
|
|
// the intended behavior when the endpoint injects no credentials.
|
|
let scan_end = raw
|
|
.windows(4)
|
|
.position(|w| w == b"\r\n\r\n")
|
|
.map_or(raw.len(), |p| raw.len().min(p + 4 + 256));
|
|
let header_region = String::from_utf8_lossy(&raw[..scan_end]);
|
|
if contains_reserved_credential_marker(&header_region) {
|
|
return Err(unresolved("header"));
|
|
}
|
|
return Ok(RewriteResult {
|
|
rewritten: raw.to_vec(),
|
|
redacted_target: None,
|
|
});
|
|
};
|
|
|
|
let Some(header_end) = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) else {
|
|
return Ok(RewriteResult {
|
|
rewritten: raw.to_vec(),
|
|
redacted_target: None,
|
|
});
|
|
};
|
|
|
|
let header_str = String::from_utf8_lossy(&raw[..header_end]);
|
|
let mut lines = header_str.split("\r\n");
|
|
let Some(request_line) = lines.next() else {
|
|
return Ok(RewriteResult {
|
|
rewritten: raw.to_vec(),
|
|
redacted_target: None,
|
|
});
|
|
};
|
|
|
|
// Rewrite request line (path + query params)
|
|
let rl_result = rewrite_request_line(request_line, resolver)?;
|
|
|
|
let mut output = Vec::with_capacity(raw.len());
|
|
output.extend_from_slice(rl_result.line.as_bytes());
|
|
output.extend_from_slice(b"\r\n");
|
|
|
|
for line in lines {
|
|
if line.is_empty() {
|
|
break;
|
|
}
|
|
|
|
output.extend_from_slice(rewrite_header_line_checked(line, resolver)?.as_bytes());
|
|
output.extend_from_slice(b"\r\n");
|
|
}
|
|
|
|
output.extend_from_slice(b"\r\n");
|
|
output.extend_from_slice(&raw[header_end..]);
|
|
|
|
// Fail-closed scan: check for any remaining unresolved placeholders or
|
|
// provider-shaped aliases in both raw and percent-decoded header bytes.
|
|
let output_header = String::from_utf8_lossy(&output[..output.len().min(header_end + 256)]);
|
|
if contains_reserved_credential_marker(&output_header) {
|
|
return Err(unresolved("header"));
|
|
}
|
|
|
|
Ok(RewriteResult {
|
|
rewritten: output,
|
|
redacted_target: rl_result.redacted_target,
|
|
})
|
|
}
|
|
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub fn rewrite_header_line(line: &str, resolver: &SecretResolver) -> String {
|
|
rewrite_header_line_checked(line, resolver).unwrap_or_else(|_| line.to_string())
|
|
}
|
|
|
|
pub fn rewrite_header_line_checked(
|
|
line: &str,
|
|
resolver: &SecretResolver,
|
|
) -> Result<String, UnresolvedPlaceholderError> {
|
|
let Some((name, value)) = line.split_once(':') else {
|
|
return Ok(line.to_string());
|
|
};
|
|
|
|
resolver.rewrite_header_value(value.trim())?.map_or_else(
|
|
|| Ok(line.to_string()),
|
|
|rewritten| Ok(format!("{name}: {rewritten}")),
|
|
)
|
|
}
|
|
|
|
/// Resolve placeholders in a request target (path + query) for OPA evaluation.
|
|
///
|
|
/// Returns the resolved target (real secrets, for upstream) and a redacted
|
|
/// version (`[CREDENTIAL]` in place of secrets, for OPA input and logs).
|
|
// `resolver` and `resolved` are intentionally distinct nouns; see comment at
|
|
// `rewrite_request_line`.
|
|
#[allow(clippy::similar_names)]
|
|
pub fn rewrite_target_for_eval(
|
|
target: &str,
|
|
resolver: &SecretResolver,
|
|
) -> Result<RewriteTargetResult, UnresolvedPlaceholderError> {
|
|
if !contains_reserved_credential_marker(target) {
|
|
return Ok(RewriteTargetResult {
|
|
resolved: target.to_string(),
|
|
redacted: target.to_string(),
|
|
});
|
|
}
|
|
|
|
let (path, query) = match target.split_once('?') {
|
|
Some((p, q)) => (p, Some(q)),
|
|
None => (target, None),
|
|
};
|
|
|
|
// Rewrite path
|
|
let (resolved_path, redacted_path) = match rewrite_uri_path(path, resolver)? {
|
|
Some((resolved, redacted)) => (resolved, redacted),
|
|
None => (path.to_string(), path.to_string()),
|
|
};
|
|
|
|
// Rewrite query
|
|
let (resolved_query, redacted_query) = match query {
|
|
Some(q) => match rewrite_uri_query_params(q, resolver)? {
|
|
Some((resolved, redacted)) => (Some(resolved), Some(redacted)),
|
|
None => (Some(q.to_string()), Some(q.to_string())),
|
|
},
|
|
None => (None, None),
|
|
};
|
|
|
|
let resolved = match &resolved_query {
|
|
Some(q) => format!("{resolved_path}?{q}"),
|
|
None => resolved_path,
|
|
};
|
|
let redacted = match &redacted_query {
|
|
Some(q) => format!("{redacted_path}?{q}"),
|
|
None => redacted_path,
|
|
};
|
|
|
|
Ok(RewriteTargetResult { resolved, redacted })
|
|
}
|
|
|
|
/// Produce the policy/logging representation of a request target without
|
|
/// consulting or materializing credential values.
|
|
///
|
|
/// This validates placeholder syntax using the same URI-aware rewrite path as
|
|
/// upstream injection, but resolves every referenced key to a fixed redaction
|
|
/// marker. Callers can therefore select routes and evaluate policy before the
|
|
/// real endpoint-scoped resolver is touched.
|
|
pub fn redact_target_for_policy(target: &str) -> Result<String, UnresolvedPlaceholderError> {
|
|
if !contains_reserved_credential_marker(target) {
|
|
return Ok(target.to_string());
|
|
}
|
|
|
|
let decoded = percent_decode(target);
|
|
let mut provider_env = HashMap::new();
|
|
let mut pos = 0;
|
|
while pos < decoded.len() {
|
|
let next_canonical = decoded[pos..].find(PLACEHOLDER_PREFIX).map(|p| pos + p);
|
|
let next_alias = decoded[pos..]
|
|
.find(PROVIDER_ALIAS_MARKER)
|
|
.map(|marker_pos| alias_start_for_marker(&decoded, pos + marker_pos));
|
|
let Some(start) = [next_canonical, next_alias].into_iter().flatten().min() else {
|
|
break;
|
|
};
|
|
let Some((end, token)) =
|
|
canonical_token_at(&decoded, start).or_else(|| alias_token_at(&decoded, start))
|
|
else {
|
|
return Err(unresolved("request_target"));
|
|
};
|
|
let Some(key) = placeholder_env_key(token) else {
|
|
return Err(unresolved("request_target"));
|
|
};
|
|
provider_env.insert(key.to_string(), "[CREDENTIAL]".to_string());
|
|
pos = end;
|
|
}
|
|
|
|
let (_, resolver) = SecretResolver::from_provider_env(provider_env);
|
|
let Some(resolver) = resolver else {
|
|
return Err(unresolved("request_target"));
|
|
};
|
|
rewrite_target_for_eval(target, &resolver).map(|result| result.redacted)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
#[allow(
|
|
clippy::iter_on_single_items,
|
|
reason = "Test code: single-key fixtures are clearer as array literals than std::iter::once."
|
|
)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// === Existing tests (preserved) ===
|
|
|
|
#[test]
|
|
fn byte_marker_detection_handles_raw_encoded_and_binary_input() {
|
|
assert!(contains_reserved_credential_marker_bytes(
|
|
b"openshell:resolve:env:API_TOKEN"
|
|
));
|
|
assert!(contains_reserved_credential_marker_bytes(
|
|
b"openshell%3Aresolve%3Aenv%3AAPI_TOKEN"
|
|
));
|
|
assert!(!contains_reserved_credential_marker_bytes(&[
|
|
0xff, 0x00, 0x01, 0x02
|
|
]));
|
|
}
|
|
|
|
#[test]
|
|
fn header_value_marker_detection_covers_rewrite_forms() {
|
|
let basic =
|
|
base64::engine::general_purpose::STANDARD.encode("user:openshell:resolve:env:API_KEY");
|
|
|
|
for value in [
|
|
"openshell:resolve:env:API_KEY".to_string(),
|
|
"Bearer openshell:resolve:env:API_KEY".to_string(),
|
|
"provider-OPENSHELL-RESOLVE-ENV-API_KEY".to_string(),
|
|
"openshell%3Aresolve%3Aenv%3AAPI_KEY".to_string(),
|
|
format!("Basic {basic}"),
|
|
] {
|
|
assert!(
|
|
header_value_contains_reserved_credential_marker(&value),
|
|
"reserved credential marker in {value:?}"
|
|
);
|
|
}
|
|
assert!(!header_value_contains_reserved_credential_marker(
|
|
"Bearer ordinary-token"
|
|
));
|
|
}
|
|
|
|
fn fully_percent_encoded(marker: &str) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
|
let mut encoded = String::with_capacity(marker.len() * 3);
|
|
for byte in marker.bytes() {
|
|
encoded.push('%');
|
|
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
|
|
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
|
}
|
|
encoded
|
|
}
|
|
|
|
#[test]
|
|
fn scan_tail_window_covers_longest_encoded_marker_form() {
|
|
for marker in [PLACEHOLDER_PREFIX, PROVIDER_ALIAS_MARKER] {
|
|
let encoded = fully_percent_encoded(marker);
|
|
assert!(
|
|
contains_reserved_credential_marker(&encoded),
|
|
"fully encoded {marker} must be detected"
|
|
);
|
|
assert!(
|
|
CREDENTIAL_MARKER_SCAN_TAIL_BYTES >= encoded.len() - 1,
|
|
"scan window must retain every byte of {encoded} but the last"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_env_is_replaced_with_placeholders() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
assert_eq!(
|
|
child_env.get("ANTHROPIC_API_KEY"),
|
|
Some(&"openshell:resolve:env:ANTHROPIC_API_KEY".to_string())
|
|
);
|
|
assert_eq!(
|
|
resolver
|
|
.as_ref()
|
|
.and_then(|resolver| resolver
|
|
.resolve_placeholder("openshell:resolve:env:ANTHROPIC_API_KEY")),
|
|
Some("sk-test")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_env_rejects_revision_namespace_keys() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("v10_GITHUB_TOKEN".to_string(), "ambiguous".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
assert!(child_env.is_empty());
|
|
assert!(resolver.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_revision_namespace_requires_version_and_key() {
|
|
assert!(uses_reserved_revision_namespace("v10_GITHUB_TOKEN"));
|
|
assert!(uses_reserved_revision_namespace("v999999_very_unlikely"));
|
|
assert!(uses_reserved_revision_namespace(
|
|
"saaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_GITHUB_TOKEN"
|
|
));
|
|
assert!(!uses_reserved_revision_namespace("v_GITHUB_TOKEN"));
|
|
assert!(!uses_reserved_revision_namespace("v10_"));
|
|
assert!(!uses_reserved_revision_namespace("sshort_GITHUB_TOKEN"));
|
|
assert!(!uses_reserved_revision_namespace("very_unlikely"));
|
|
assert!(!uses_reserved_revision_namespace("GITHUB_TOKEN"));
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_exact_placeholder_header_values() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("CUSTOM_TOKEN".to_string(), "secret-token".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
assert_eq!(
|
|
rewrite_header_line("x-api-key: openshell:resolve:env:CUSTOM_TOKEN", &resolver),
|
|
"x-api-key: secret-token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_bearer_placeholder_header_values() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
assert_eq!(
|
|
rewrite_header_line(
|
|
"Authorization: Bearer openshell:resolve:env:ANTHROPIC_API_KEY",
|
|
&resolver,
|
|
),
|
|
"Authorization: Bearer sk-test"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_stale_revisioned_bearer_placeholder_to_current_alias() {
|
|
let (_, resolver) = SecretResolver::from_provider_env_for_revision_with_current_aliases(
|
|
[("GITHUB_TOKEN".to_string(), "ghp-current".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
HashMap::new(),
|
|
42,
|
|
true,
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
assert_eq!(
|
|
rewrite_header_line_checked(
|
|
"Authorization: Bearer openshell:resolve:env:v10_GITHUB_TOKEN",
|
|
&resolver,
|
|
)
|
|
.expect("stale revision should fall back to current alias"),
|
|
"Authorization: Bearer ghp-current"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_revisioned_placeholder_fails_when_key_is_unknown() {
|
|
let (_, resolver) = SecretResolver::from_provider_env_for_revision_with_current_aliases(
|
|
[("GITHUB_TOKEN".to_string(), "ghp-current".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
HashMap::new(),
|
|
42,
|
|
true,
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
assert!(
|
|
rewrite_header_line_checked(
|
|
"Authorization: Bearer openshell:resolve:env:v999999_very_unlikely",
|
|
&resolver,
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_provider_shaped_alias_header_values() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
("API_TOKEN".to_string(), "provider-real-token".to_string()),
|
|
("CHAT_APP_TOKEN".to_string(), "app-real-token".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
assert_eq!(
|
|
rewrite_header_line(
|
|
"Authorization: Bearer vendor-OPENSHELL-RESOLVE-ENV-API_TOKEN",
|
|
&resolver,
|
|
),
|
|
"Authorization: Bearer provider-real-token"
|
|
);
|
|
assert_eq!(
|
|
rewrite_header_line(
|
|
"x-app-token: token.v1-OPENSHELL-RESOLVE-ENV-CHAT_APP_TOKEN",
|
|
&resolver,
|
|
),
|
|
"x-app-token: app-real-token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_provider_shaped_alias_fails_closed() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("API_TOKEN".to_string(), "provider-real-token".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let raw = b"GET / HTTP/1.1\r\nAuthorization: Bearer vendor-OPENSHELL-RESOLVE-ENV-UNKNOWN_TOKEN\r\n\r\n";
|
|
|
|
let err = rewrite_http_header_block(raw, Some(&resolver))
|
|
.expect_err("unknown alias should fail closed");
|
|
|
|
assert_eq!(err.location, "header");
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_http_header_blocks_and_preserves_body() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("CUSTOM_TOKEN".to_string(), "secret-token".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"POST /v1 HTTP/1.1\r\nAuthorization: Bearer openshell:resolve:env:CUSTOM_TOKEN\r\nContent-Length: 5\r\n\r\nhello";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(rewritten.contains("Authorization: Bearer secret-token\r\n"));
|
|
assert!(rewritten.ends_with("\r\n\r\nhello"));
|
|
}
|
|
|
|
#[test]
|
|
fn full_round_trip_child_env_to_rewritten_headers() {
|
|
let provider_env: HashMap<String, String> = [
|
|
(
|
|
"ANTHROPIC_API_KEY".to_string(),
|
|
"sk-real-key-12345".to_string(),
|
|
),
|
|
(
|
|
"CUSTOM_SERVICE_TOKEN".to_string(),
|
|
"tok-real-svc-67890".to_string(),
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect();
|
|
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(provider_env);
|
|
|
|
let auth_value = child_env.get("ANTHROPIC_API_KEY").unwrap();
|
|
let token_value = child_env.get("CUSTOM_SERVICE_TOKEN").unwrap();
|
|
assert!(auth_value.starts_with(PLACEHOLDER_PREFIX));
|
|
assert!(token_value.starts_with(PLACEHOLDER_PREFIX));
|
|
|
|
let raw = format!(
|
|
"GET /v1/messages HTTP/1.1\r\n\
|
|
Host: api.example.com\r\n\
|
|
Authorization: Bearer {auth_value}\r\n\
|
|
x-api-key: {token_value}\r\n\
|
|
Content-Length: 0\r\n\r\n"
|
|
);
|
|
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.contains("Authorization: Bearer sk-real-key-12345\r\n"),
|
|
"Expected rewritten Authorization header, got: {rewritten}"
|
|
);
|
|
assert!(
|
|
rewritten.contains("x-api-key: tok-real-svc-67890\r\n"),
|
|
"Expected rewritten x-api-key header, got: {rewritten}"
|
|
);
|
|
assert!(
|
|
!rewritten.contains("openshell:resolve:env:"),
|
|
"Placeholder leaked into rewritten request: {rewritten}"
|
|
);
|
|
assert!(rewritten.starts_with("GET /v1/messages HTTP/1.1\r\n"));
|
|
assert!(rewritten.contains("Host: api.example.com\r\n"));
|
|
assert!(rewritten.contains("Content-Length: 0\r\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn non_secret_headers_are_not_modified() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("API_KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: application/json\r\nContent-Type: text/plain\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref()).expect("should succeed");
|
|
assert_eq!(raw.as_slice(), result.rewritten.as_slice());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_provider_env_produces_no_resolver() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(HashMap::new());
|
|
assert!(child_env.is_empty());
|
|
assert!(resolver.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_with_no_resolver_returns_original() {
|
|
let raw = b"GET / HTTP/1.1\r\nAuthorization: Bearer my-token\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, None).expect("should succeed");
|
|
assert_eq!(raw.as_slice(), result.rewritten.as_slice());
|
|
}
|
|
|
|
// === Secret validation tests (F1 — CWE-113) ===
|
|
|
|
#[test]
|
|
fn resolve_placeholder_rejects_crlf() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("BAD_KEY".to_string(), "value\r\nEvil: header".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
assert!(
|
|
resolver
|
|
.resolve_placeholder("openshell:resolve:env:BAD_KEY")
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_placeholder_rejects_null() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("BAD_KEY".to_string(), "value\0rest".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
assert!(
|
|
resolver
|
|
.resolve_placeholder("openshell:resolve:env:BAD_KEY")
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_placeholder_accepts_normal_values() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "sk-abc123_DEF.456~xyz".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
assert_eq!(
|
|
resolver.resolve_placeholder("openshell:resolve:env:KEY"),
|
|
Some("sk-abc123_DEF.456~xyz")
|
|
);
|
|
}
|
|
|
|
// === Query parameter rewriting tests (absorbed from PR #631) ===
|
|
|
|
#[test]
|
|
fn rewrites_query_param_placeholder_in_request_line() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("YOUTUBE_API_KEY".to_string(), "AIzaSy-secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let placeholder = child_env.get("YOUTUBE_API_KEY").unwrap();
|
|
|
|
let raw = format!(
|
|
"GET /youtube/v3/search?part=snippet&key={placeholder} HTTP/1.1\r\n\
|
|
Host: www.googleapis.com\r\n\r\n"
|
|
);
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten
|
|
.starts_with("GET /youtube/v3/search?part=snippet&key=AIzaSy-secret HTTP/1.1\r\n"),
|
|
"Expected query param rewritten, got: {rewritten}"
|
|
);
|
|
assert!(!rewritten.contains("openshell:resolve:env:"));
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_query_param_with_special_chars_percent_encoded() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[(
|
|
"API_KEY".to_string(),
|
|
"key with spaces&symbols=yes".to_string(),
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let placeholder = child_env.get("API_KEY").unwrap();
|
|
|
|
let raw = format!("GET /api?token={placeholder} HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.contains("token=key%20with%20spaces%26symbols%3Dyes"),
|
|
"Expected percent-encoded secret, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrites_query_param_only_placeholder_first_param() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret123".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let placeholder = child_env.get("KEY").unwrap();
|
|
|
|
let raw = format!("GET /api?key={placeholder}&format=json HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.starts_with("GET /api?key=secret123&format=json HTTP/1.1"),
|
|
"Expected first param rewritten, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_query_param_rewrite_without_placeholder() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"GET /api?key=normalvalue HTTP/1.1\r\nHost: x\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref()).expect("should succeed");
|
|
assert_eq!(raw.as_slice(), result.rewritten.as_slice());
|
|
}
|
|
|
|
// === Basic Authorization header encoding tests (absorbed from PR #631) ===
|
|
|
|
#[test]
|
|
fn rewrites_basic_auth_placeholder_in_decoded_token() {
|
|
let b64 = base64::engine::general_purpose::STANDARD;
|
|
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("DB_PASSWORD".to_string(), "s3cret!".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("DB_PASSWORD").unwrap();
|
|
|
|
let credentials = format!("admin:{placeholder}");
|
|
let encoded = b64.encode(credentials.as_bytes());
|
|
|
|
let header_line = format!("Authorization: Basic {encoded}");
|
|
let rewritten = rewrite_header_line(&header_line, &resolver);
|
|
|
|
let rewritten_token = rewritten.strip_prefix("Authorization: Basic ").unwrap();
|
|
let decoded = b64.decode(rewritten_token).unwrap();
|
|
let decoded_str = std::str::from_utf8(&decoded).unwrap();
|
|
|
|
assert_eq!(decoded_str, "admin:s3cret!");
|
|
assert!(!rewritten.contains("openshell:resolve:env:"));
|
|
}
|
|
|
|
#[test]
|
|
fn basic_auth_without_placeholder_unchanged() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
let b64 = base64::engine::general_purpose::STANDARD;
|
|
let encoded = b64.encode(b"user:password");
|
|
let header_line = format!("Authorization: Basic {encoded}");
|
|
|
|
let rewritten = rewrite_header_line(&header_line, &resolver);
|
|
assert_eq!(
|
|
rewritten, header_line,
|
|
"Should not modify non-placeholder Basic auth"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basic_auth_full_round_trip_header_block() {
|
|
let b64 = base64::engine::general_purpose::STANDARD;
|
|
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("REGISTRY_PASS".to_string(), "hunter2".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let placeholder = child_env.get("REGISTRY_PASS").unwrap();
|
|
let credentials = format!("deploy:{placeholder}");
|
|
let encoded = b64.encode(credentials.as_bytes());
|
|
|
|
let raw = format!(
|
|
"GET /v2/_catalog HTTP/1.1\r\n\
|
|
Host: registry.example.com\r\n\
|
|
Authorization: Basic {encoded}\r\n\
|
|
Accept: application/json\r\n\r\n"
|
|
);
|
|
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
let auth_line = rewritten
|
|
.lines()
|
|
.find(|l| l.starts_with("Authorization:"))
|
|
.unwrap();
|
|
let token = auth_line.strip_prefix("Authorization: Basic ").unwrap();
|
|
let decoded = b64.decode(token).unwrap();
|
|
assert_eq!(std::str::from_utf8(&decoded).unwrap(), "deploy:hunter2");
|
|
|
|
assert!(rewritten.contains("Host: registry.example.com\r\n"));
|
|
assert!(rewritten.contains("Accept: application/json\r\n"));
|
|
assert!(!rewritten.contains("openshell:resolve:env:"));
|
|
}
|
|
|
|
// === Percent encoding tests (absorbed from PR #631) ===
|
|
|
|
#[test]
|
|
fn percent_encode_preserves_unreserved() {
|
|
assert_eq!(percent_encode_query("abc123-._~"), "abc123-._~");
|
|
}
|
|
|
|
#[test]
|
|
fn percent_encode_encodes_special_chars() {
|
|
assert_eq!(percent_encode_query("a b"), "a%20b");
|
|
assert_eq!(percent_encode_query("key=val&x"), "key%3Dval%26x");
|
|
}
|
|
|
|
#[test]
|
|
fn percent_decode_round_trips() {
|
|
let original = "hello world & more=stuff";
|
|
let encoded = percent_encode_query(original);
|
|
let decoded = percent_decode(&encoded);
|
|
assert_eq!(decoded, original);
|
|
}
|
|
|
|
// === URL path rewriting tests ===
|
|
|
|
#[test]
|
|
fn rewrite_path_single_segment_placeholder() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("TOKEN".to_string(), "abc123".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("TOKEN").unwrap();
|
|
|
|
let raw = format!("GET /api/{placeholder}/data HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), Some(&resolver)).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.starts_with("GET /api/abc123/data HTTP/1.1"),
|
|
"Expected path rewritten, got: {rewritten}"
|
|
);
|
|
assert_eq!(
|
|
result.redacted_target.as_deref(),
|
|
Some("/api/[CREDENTIAL]/data")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_telegram_style_concatenated() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[(
|
|
"TELEGRAM_TOKEN".to_string(),
|
|
"123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11".to_string(),
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("TELEGRAM_TOKEN").unwrap();
|
|
|
|
let raw = format!(
|
|
"POST /bot{placeholder}/sendMessage HTTP/1.1\r\nHost: api.telegram.org\r\n\r\n"
|
|
);
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), Some(&resolver)).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.starts_with(
|
|
"POST /bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage HTTP/1.1"
|
|
),
|
|
"Expected Telegram-style path rewritten, got: {rewritten}"
|
|
);
|
|
assert_eq!(
|
|
result.redacted_target.as_deref(),
|
|
Some("/bot[CREDENTIAL]/sendMessage")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_multiple_placeholders_in_separate_segments() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
("ORG_ID".to_string(), "org-123".to_string()),
|
|
("API_KEY".to_string(), "key-456".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let org_ph = child_env.get("ORG_ID").unwrap();
|
|
let key_ph = child_env.get("API_KEY").unwrap();
|
|
|
|
let raw = format!("GET /orgs/{org_ph}/keys/{key_ph} HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), Some(&resolver)).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.starts_with("GET /orgs/org-123/keys/key-456 HTTP/1.1"),
|
|
"Expected both path segments rewritten, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_no_placeholders_unchanged() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"GET /v1/chat/completions HTTP/1.1\r\nHost: x\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref()).expect("should succeed");
|
|
assert_eq!(raw.as_slice(), result.rewritten.as_slice());
|
|
assert!(result.redacted_target.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_preserves_query_params() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("TOKEN".to_string(), "tok123".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("TOKEN").unwrap();
|
|
|
|
let raw = format!("GET /bot{placeholder}/method?format=json HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), Some(&resolver)).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.starts_with("GET /bottok123/method?format=json HTTP/1.1"),
|
|
"Expected path rewritten and query preserved, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_credential_traversal_rejected() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("BAD".to_string(), "../admin".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("BAD").unwrap();
|
|
|
|
let raw = format!("GET /api/{placeholder}/data HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result = rewrite_http_header_block(raw.as_bytes(), Some(&resolver));
|
|
assert!(
|
|
result.is_err(),
|
|
"Path traversal credential should be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_credential_backslash_rejected() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("BAD".to_string(), "foo\\bar".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("BAD").unwrap();
|
|
|
|
let raw = format!("GET /api/{placeholder} HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result = rewrite_http_header_block(raw.as_bytes(), Some(&resolver));
|
|
assert!(
|
|
result.is_err(),
|
|
"Backslash in credential should be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_credential_slash_rejected() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("BAD".to_string(), "foo/bar".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("BAD").unwrap();
|
|
|
|
let raw = format!("GET /api/{placeholder} HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result = rewrite_http_header_block(raw.as_bytes(), Some(&resolver));
|
|
assert!(
|
|
result.is_err(),
|
|
"Slash in path credential should be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_credential_null_rejected() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("BAD".to_string(), "foo\0bar".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("BAD").unwrap();
|
|
|
|
let raw = format!("GET /api/{placeholder} HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
// The null byte in the credential is caught by resolve_placeholder's
|
|
// validate_resolved_secret, which returns None. This triggers the
|
|
// unresolved placeholder path in rewrite_path_segment → fail-closed.
|
|
let result = rewrite_http_header_block(raw.as_bytes(), Some(&resolver));
|
|
assert!(
|
|
result.is_err(),
|
|
"Null byte in credential should be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_path_percent_encodes_special_chars() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("TOKEN".to_string(), "hello world".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("TOKEN").unwrap();
|
|
|
|
// Space in the credential should trigger path validation rejection
|
|
// since space is safe to encode but the credential also doesn't
|
|
// contain path-unsafe chars. Actually, space IS allowed (just encoded).
|
|
// Let's test with a safe value that just needs encoding.
|
|
let raw = format!("GET /api/{placeholder}/data HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), Some(&resolver)).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(
|
|
rewritten.contains("/api/hello%20world/data"),
|
|
"Expected percent-encoded path segment, got: {rewritten}"
|
|
);
|
|
}
|
|
|
|
// === Fail-closed tests ===
|
|
|
|
#[test]
|
|
fn unresolved_header_placeholder_returns_error() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"GET / HTTP/1.1\r\nx-api-key: openshell:resolve:env:UNKNOWN_KEY\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref());
|
|
assert!(result.is_err(), "Unresolved header placeholder should fail");
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_query_param_returns_error() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"GET /api?token=openshell:resolve:env:UNKNOWN HTTP/1.1\r\nHost: x\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref());
|
|
assert!(
|
|
result.is_err(),
|
|
"Unresolved query param placeholder should fail"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_path_placeholder_returns_error() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
let raw = b"GET /api/openshell:resolve:env:UNKNOWN/data HTTP/1.1\r\nHost: x\r\n\r\n";
|
|
let result = rewrite_http_header_block(raw, resolver.as_ref());
|
|
assert!(result.is_err(), "Unresolved path placeholder should fail");
|
|
}
|
|
|
|
#[test]
|
|
fn percent_encoded_placeholder_in_path_caught() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
|
|
// Percent-encode "openshell:resolve:env:UNKNOWN" in the path
|
|
let encoded_placeholder = "openshell%3Aresolve%3Aenv%3AUNKNOWN";
|
|
let raw = format!("GET /api/{encoded_placeholder}/data HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
let result = rewrite_http_header_block(raw.as_bytes(), resolver.as_ref());
|
|
assert!(
|
|
result.is_err(),
|
|
"Percent-encoded placeholder should be caught by fail-closed scan"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn percent_encoded_canonical_placeholder_in_query_rewrites() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("API_TOKEN".to_string(), "provider-real-token".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let encoded = "openshell%3Aresolve%3Aenv%3AAPI_TOKEN";
|
|
let raw = format!("GET /api?token={encoded} HTTP/1.1\r\nHost: x\r\n\r\n");
|
|
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), Some(&resolver)).expect("should rewrite");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(rewritten.starts_with("GET /api?token=provider-real-token HTTP/1.1"));
|
|
assert!(!rewritten.contains("openshell"));
|
|
assert_eq!(
|
|
result.redacted_target.as_deref(),
|
|
Some("/api?token=[CREDENTIAL]")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn all_resolved_succeeds() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
("KEY1".to_string(), "secret1".to_string()),
|
|
("KEY2".to_string(), "secret2".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let ph1 = child_env.get("KEY1").unwrap();
|
|
let ph2 = child_env.get("KEY2").unwrap();
|
|
|
|
let raw = format!(
|
|
"GET /api/{ph1}?token={ph2} HTTP/1.1\r\n\
|
|
x-auth: {ph1}\r\n\r\n"
|
|
);
|
|
let result =
|
|
rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()).expect("should succeed");
|
|
let rewritten = String::from_utf8(result.rewritten).expect("utf8");
|
|
|
|
assert!(!rewritten.contains("openshell:resolve:env:"));
|
|
assert!(rewritten.contains("secret1"));
|
|
assert!(rewritten.contains("secret2"));
|
|
}
|
|
|
|
#[test]
|
|
fn no_resolver_with_placeholder_in_request_line_fails_closed() {
|
|
// Fail-closed: a placeholder in the request line with no resolver must
|
|
// never be forwarded verbatim — it would leak the reserved token.
|
|
let raw = b"GET /api/openshell:resolve:env:KEY HTTP/1.1\r\nHost: x\r\n\r\n";
|
|
let err = rewrite_http_header_block(raw, None)
|
|
.expect_err("placeholder without resolver must fail closed");
|
|
assert_eq!(err.location, "header");
|
|
}
|
|
|
|
#[test]
|
|
fn no_resolver_with_placeholder_in_header_value_fails_closed() {
|
|
let raw =
|
|
b"GET / HTTP/1.1\r\nAuthorization: Bearer openshell:resolve:env:KEY\r\nHost: x\r\n\r\n";
|
|
let err = rewrite_http_header_block(raw, None)
|
|
.expect_err("placeholder without resolver must fail closed");
|
|
assert_eq!(err.location, "header");
|
|
}
|
|
|
|
#[test]
|
|
fn no_resolver_with_percent_encoded_marker_fails_closed() {
|
|
// Percent-encoded form of the canonical marker: a client (or an evasive
|
|
// sender) can URL-encode the reserved token, and the no-resolver scan
|
|
// must still catch it via the percent-decoded pass.
|
|
let raw = b"GET /api/openshell%3Aresolve%3Aenv%3AKEY HTTP/1.1\r\nHost: x\r\n\r\n";
|
|
let err = rewrite_http_header_block(raw, None)
|
|
.expect_err("percent-encoded placeholder without resolver must fail closed");
|
|
assert_eq!(err.location, "header");
|
|
}
|
|
|
|
#[test]
|
|
fn no_resolver_with_provider_alias_marker_fails_closed() {
|
|
// The provider-shaped alias marker is also a reserved credential token
|
|
// and must fail closed when no resolver can rewrite it.
|
|
let raw =
|
|
b"GET / HTTP/1.1\r\nX-Api-Key: OPENSHELL-RESOLVE-ENV-CUSTOM_TOKEN\r\nHost: x\r\n\r\n";
|
|
let err = rewrite_http_header_block(raw, None)
|
|
.expect_err("alias marker without resolver must fail closed");
|
|
assert_eq!(err.location, "header");
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_websocket_text_replaces_placeholders_and_returns_count() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string()),
|
|
("APP_ID".to_string(), "app-123".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let token = child_env.get("DISCORD_BOT_TOKEN").unwrap();
|
|
let app_id = child_env.get("APP_ID").unwrap();
|
|
let mut payload =
|
|
format!(r#"{{"op":2,"d":{{"token":"{token}","properties":{{"app":"{app_id}"}}}}}}"#);
|
|
|
|
let count = resolver
|
|
.rewrite_websocket_text_placeholders(&mut payload)
|
|
.expect("rewrite should succeed");
|
|
|
|
assert_eq!(count, 2);
|
|
assert!(payload.contains(r#""token":"real-token""#));
|
|
assert!(payload.contains(r#""app":"app-123""#));
|
|
assert!(!payload.contains(PLACEHOLDER_PREFIX));
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_websocket_text_replaces_provider_shaped_alias() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("APP_TOKEN".to_string(), "app-real-token".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let mut payload = r#"{"token":"provider-OPENSHELL-RESOLVE-ENV-APP_TOKEN"}"#.to_string();
|
|
|
|
let count = resolver
|
|
.rewrite_websocket_text_placeholders(&mut payload)
|
|
.expect("alias should rewrite");
|
|
|
|
assert_eq!(count, 1);
|
|
assert_eq!(payload, r#"{"token":"app-real-token"}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_websocket_text_without_placeholder_is_unchanged() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let mut payload = r#"{"op":1,"d":42}"#.to_string();
|
|
|
|
let count = resolver
|
|
.rewrite_websocket_text_placeholders(&mut payload)
|
|
.expect("rewrite should succeed");
|
|
|
|
assert_eq!(count, 0);
|
|
assert_eq!(payload, r#"{"op":1,"d":42}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_websocket_text_unknown_placeholder_fails_closed_without_mutating() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let original = r#"{"token":"openshell:resolve:env:UNKNOWN"}"#.to_string();
|
|
let mut payload = original.clone();
|
|
|
|
let err = resolver
|
|
.rewrite_websocket_text_placeholders(&mut payload)
|
|
.expect_err("unknown placeholder should fail");
|
|
|
|
assert_eq!(err.location, "websocket");
|
|
assert_eq!(payload, original);
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_websocket_text_handles_repeated_adjacent_and_unicode_placeholders() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
("TOKEN".to_string(), "tok".to_string()),
|
|
("APP".to_string(), "app".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let token = child_env.get("TOKEN").unwrap();
|
|
let app = child_env.get("APP").unwrap();
|
|
let mut payload = format!("prefix-☃-{token}{app}-{token}-suffix");
|
|
|
|
let count = resolver
|
|
.rewrite_websocket_text_placeholders(&mut payload)
|
|
.expect("rewrite should succeed");
|
|
|
|
assert_eq!(count, 3);
|
|
assert_eq!(payload, "prefix-☃-tokapp-tok-suffix");
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_websocket_text_placeholder_like_prefix_fails_without_mutating() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let original = "openshell:resolve:env:-not-a-key".to_string();
|
|
let mut payload = original.clone();
|
|
|
|
let err = resolver
|
|
.rewrite_websocket_text_placeholders(&mut payload)
|
|
.expect_err("placeholder-like prefix should fail closed");
|
|
|
|
assert_eq!(err.location, "websocket");
|
|
assert_eq!(payload, original);
|
|
}
|
|
|
|
// === Redaction tests ===
|
|
|
|
#[test]
|
|
fn redacted_target_replaces_path_secrets_with_credential_marker() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("TOKEN".to_string(), "real-secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("TOKEN").unwrap();
|
|
|
|
let result = rewrite_target_for_eval(&format!("/bot{placeholder}/sendMessage"), &resolver)
|
|
.expect("should succeed");
|
|
|
|
assert_eq!(result.redacted, "/bot[CREDENTIAL]/sendMessage");
|
|
assert!(result.resolved.contains("real-secret"));
|
|
assert!(!result.redacted.contains("real-secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_target_replaces_query_secrets_with_credential_marker() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret123".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let placeholder = child_env.get("KEY").unwrap();
|
|
|
|
let result =
|
|
rewrite_target_for_eval(&format!("/api?key={placeholder}&format=json"), &resolver)
|
|
.expect("should succeed");
|
|
|
|
assert_eq!(result.redacted, "/api?key=[CREDENTIAL]&format=json");
|
|
assert!(result.resolved.contains("secret123"));
|
|
assert!(!result.redacted.contains("secret123"));
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_target_preserves_non_secret_segments() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[("KEY".to_string(), "secret".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
let result = rewrite_target_for_eval("/v1/chat/completions?format=json", &resolver)
|
|
.expect("should succeed");
|
|
|
|
assert_eq!(result.resolved, "/v1/chat/completions?format=json");
|
|
assert_eq!(result.redacted, "/v1/chat/completions?format=json");
|
|
}
|
|
|
|
#[test]
|
|
fn debug_format_does_not_leak_secret_values() {
|
|
let (_, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
(
|
|
"ANTHROPIC_API_KEY".to_string(),
|
|
"sk-very-secret-value-12345".to_string(),
|
|
),
|
|
("DB_PASSWORD".to_string(), "very-secret-value".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
|
|
let plain = format!("{resolver:?}");
|
|
let pretty = format!("{resolver:#?}");
|
|
|
|
for output in [&plain, &pretty] {
|
|
assert!(
|
|
!output.contains("sk-very-secret-value-12345"),
|
|
"secret value leaked via Debug: {output}"
|
|
);
|
|
assert!(
|
|
!output.contains("very-secret-value"),
|
|
"secret value leaked via Debug: {output}"
|
|
);
|
|
assert!(
|
|
!output.contains("ANTHROPIC_API_KEY"),
|
|
"placeholder key (env var name) leaked via Debug: {output}"
|
|
);
|
|
assert!(
|
|
!output.contains("DB_PASSWORD"),
|
|
"placeholder key (env var name) leaked via Debug: {output}"
|
|
);
|
|
assert!(
|
|
!output.contains(PLACEHOLDER_PREFIX),
|
|
"placeholder prefix leaked via Debug: {output}"
|
|
);
|
|
assert!(
|
|
output.contains("SecretResolver"),
|
|
"Debug output should still identify the type: {output}"
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
plain.contains('2'),
|
|
"Debug output should expose the placeholder count: {plain}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn debug_format_of_empty_resolver_is_safe() {
|
|
let resolver = SecretResolver::default();
|
|
let output = format!("{resolver:?}");
|
|
assert!(output.contains("SecretResolver"));
|
|
assert!(output.contains('0'));
|
|
assert!(!output.contains(PLACEHOLDER_PREFIX));
|
|
}
|
|
|
|
#[test]
|
|
fn rewrite_target_for_eval_roundtrip() {
|
|
let (child_env, resolver) = SecretResolver::from_provider_env(
|
|
[
|
|
("TOKEN".to_string(), "tok123".to_string()),
|
|
("KEY".to_string(), "key456".to_string()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
);
|
|
let resolver = resolver.expect("resolver");
|
|
let tok_ph = child_env.get("TOKEN").unwrap();
|
|
let key_ph = child_env.get("KEY").unwrap();
|
|
|
|
let target = format!("/bot{tok_ph}/method?key={key_ph}");
|
|
let result = rewrite_target_for_eval(&target, &resolver).expect("should succeed");
|
|
|
|
assert_eq!(result.resolved, "/bottok123/method?key=key456");
|
|
assert_eq!(result.redacted, "/bot[CREDENTIAL]/method?key=[CREDENTIAL]");
|
|
}
|
|
|
|
#[test]
|
|
fn policy_target_redaction_never_requires_real_secret_material() {
|
|
let target = "/v1/openshell:resolve:env:v9_API_KEY/messages?token=provider-OPENSHELL-RESOLVE-ENV-API_KEY";
|
|
let redacted = redact_target_for_policy(target).expect("valid placeholder syntax");
|
|
assert_eq!(redacted, "/v1/[CREDENTIAL]/messages?token=[CREDENTIAL]");
|
|
assert!(!redacted.contains("API_KEY"));
|
|
}
|
|
}
|