Files
Aether/apps/aether-gateway/src/state/catalog_proxy.rs
T
elky 579f2c7cc1 feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
2026-09-04 03:45:52 +08:00

1861 lines
64 KiB
Rust

use aether_admin::provider::redaction::{
admin_json_field_has_contextual_secrets, admin_json_field_is_sensitive,
admin_proxy_credential_field, AdminProxyCredentialField,
};
use aether_crypto::looks_like_python_fernet_ciphertext;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogProxyCasUpdate, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
};
use http::StatusCode;
use percent_encoding::percent_decode_str;
use serde_json::{Map, Value};
use url::Url;
use super::AppState;
use crate::handlers::shared::{
open_runtime_secret_payload, runtime_secret_payload_is_sealed, seal_runtime_secret_payload,
};
use crate::GatewayError;
const PROVIDER_PROXY_USERNAME_PURPOSE: &str = "provider-catalog-provider-proxy-username";
const PROVIDER_PROXY_PASSWORD_PURPOSE: &str = "provider-catalog-provider-proxy-password";
const ENDPOINT_PROXY_USERNAME_PURPOSE: &str = "provider-catalog-endpoint-proxy-username";
const ENDPOINT_PROXY_PASSWORD_PURPOSE: &str = "provider-catalog-endpoint-proxy-password";
const KEY_PROXY_USERNAME_PURPOSE: &str = "provider-catalog-key-proxy-username";
const KEY_PROXY_PASSWORD_PURPOSE: &str = "provider-catalog-key-proxy-password";
const CATALOG_PROXY_SECRET_V2_PREFIX: &str = "aether-provider-catalog-proxy-secret-v2:";
const CATALOG_PROXY_BOUND_PURPOSE_VERSION: &str = "provider-catalog-proxy-credential-v2";
const CATALOG_PROXY_MIGRATION_RETRIES: usize = 8;
#[derive(Debug, Clone, Copy)]
enum CatalogProxyScope {
Provider,
Endpoint,
Key,
}
impl CatalogProxyScope {
fn label(self) -> &'static str {
match self {
Self::Provider => "provider",
Self::Endpoint => "endpoint",
Self::Key => "key",
}
}
fn username_purpose(self) -> &'static str {
match self {
Self::Provider => PROVIDER_PROXY_USERNAME_PURPOSE,
Self::Endpoint => ENDPOINT_PROXY_USERNAME_PURPOSE,
Self::Key => KEY_PROXY_USERNAME_PURPOSE,
}
}
fn password_purpose(self) -> &'static str {
match self {
Self::Provider => PROVIDER_PROXY_PASSWORD_PURPOSE,
Self::Endpoint => ENDPOINT_PROXY_PASSWORD_PURPOSE,
Self::Key => KEY_PROXY_PASSWORD_PURPOSE,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CatalogProxySource {
Incoming,
Stored,
}
struct CatalogProxyProjection {
runtime: Option<Value>,
protected: Option<Value>,
migration_required: bool,
}
struct CatalogProxyCredential {
plaintext: String,
protected: String,
migration_required: bool,
}
impl AppState {
pub(super) fn protect_provider_catalog_provider(
&self,
provider: &StoredProviderCatalogProvider,
) -> Result<StoredProviderCatalogProvider, GatewayError> {
let mut protected = provider.clone();
protected.proxy = self.protect_catalog_proxy(
CatalogProxyScope::Provider,
&provider.id,
provider.proxy.as_ref(),
)?;
Ok(protected)
}
pub(super) fn protect_provider_catalog_endpoint(
&self,
endpoint: &StoredProviderCatalogEndpoint,
) -> Result<StoredProviderCatalogEndpoint, GatewayError> {
let mut protected = endpoint.clone();
protected.proxy = self.protect_catalog_proxy(
CatalogProxyScope::Endpoint,
&endpoint.id,
endpoint.proxy.as_ref(),
)?;
Ok(protected)
}
pub(super) fn protect_provider_catalog_key(
&self,
key: &StoredProviderCatalogKey,
) -> Result<StoredProviderCatalogKey, GatewayError> {
let mut protected = self.protect_provider_catalog_key_credentials(key)?;
protected.proxy =
self.protect_catalog_proxy(CatalogProxyScope::Key, &key.id, key.proxy.as_ref())?;
Ok(protected)
}
pub(super) async fn open_provider_catalog_providers(
&self,
providers: Vec<StoredProviderCatalogProvider>,
) -> Result<Vec<StoredProviderCatalogProvider>, GatewayError> {
let mut opened = Vec::with_capacity(providers.len());
for provider in providers {
opened.push(self.open_provider_catalog_provider(provider).await?);
}
Ok(opened)
}
pub(super) async fn open_provider_catalog_endpoints(
&self,
endpoints: Vec<StoredProviderCatalogEndpoint>,
) -> Result<Vec<StoredProviderCatalogEndpoint>, GatewayError> {
let mut opened = Vec::with_capacity(endpoints.len());
for endpoint in endpoints {
opened.push(self.open_provider_catalog_endpoint(endpoint).await?);
}
Ok(opened)
}
pub(super) async fn open_provider_catalog_keys(
&self,
keys: Vec<StoredProviderCatalogKey>,
) -> Result<Vec<StoredProviderCatalogKey>, GatewayError> {
let mut opened = Vec::with_capacity(keys.len());
for key in keys {
opened.push(self.open_provider_catalog_key(key).await?);
}
Ok(opened)
}
pub(super) async fn open_provider_catalog_provider(
&self,
mut provider: StoredProviderCatalogProvider,
) -> Result<StoredProviderCatalogProvider, GatewayError> {
for _ in 0..CATALOG_PROXY_MIGRATION_RETRIES {
let projection = self.project_catalog_proxy(
CatalogProxyScope::Provider,
&provider.id,
provider.proxy.as_ref(),
CatalogProxySource::Stored,
)?;
if !projection.migration_required {
provider.proxy = projection.runtime;
return Ok(provider);
}
self.require_catalog_proxy_migration_writer(CatalogProxyScope::Provider)?;
let update = ProviderCatalogProxyCasUpdate {
record_id: provider.id.clone(),
expected_proxy: provider.proxy.clone(),
proxy: projection.protected,
};
if self
.data
.compare_and_swap_provider_catalog_provider_proxy(&update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
provider.proxy = projection.runtime;
return Ok(provider);
}
provider = self
.data
.list_provider_catalog_providers_by_ids(std::slice::from_ref(&provider.id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.next()
.ok_or_else(|| {
catalog_proxy_migration_changed_error(CatalogProxyScope::Provider)
})?;
}
Err(catalog_proxy_migration_unstable_error(
CatalogProxyScope::Provider,
))
}
pub(super) async fn open_provider_catalog_endpoint(
&self,
mut endpoint: StoredProviderCatalogEndpoint,
) -> Result<StoredProviderCatalogEndpoint, GatewayError> {
for _ in 0..CATALOG_PROXY_MIGRATION_RETRIES {
let projection = self.project_catalog_proxy(
CatalogProxyScope::Endpoint,
&endpoint.id,
endpoint.proxy.as_ref(),
CatalogProxySource::Stored,
)?;
if !projection.migration_required {
endpoint.proxy = projection.runtime;
return Ok(endpoint);
}
self.require_catalog_proxy_migration_writer(CatalogProxyScope::Endpoint)?;
let update = ProviderCatalogProxyCasUpdate {
record_id: endpoint.id.clone(),
expected_proxy: endpoint.proxy.clone(),
proxy: projection.protected,
};
if self
.data
.compare_and_swap_provider_catalog_endpoint_proxy(&update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
endpoint.proxy = projection.runtime;
return Ok(endpoint);
}
endpoint = self
.data
.list_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint.id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.next()
.ok_or_else(|| {
catalog_proxy_migration_changed_error(CatalogProxyScope::Endpoint)
})?;
}
Err(catalog_proxy_migration_unstable_error(
CatalogProxyScope::Endpoint,
))
}
pub(super) async fn open_provider_catalog_key(
&self,
mut key: StoredProviderCatalogKey,
) -> Result<StoredProviderCatalogKey, GatewayError> {
let initial_provider_id = key.provider_id.clone();
for _ in 0..CATALOG_PROXY_MIGRATION_RETRIES {
if !self
.open_provider_catalog_key_credentials_once(&mut key)
.await?
{
key = self
.data
.list_provider_catalog_keys_by_ids_strong(std::slice::from_ref(&key.id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.next()
.ok_or_else(|| catalog_proxy_migration_changed_error(CatalogProxyScope::Key))?;
if key.provider_id != initial_provider_id {
return Err(GatewayError::Internal(
"provider catalog key provider binding changed during credential migration"
.to_string(),
));
}
continue;
}
let projection = self.project_catalog_proxy(
CatalogProxyScope::Key,
&key.id,
key.proxy.as_ref(),
CatalogProxySource::Stored,
)?;
if !projection.migration_required {
key.proxy = projection.runtime;
return Ok(key);
}
self.require_catalog_proxy_migration_writer(CatalogProxyScope::Key)?;
let update = ProviderCatalogProxyCasUpdate {
record_id: key.id.clone(),
expected_proxy: key.proxy.clone(),
proxy: projection.protected,
};
if self
.data
.compare_and_swap_provider_catalog_key_proxy(&update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
key.proxy = projection.runtime;
return Ok(key);
}
key = self
.data
.list_provider_catalog_keys_by_ids_strong(std::slice::from_ref(&key.id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.next()
.ok_or_else(|| catalog_proxy_migration_changed_error(CatalogProxyScope::Key))?;
if key.provider_id != initial_provider_id {
return Err(GatewayError::Internal(
"provider catalog key provider binding changed during proxy migration"
.to_string(),
));
}
}
Err(catalog_proxy_migration_unstable_error(
CatalogProxyScope::Key,
))
}
pub(super) async fn open_provider_transport_snapshot_once(
&self,
snapshot: &mut crate::provider_transport::GatewayProviderTransportSnapshot,
) -> Result<bool, GatewayError> {
let Some(mut stored_key) = self
.data
.list_provider_catalog_keys_by_ids_strong(std::slice::from_ref(&snapshot.key.id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.next()
else {
return Ok(false);
};
if stored_key.provider_id != snapshot.provider.id
|| stored_key.provider_id != snapshot.key.provider_id
{
return Ok(false);
}
if !self
.open_provider_catalog_key_credentials_once(&mut stored_key)
.await?
{
return Ok(false);
}
let provider = self.project_catalog_proxy(
CatalogProxyScope::Provider,
&snapshot.provider.id,
snapshot.provider.proxy.as_ref(),
CatalogProxySource::Stored,
)?;
if provider.migration_required {
self.require_catalog_proxy_migration_writer(CatalogProxyScope::Provider)?;
if !self
.data
.compare_and_swap_provider_catalog_provider_proxy(&ProviderCatalogProxyCasUpdate {
record_id: snapshot.provider.id.clone(),
expected_proxy: snapshot.provider.proxy.clone(),
proxy: provider.protected,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
return Ok(false);
}
}
snapshot.provider.proxy = provider.runtime;
let endpoint = self.project_catalog_proxy(
CatalogProxyScope::Endpoint,
&snapshot.endpoint.id,
snapshot.endpoint.proxy.as_ref(),
CatalogProxySource::Stored,
)?;
if endpoint.migration_required {
self.require_catalog_proxy_migration_writer(CatalogProxyScope::Endpoint)?;
if !self
.data
.compare_and_swap_provider_catalog_endpoint_proxy(&ProviderCatalogProxyCasUpdate {
record_id: snapshot.endpoint.id.clone(),
expected_proxy: snapshot.endpoint.proxy.clone(),
proxy: endpoint.protected,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
return Ok(false);
}
}
snapshot.endpoint.proxy = endpoint.runtime;
let key = self.project_catalog_proxy(
CatalogProxyScope::Key,
&snapshot.key.id,
snapshot.key.proxy.as_ref(),
CatalogProxySource::Stored,
)?;
if key.migration_required {
self.require_catalog_proxy_migration_writer(CatalogProxyScope::Key)?;
if !self
.data
.compare_and_swap_provider_catalog_key_proxy(&ProviderCatalogProxyCasUpdate {
record_id: snapshot.key.id.clone(),
expected_proxy: snapshot.key.proxy.clone(),
proxy: key.protected,
})
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
{
return Ok(false);
}
}
snapshot.key.proxy = key.runtime;
Ok(true)
}
fn require_catalog_proxy_migration_writer(
&self,
scope: CatalogProxyScope,
) -> Result<(), GatewayError> {
if self.has_provider_catalog_data_writer() {
Ok(())
} else {
Err(GatewayError::Internal(format!(
"stored {} proxy credentials require migration but the catalog writer is unavailable",
scope.label()
)))
}
}
fn protect_catalog_proxy(
&self,
scope: CatalogProxyScope,
record_id: &str,
proxy: Option<&Value>,
) -> Result<Option<Value>, GatewayError> {
self.project_catalog_proxy(scope, record_id, proxy, CatalogProxySource::Incoming)
.map(|projection| projection.protected)
}
fn project_catalog_proxy(
&self,
scope: CatalogProxyScope,
record_id: &str,
proxy: Option<&Value>,
source: CatalogProxySource,
) -> Result<CatalogProxyProjection, GatewayError> {
let Some(proxy) = proxy else {
return Ok(CatalogProxyProjection {
runtime: None,
protected: None,
migration_required: false,
});
};
if proxy.is_null() {
return Ok(CatalogProxyProjection {
runtime: None,
protected: None,
migration_required: source == CatalogProxySource::Stored,
});
}
let proxy_was_string = proxy.is_string();
let mut object = match proxy {
Value::Object(object) => object.clone(),
Value::String(url) => {
Map::from_iter([("url".to_string(), Value::String(url.to_string()))])
}
_ => return Err(catalog_proxy_representation_error(scope, source)),
};
let mut raw_usernames = Vec::new();
let mut raw_passwords = Vec::new();
let mut migration_required = scrub_catalog_proxy_sensitive_fields(
scope,
source,
&mut object,
true,
&mut raw_usernames,
&mut raw_passwords,
)?;
let mut normalized_url = None;
let mut url_username = None;
let mut url_password = None;
let url_keys = object
.keys()
.filter(|key| matches!(compact_catalog_proxy_key(key).as_str(), "url" | "proxyurl"))
.cloned()
.collect::<Vec<_>>();
for key in url_keys {
let value = object
.remove(&key)
.expect("catalog proxy URL key should still exist");
if value.is_null() {
migration_required |= source == CatalogProxySource::Stored;
continue;
}
let Some(raw_url) = value.as_str() else {
return Err(catalog_proxy_url_error(scope, source));
};
let (candidate_url, username, password) =
parse_catalog_proxy_url(scope, source, raw_url)?;
merge_catalog_proxy_url(scope, source, &mut normalized_url, candidate_url.clone())?;
merge_catalog_proxy_url_credential(
scope,
source,
"username",
&mut url_username,
username,
)?;
merge_catalog_proxy_url_credential(
scope,
source,
"password",
&mut url_password,
password,
)?;
if key != "url"
|| (candidate_url != raw_url
&& (!proxy_was_string || stored_catalog_proxy_url_requires_cleanup(raw_url)))
{
migration_required |= source == CatalogProxySource::Stored;
}
}
if let Some(url) = normalized_url {
object.insert("url".to_string(), Value::String(url));
}
let mut explicit_username = None;
for value in raw_usernames {
let candidate = self.catalog_proxy_credential(
scope,
record_id,
source,
"username",
Some(&value),
scope.username_purpose(),
)?;
merge_catalog_proxy_explicit_credential(
scope,
source,
"username",
&mut explicit_username,
candidate,
)?;
}
let mut explicit_password = None;
for value in raw_passwords {
let candidate = self.catalog_proxy_credential(
scope,
record_id,
source,
"password",
Some(&value),
scope.password_purpose(),
)?;
merge_catalog_proxy_explicit_credential(
scope,
source,
"password",
&mut explicit_password,
candidate,
)?;
}
let username = merge_catalog_proxy_credential(
scope,
record_id,
source,
"username",
explicit_username,
url_username,
self,
)?;
let password = merge_catalog_proxy_credential(
scope,
record_id,
source,
"password",
explicit_password,
url_password,
self,
)?;
let mut runtime = object.clone();
let mut protected = object;
migration_required |= apply_catalog_proxy_credential(
source,
"username",
username,
&mut runtime,
&mut protected,
);
migration_required |= apply_catalog_proxy_credential(
source,
"password",
password,
&mut runtime,
&mut protected,
);
Ok(CatalogProxyProjection {
runtime: Some(Value::Object(runtime)),
protected: Some(Value::Object(protected)),
migration_required,
})
}
fn catalog_proxy_credential(
&self,
scope: CatalogProxyScope,
record_id: &str,
source: CatalogProxySource,
field: &'static str,
value: Option<&Value>,
legacy_purpose: &'static str,
) -> Result<Option<CatalogProxyCredential>, GatewayError> {
let Some(value) = value else {
return Ok(None);
};
if value.is_null() {
return Ok(Some(CatalogProxyCredential {
plaintext: String::new(),
protected: String::new(),
migration_required: source == CatalogProxySource::Stored,
}));
}
let Some(value) = value.as_str() else {
return Err(catalog_proxy_credential_error(scope, source, field));
};
if value.contains('\0') {
return Err(match source {
CatalogProxySource::Incoming => {
catalog_proxy_credential_error(scope, source, field)
}
CatalogProxySource::Stored => catalog_proxy_storage_error(scope),
});
}
if value.is_empty() {
return Ok(Some(CatalogProxyCredential {
plaintext: String::new(),
protected: String::new(),
migration_required: source == CatalogProxySource::Stored,
}));
}
if source == CatalogProxySource::Stored && catalog_proxy_secret_is_v2(value) {
let plaintext = open_catalog_proxy_secret_v2(self, scope, record_id, field, value)
.ok_or_else(|| catalog_proxy_storage_error(scope))?;
return Ok(Some(CatalogProxyCredential {
plaintext,
protected: value.to_string(),
migration_required: false,
}));
}
if source == CatalogProxySource::Stored && runtime_secret_payload_is_sealed(value) {
let plaintext = open_runtime_secret_payload(self, legacy_purpose, value)
.ok_or_else(|| catalog_proxy_storage_error(scope))?;
let protected = seal_catalog_proxy_secret_v2(self, scope, record_id, field, &plaintext)
.ok_or_else(|| catalog_proxy_encryption_error(scope))?;
return Ok(Some(CatalogProxyCredential {
plaintext,
protected,
migration_required: true,
}));
}
if catalog_proxy_secret_is_v2(value)
|| runtime_secret_payload_is_sealed(value)
|| value.starts_with("aether-")
|| looks_like_python_fernet_ciphertext(value)
{
return Err(match source {
CatalogProxySource::Incoming => {
catalog_proxy_credential_error(scope, source, field)
}
CatalogProxySource::Stored => catalog_proxy_storage_error(scope),
});
}
let protected = seal_catalog_proxy_secret_v2(self, scope, record_id, field, value)
.ok_or_else(|| catalog_proxy_encryption_error(scope))?;
Ok(Some(CatalogProxyCredential {
plaintext: value.to_string(),
protected,
migration_required: source == CatalogProxySource::Stored,
}))
}
}
fn catalog_proxy_secret_is_v2(value: &str) -> bool {
value.starts_with(CATALOG_PROXY_SECRET_V2_PREFIX)
}
fn catalog_proxy_bound_purpose(scope: CatalogProxyScope, record_id: &str, field: &str) -> String {
format!(
"{CATALOG_PROXY_BOUND_PURPOSE_VERSION}\0scope={}\0field={field}\0record-id-bytes={}\0{record_id}",
scope.label(),
record_id.len()
)
}
fn seal_catalog_proxy_secret_v2(
state: &AppState,
scope: CatalogProxyScope,
record_id: &str,
field: &str,
plaintext: &str,
) -> Option<String> {
if plaintext.contains('\0') {
return None;
}
let purpose = catalog_proxy_bound_purpose(scope, record_id, field);
seal_runtime_secret_payload(state, &purpose, plaintext)
.map(|sealed| format!("{CATALOG_PROXY_SECRET_V2_PREFIX}{sealed}"))
}
fn open_catalog_proxy_secret_v2(
state: &AppState,
scope: CatalogProxyScope,
record_id: &str,
field: &str,
stored: &str,
) -> Option<String> {
// The distinct outer envelope is security-significant: a v2 binding failure
// must never be retried with the unbound legacy purpose.
let sealed = stored.strip_prefix(CATALOG_PROXY_SECRET_V2_PREFIX)?;
let purpose = catalog_proxy_bound_purpose(scope, record_id, field);
open_runtime_secret_payload(state, &purpose, sealed)
.filter(|plaintext| !plaintext.contains('\0'))
}
fn scrub_catalog_proxy_sensitive_fields(
scope: CatalogProxyScope,
source: CatalogProxySource,
object: &mut Map<String, Value>,
at_root: bool,
usernames: &mut Vec<Value>,
passwords: &mut Vec<Value>,
) -> Result<bool, GatewayError> {
let mut changed = false;
let keys = object.keys().cloned().collect::<Vec<_>>();
for key in keys {
let compact_key = compact_catalog_proxy_key(&key);
if compact_key == "hascredentials" {
object.remove(&key);
changed = true;
continue;
}
if let Some(field) = admin_proxy_credential_field(&key) {
let value = object
.remove(&key)
.expect("catalog proxy credential key should still exist");
let canonical = at_root
&& key
== match field {
AdminProxyCredentialField::Username => "username",
AdminProxyCredentialField::Password => "password",
};
changed |= !canonical || catalog_proxy_sensitive_value_is_unset(&value);
match field {
AdminProxyCredentialField::Username => usernames.push(value),
AdminProxyCredentialField::Password => passwords.push(value),
}
continue;
}
let is_credential_container = matches!(
compact_key.as_str(),
"auth" | "proxyauth" | "credentials" | "proxycredentials"
);
if is_credential_container
&& object
.get(&key)
.is_some_and(|value| value.is_object() || value.is_array())
{
let nested_changed = scrub_catalog_proxy_sensitive_value(
scope,
source,
object
.get_mut(&key)
.expect("catalog proxy credential container should still exist"),
usernames,
passwords,
)?;
changed |= nested_changed;
let is_empty = object.get(&key).is_some_and(|value| match value {
Value::Object(value) => value.is_empty(),
Value::Array(value) => value.is_empty(),
_ => false,
});
if is_empty {
object.remove(&key);
changed = true;
continue;
}
}
let value = object
.get(&key)
.expect("catalog proxy field should still exist");
let is_root_proxy_url = at_root && matches!(compact_key.as_str(), "url" | "proxyurl");
if !is_root_proxy_url && admin_json_field_has_contextual_secrets(&key, value) {
return Err(catalog_proxy_unsupported_sensitive_error(
scope, source, &key,
));
}
if admin_json_field_is_sensitive(&key, value) {
if catalog_proxy_sensitive_value_is_unset(value) {
object.remove(&key);
changed = true;
continue;
}
return Err(catalog_proxy_unsupported_sensitive_error(
scope, source, &key,
));
}
changed |= scrub_catalog_proxy_sensitive_value(
scope,
source,
object
.get_mut(&key)
.expect("catalog proxy nested field should still exist"),
usernames,
passwords,
)?;
}
Ok(changed && source == CatalogProxySource::Stored)
}
fn scrub_catalog_proxy_sensitive_value(
scope: CatalogProxyScope,
source: CatalogProxySource,
value: &mut Value,
usernames: &mut Vec<Value>,
passwords: &mut Vec<Value>,
) -> Result<bool, GatewayError> {
match value {
Value::Object(object) => {
scrub_catalog_proxy_sensitive_fields(scope, source, object, false, usernames, passwords)
}
Value::Array(values) => {
let mut changed = false;
for value in values {
changed |= scrub_catalog_proxy_sensitive_value(
scope, source, value, usernames, passwords,
)?;
}
Ok(changed)
}
_ => Ok(false),
}
}
fn catalog_proxy_sensitive_value_is_unset(value: &Value) -> bool {
match value {
Value::Null => true,
Value::String(value) => value.is_empty(),
Value::Array(value) => value.is_empty(),
Value::Object(value) => value.is_empty(),
_ => false,
}
}
fn compact_catalog_proxy_key(key: &str) -> String {
key.chars()
.filter(|character| character.is_ascii_alphanumeric())
.map(|character| character.to_ascii_lowercase())
.collect()
}
fn parse_catalog_proxy_url(
scope: CatalogProxyScope,
source: CatalogProxySource,
raw_url: &str,
) -> Result<(String, Option<String>, Option<String>), GatewayError> {
let raw_url = raw_url.trim();
let mut parsed = Url::parse(raw_url).map_err(|_| catalog_proxy_url_error(scope, source))?;
if !matches!(parsed.scheme(), "http" | "https" | "socks5" | "socks5h")
|| parsed.host_str().is_none()
{
return Err(catalog_proxy_url_error(scope, source));
}
let has_non_root_path = !matches!(parsed.path(), "" | "/");
let has_disallowed_components =
has_non_root_path || parsed.query().is_some() || parsed.fragment().is_some();
if has_disallowed_components && source == CatalogProxySource::Incoming {
return Err(catalog_proxy_url_error(scope, source));
}
let username = (!parsed.username().is_empty() || parsed.password().is_some())
.then(|| decode_catalog_proxy_userinfo(scope, source, parsed.username()))
.transpose()?;
let password = parsed
.password()
.map(|value| decode_catalog_proxy_userinfo(scope, source, value))
.transpose()?
.filter(|value| !value.is_empty());
if username.is_some() || parsed.password().is_some() {
parsed
.set_username("")
.map_err(|_| catalog_proxy_url_error(scope, source))?;
parsed
.set_password(None)
.map_err(|_| catalog_proxy_url_error(scope, source))?;
}
parsed.set_path("");
parsed.set_query(None);
parsed.set_fragment(None);
Ok((parsed.to_string(), username, password))
}
fn stored_catalog_proxy_url_requires_cleanup(raw_url: &str) -> bool {
let Ok(parsed) = Url::parse(raw_url.trim()) else {
return true;
};
!parsed.username().is_empty()
|| parsed.password().is_some()
|| !matches!(parsed.path(), "" | "/")
|| parsed.query().is_some()
|| parsed.fragment().is_some()
}
fn merge_catalog_proxy_url(
scope: CatalogProxyScope,
source: CatalogProxySource,
current: &mut Option<String>,
candidate: String,
) -> Result<(), GatewayError> {
if current
.as_ref()
.is_some_and(|current| current != &candidate)
{
return Err(catalog_proxy_ambiguous_url_error(scope, source));
}
*current = Some(candidate);
Ok(())
}
fn decode_catalog_proxy_userinfo(
scope: CatalogProxyScope,
source: CatalogProxySource,
value: &str,
) -> Result<String, GatewayError> {
percent_decode_str(value)
.decode_utf8()
.map(|value| value.into_owned())
.map_err(|_| catalog_proxy_url_error(scope, source))
}
fn merge_catalog_proxy_url_credential(
scope: CatalogProxyScope,
source: CatalogProxySource,
field: &'static str,
current: &mut Option<String>,
candidate: Option<String>,
) -> Result<(), GatewayError> {
let Some(candidate) = candidate else {
return Ok(());
};
if current
.as_ref()
.is_some_and(|current| current != &candidate)
{
return Err(catalog_proxy_ambiguous_credential_error(
scope, source, field,
));
}
*current = Some(candidate);
Ok(())
}
fn merge_catalog_proxy_credential(
scope: CatalogProxyScope,
record_id: &str,
source: CatalogProxySource,
field: &'static str,
explicit: Option<CatalogProxyCredential>,
from_url: Option<String>,
state: &AppState,
) -> Result<Option<CatalogProxyCredential>, GatewayError> {
let explicit = explicit.filter(|credential| !credential.plaintext.is_empty());
if let (Some(explicit), Some(from_url)) = (explicit.as_ref(), from_url.as_ref()) {
if &explicit.plaintext != from_url {
return Err(catalog_proxy_ambiguous_credential_error(
scope, source, field,
));
}
}
if explicit.is_some() {
return Ok(explicit);
}
let Some(from_url) = from_url.filter(|value| !value.is_empty()) else {
return Ok(None);
};
if from_url.contains('\0')
|| catalog_proxy_secret_is_v2(&from_url)
|| runtime_secret_payload_is_sealed(&from_url)
|| from_url.starts_with("aether-")
|| looks_like_python_fernet_ciphertext(&from_url)
{
return Err(match source {
CatalogProxySource::Incoming => catalog_proxy_credential_error(scope, source, field),
CatalogProxySource::Stored => catalog_proxy_storage_error(scope),
});
}
let protected = seal_catalog_proxy_secret_v2(state, scope, record_id, field, &from_url)
.ok_or_else(|| catalog_proxy_encryption_error(scope))?;
Ok(Some(CatalogProxyCredential {
plaintext: from_url,
protected,
migration_required: source == CatalogProxySource::Stored,
}))
}
fn merge_catalog_proxy_explicit_credential(
scope: CatalogProxyScope,
source: CatalogProxySource,
field: &'static str,
current: &mut Option<CatalogProxyCredential>,
candidate: Option<CatalogProxyCredential>,
) -> Result<(), GatewayError> {
let Some(candidate) = candidate.filter(|credential| !credential.plaintext.is_empty()) else {
return Ok(());
};
let Some(existing) = current.as_mut() else {
*current = Some(candidate);
return Ok(());
};
if existing.plaintext != candidate.plaintext {
return Err(catalog_proxy_ambiguous_credential_error(
scope, source, field,
));
}
existing.migration_required |= candidate.migration_required;
Ok(())
}
fn apply_catalog_proxy_credential(
source: CatalogProxySource,
field: &'static str,
credential: Option<CatalogProxyCredential>,
runtime: &mut Map<String, Value>,
protected: &mut Map<String, Value>,
) -> bool {
let existing = runtime.contains_key(field) || protected.contains_key(field);
let Some(credential) = credential else {
runtime.remove(field);
protected.remove(field);
return existing && source == CatalogProxySource::Stored;
};
runtime.insert(field.to_string(), Value::String(credential.plaintext));
protected.insert(field.to_string(), Value::String(credential.protected));
credential.migration_required
}
fn catalog_proxy_representation_error(
scope: CatalogProxyScope,
source: CatalogProxySource,
) -> GatewayError {
catalog_proxy_error(
scope,
source,
"proxy must be an object, URL string, or null",
)
}
fn catalog_proxy_url_error(scope: CatalogProxyScope, source: CatalogProxySource) -> GatewayError {
catalog_proxy_error(
scope,
source,
"proxy URL must be an http, https, socks5, or socks5h origin without path, query, or fragment",
)
}
fn catalog_proxy_ambiguous_url_error(
scope: CatalogProxyScope,
source: CatalogProxySource,
) -> GatewayError {
catalog_proxy_error(scope, source, "proxy contains conflicting URL fields")
}
fn catalog_proxy_unsupported_sensitive_error(
scope: CatalogProxyScope,
source: CatalogProxySource,
field: &str,
) -> GatewayError {
catalog_proxy_error(
scope,
source,
&format!("proxy contains unsupported sensitive field {field}"),
)
}
fn catalog_proxy_credential_error(
scope: CatalogProxyScope,
source: CatalogProxySource,
field: &'static str,
) -> GatewayError {
catalog_proxy_error(
scope,
source,
&format!("proxy {field} must be gateway-managed plaintext"),
)
}
fn catalog_proxy_ambiguous_credential_error(
scope: CatalogProxyScope,
source: CatalogProxySource,
field: &'static str,
) -> GatewayError {
catalog_proxy_error(
scope,
source,
&format!("proxy URL and proxy {field} contain conflicting credentials"),
)
}
fn catalog_proxy_error(
scope: CatalogProxyScope,
source: CatalogProxySource,
detail: &str,
) -> GatewayError {
match source {
CatalogProxySource::Incoming => GatewayError::Client {
status: StatusCode::BAD_REQUEST,
message: format!("{} {detail}", scope.label()),
},
CatalogProxySource::Stored => catalog_proxy_storage_error(scope),
}
}
fn catalog_proxy_storage_error(scope: CatalogProxyScope) -> GatewayError {
GatewayError::Internal(format!(
"stored {} proxy credentials cannot be decrypted",
scope.label()
))
}
fn catalog_proxy_encryption_error(scope: CatalogProxyScope) -> GatewayError {
GatewayError::Internal(format!(
"{} proxy credential encryption is unavailable",
scope.label()
))
}
fn catalog_proxy_migration_changed_error(scope: CatalogProxyScope) -> GatewayError {
GatewayError::Internal(format!(
"stored {} proxy changed during credential migration",
scope.label()
))
}
fn catalog_proxy_migration_unstable_error(scope: CatalogProxyScope) -> GatewayError {
GatewayError::Internal(format!(
"stored {} proxy credential migration did not stabilize",
scope.label()
))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
};
use serde_json::{json, Value};
use super::{
catalog_proxy_secret_is_v2, open_catalog_proxy_secret_v2, seal_catalog_proxy_secret_v2,
CatalogProxyScope, ENDPOINT_PROXY_PASSWORD_PURPOSE, PROVIDER_PROXY_PASSWORD_PURPOSE,
};
use crate::data::GatewayDataState;
use crate::handlers::shared::seal_runtime_secret_payload;
use crate::{AppState, GatewayError};
fn sample_provider(id: &str, proxy: Option<Value>) -> StoredProviderCatalogProvider {
let mut provider = StoredProviderCatalogProvider::new(
id.to_string(),
format!("Provider {id}"),
Some("https://example.test".to_string()),
"openai".to_string(),
)
.expect("provider should build");
provider.proxy = proxy;
provider
}
fn sample_endpoint(proxy: Option<Value>) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
"endpoint-1".to_string(),
"provider-1".to_string(),
"openai:chat".to_string(),
Some("openai".to_string()),
Some("chat".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.example.test/v1".to_string(),
None,
None,
None,
None,
None,
None,
proxy,
)
.expect("endpoint transport should build")
}
fn sample_key(proxy: Option<Value>) -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"Key 1".to_string(),
"api_key".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
None,
None::<String>,
None,
None,
None,
None,
None,
proxy,
None,
)
.expect("key transport should build")
}
fn state_with_repository(repository: Arc<InMemoryProviderCatalogReadRepository>) -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
fn encryption_state() -> AppState {
AppState::new()
.expect("test state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
}
#[tokio::test]
async fn provider_proxy_credentials_are_sealed_before_repository_write() {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
Vec::new(),
Vec::new(),
Vec::new(),
));
let state = state_with_repository(Arc::clone(&repository));
let provider = sample_provider(
"provider-create",
Some(json!({
"url": "http://alice%40example.test:p%[email protected]:8080",
"enabled": true
})),
);
let created = state
.create_provider_catalog_provider(&provider, None)
.await
.expect("provider create should succeed")
.expect("provider writer should exist");
assert_eq!(
created.proxy.as_ref().unwrap()["username"],
"[email protected]"
);
assert_eq!(created.proxy.as_ref().unwrap()["password"], "p:ss");
assert_eq!(
created.proxy.as_ref().unwrap()["url"],
"http://proxy.example.test:8080/"
);
let stored = repository
.list_providers_by_ids(&["provider-create".to_string()])
.await
.expect("stored provider should load")
.pop()
.expect("stored provider should exist");
let serialized = serde_json::to_string(&stored.proxy).expect("proxy should serialize");
assert!(!serialized.contains("[email protected]"));
assert!(!serialized.contains("p:ss"));
let stored_proxy = stored.proxy.as_ref().expect("stored proxy should exist");
assert!(stored_proxy["username"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
assert!(stored_proxy["password"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
assert_eq!(stored_proxy["url"], "http://proxy.example.test:8080/");
}
#[tokio::test]
async fn legacy_proxy_url_userinfo_is_migrated_with_field_level_cas() {
let provider = sample_provider(
"provider-legacy",
Some(Value::String(
"http://legacy-user:[email protected]:8080".to_string(),
)),
);
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
Vec::new(),
));
let state = state_with_repository(Arc::clone(&repository));
let opened = state
.list_provider_catalog_providers(false)
.await
.expect("legacy provider should migrate")
.pop()
.expect("legacy provider should exist");
assert_eq!(opened.proxy.as_ref().unwrap()["username"], "legacy-user");
assert_eq!(opened.proxy.as_ref().unwrap()["password"], "legacy-pass");
let stored = repository
.list_providers_by_ids(&["provider-legacy".to_string()])
.await
.expect("migrated provider should load")
.pop()
.expect("migrated provider should exist");
let stored_proxy = stored.proxy.as_ref().expect("stored proxy should exist");
assert_eq!(stored_proxy["url"], "http://proxy.example.test:8080/");
assert!(stored_proxy["username"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
assert!(stored_proxy["password"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
}
#[tokio::test]
async fn legacy_unbound_proxy_ciphertext_is_migrated_to_record_bound_v2_with_cas() {
let encryptor = encryption_state();
let legacy_password = seal_runtime_secret_payload(
&encryptor,
PROVIDER_PROXY_PASSWORD_PURPOSE,
"legacy-unbound-password",
)
.expect("legacy password should seal");
let provider = sample_provider(
"provider-legacy-envelope",
Some(json!({
"url": "http://proxy.example.test:8080",
"password": legacy_password
})),
);
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
Vec::new(),
));
let state = state_with_repository(Arc::clone(&repository));
let opened = state
.list_provider_catalog_providers(false)
.await
.expect("legacy ciphertext should migrate")
.pop()
.expect("legacy provider should exist");
assert_eq!(
opened.proxy.as_ref().unwrap()["password"],
"legacy-unbound-password"
);
let stored = repository
.list_providers_by_ids(&["provider-legacy-envelope".to_string()])
.await
.expect("migrated provider should load")
.pop()
.expect("migrated provider should exist");
let migrated = stored.proxy.as_ref().unwrap()["password"]
.as_str()
.expect("migrated password should be a string");
assert!(catalog_proxy_secret_is_v2(migrated));
assert_eq!(
open_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Provider,
"provider-legacy-envelope",
"password",
migrated,
)
.as_deref(),
Some("legacy-unbound-password")
);
}
#[tokio::test]
async fn record_bound_proxy_ciphertext_copied_to_another_record_fails_closed() {
let state = encryption_state();
let protected = state
.protect_provider_catalog_provider(&sample_provider(
"provider-source",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": "bound-user",
"password": "bound-password"
})),
))
.expect("source proxy should seal");
let copied = sample_provider("provider-target", protected.proxy.clone());
let error = state
.open_provider_catalog_provider(copied)
.await
.expect_err("cross-record ciphertext copy must fail closed");
assert!(matches!(error, GatewayError::Internal(_)));
}
#[test]
fn provider_endpoint_and_key_proxy_credentials_use_distinct_purposes() {
let state = encryption_state();
let proxy = Some(json!({
"url": "socks5h://proxy.example.test:1080",
"username": "purpose-user",
"password": "purpose-password"
}));
let provider = state
.protect_provider_catalog_provider(&sample_provider("provider-1", proxy.clone()))
.expect("provider proxy should seal");
let endpoint = state
.protect_provider_catalog_endpoint(&sample_endpoint(proxy.clone()))
.expect("endpoint proxy should seal");
let key = state
.protect_provider_catalog_key(&sample_key(proxy))
.expect("key proxy should seal");
let provider_password = provider.proxy.as_ref().unwrap()["password"]
.as_str()
.unwrap();
let endpoint_password = endpoint.proxy.as_ref().unwrap()["password"]
.as_str()
.unwrap();
let key_password = key.proxy.as_ref().unwrap()["password"].as_str().unwrap();
assert_eq!(
open_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Provider,
"provider-1",
"password",
provider_password,
)
.as_deref(),
Some("purpose-password")
);
assert!(open_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Endpoint,
"endpoint-1",
"password",
provider_password,
)
.is_none());
assert_eq!(
open_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Endpoint,
"endpoint-1",
"password",
endpoint_password,
)
.as_deref(),
Some("purpose-password")
);
assert_eq!(
open_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Key,
"key-1",
"password",
key_password,
)
.as_deref(),
Some("purpose-password")
);
}
#[tokio::test]
async fn proxy_url_string_uses_runtime_compatible_object_shape() {
let state = encryption_state();
let protected = state
.protect_provider_catalog_provider(&sample_provider(
"provider-string",
Some(Value::String("http://proxy.example.test:8080".to_string())),
))
.expect("proxy string should normalize");
assert_eq!(
protected.proxy,
Some(json!({"url": "http://proxy.example.test:8080/"}))
);
let opened = state
.open_provider_catalog_provider(sample_provider(
"provider-legacy-string",
Some(Value::String("http://proxy.example.test:8080".to_string())),
))
.await
.expect("credential-free legacy proxy string should open without a writer");
assert_eq!(
opened.proxy,
Some(json!({"url": "http://proxy.example.test:8080/"}))
);
}
#[tokio::test]
async fn proxy_credentials_preserve_significant_whitespace() {
let state = encryption_state();
let protected = state
.protect_provider_catalog_provider(&sample_provider(
"provider-spaces",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": " alice ",
"password": " pass phrase "
})),
))
.expect("proxy credentials should seal");
let stored = serde_json::to_string(&protected.proxy).expect("proxy should serialize");
assert!(!stored.contains(" alice "));
assert!(!stored.contains(" pass phrase "));
let opened = state
.open_provider_catalog_provider(protected)
.await
.expect("sealed proxy credentials should open");
assert_eq!(opened.proxy.as_ref().unwrap()["username"], " alice ");
assert_eq!(opened.proxy.as_ref().unwrap()["password"], " pass phrase ");
}
#[tokio::test]
async fn proxy_credential_aliases_are_migrated_to_encrypted_canonical_fields() {
let state = encryption_state();
let protected = state
.protect_provider_catalog_provider(&sample_provider(
"provider-aliases",
Some(json!({
"proxy_url": "socks5h://proxy.example.test:1080",
"proxy_auth": {
"proxy_user": "alias-user",
"proxy_passphrase": "alias-password"
},
"region": "test"
})),
))
.expect("supported proxy credential aliases should normalize");
let stored_proxy = protected.proxy.as_ref().expect("proxy should exist");
assert_eq!(stored_proxy["url"], "socks5h://proxy.example.test:1080");
assert_eq!(stored_proxy["region"], "test");
assert!(stored_proxy.get("proxy_url").is_none());
assert!(stored_proxy.get("proxy_auth").is_none());
assert!(stored_proxy["username"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
assert!(stored_proxy["password"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
let serialized = stored_proxy.to_string();
assert!(!serialized.contains("alias-user"));
assert!(!serialized.contains("alias-password"));
let opened = state
.open_provider_catalog_provider(protected)
.await
.expect("canonical proxy credentials should open");
assert_eq!(opened.proxy.as_ref().unwrap()["username"], "alias-user");
assert_eq!(opened.proxy.as_ref().unwrap()["password"], "alias-password");
}
#[test]
fn unsupported_proxy_sensitive_fields_fail_closed() {
let state = encryption_state();
for (id, proxy) in [
(
"provider-token",
json!({
"url": "http://proxy.example.test:8080",
"token": "must-not-persist"
}),
),
(
"provider-nested-secret",
json!({
"url": "http://proxy.example.test:8080",
"options": {"clientSecret": "must-not-enter-extra"}
}),
),
(
"provider-header-map",
json!({
"url": "http://proxy.example.test:8080",
"headers": {"x-custom-auth": "must-not-enter-extra"}
}),
),
(
"provider-nested-url",
json!({
"url": "http://proxy.example.test:8080",
"options": {
"health_url": "https://alice:[email protected]/?token=secret"
}
}),
),
] {
let error = state
.protect_provider_catalog_provider(&sample_provider(id, Some(proxy)))
.expect_err("unsupported sensitive proxy fields must be rejected");
assert!(matches!(error, GatewayError::Client { .. }));
}
}
#[test]
fn incoming_proxy_url_is_origin_only_and_scheme_allowlisted() {
let state = encryption_state();
for (id, url) in [
("provider-ftp", "ftp://proxy.example.test:21"),
("provider-path", "http://proxy.example.test:8080/path"),
(
"provider-query",
"http://proxy.example.test:8080/?token=secret",
),
(
"provider-fragment",
"socks5://proxy.example.test:1080/#secret",
),
] {
let error = state
.protect_provider_catalog_provider(&sample_provider(id, Some(json!({"url": url}))))
.expect_err("non-origin proxy URL must be rejected");
assert!(matches!(error, GatewayError::Client { .. }));
}
}
#[tokio::test]
async fn stored_proxy_url_components_are_removed_with_cas_migration() {
let provider = sample_provider(
"provider-url-cleanup",
Some(json!({
"url": "http://legacy-user:[email protected]:8080/path?token=secret#fragment"
})),
);
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
Vec::new(),
));
let state = state_with_repository(Arc::clone(&repository));
let opened = state
.list_provider_catalog_providers(false)
.await
.expect("legacy proxy URL should migrate")
.pop()
.expect("provider should exist");
assert_eq!(
opened.proxy.as_ref().unwrap()["url"],
"http://proxy.example.test:8080/"
);
assert_eq!(opened.proxy.as_ref().unwrap()["username"], "legacy-user");
assert_eq!(opened.proxy.as_ref().unwrap()["password"], "legacy-pass");
let stored = repository
.list_providers_by_ids(&["provider-url-cleanup".to_string()])
.await
.expect("migrated provider should load")
.pop()
.expect("migrated provider should exist");
let serialized = stored.proxy.expect("stored proxy should exist").to_string();
for secret in ["legacy-user", "legacy-pass", "token=secret", "fragment"] {
assert!(!serialized.contains(secret));
}
}
#[tokio::test]
async fn password_only_proxy_credentials_remain_compatible() {
let state = encryption_state();
let protected = state
.protect_provider_catalog_provider(&sample_provider(
"provider-password-only",
Some(json!({
"url": "http://proxy.example.test:8080",
"password": "legacy-password"
})),
))
.expect("password-only proxy should seal");
assert!(protected.proxy.as_ref().unwrap().get("username").is_none());
assert!(!protected
.proxy
.as_ref()
.unwrap()
.to_string()
.contains("legacy-password"));
let opened = state
.open_provider_catalog_provider(protected)
.await
.expect("password-only proxy should open");
assert!(opened.proxy.as_ref().unwrap().get("username").is_none());
assert_eq!(
opened.proxy.as_ref().unwrap()["password"],
"legacy-password"
);
let protected_from_url = state
.protect_provider_catalog_provider(&sample_provider(
"provider-password-only-url",
Some(Value::String(
"http://:[email protected]:8080".to_string(),
)),
))
.expect("password-only URL userinfo should normalize");
let stored_proxy = protected_from_url.proxy.as_ref().unwrap();
assert_eq!(stored_proxy["url"], "http://proxy.example.test:8080/");
assert!(stored_proxy.get("username").is_none());
assert!(stored_proxy["password"]
.as_str()
.is_some_and(catalog_proxy_secret_is_v2));
assert!(!stored_proxy.to_string().contains("url-password"));
}
#[tokio::test]
async fn damaged_or_wrong_purpose_stored_proxy_ciphertext_fails_closed() {
let encryptor = encryption_state();
let wrong_purpose = seal_runtime_secret_payload(
&encryptor,
ENDPOINT_PROXY_PASSWORD_PURPOSE,
"must-not-open-as-provider-password",
)
.expect("test ciphertext should seal");
let providers = vec![
sample_provider(
"provider-wrong-purpose",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": "alice",
"password": wrong_purpose
})),
),
sample_provider(
"provider-damaged",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": "alice",
"password": "aether-runtime-secret-v1:not-a-fernet-token"
})),
),
sample_provider(
"provider-foreign-envelope",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": "alice",
"password": "aether-payment-gateway-secret-v2:foreign"
})),
),
];
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
providers,
Vec::new(),
Vec::new(),
));
let state = state_with_repository(repository);
for provider_id in [
"provider-wrong-purpose",
"provider-damaged",
"provider-foreign-envelope",
] {
let error = state
.read_provider_catalog_providers_by_ids(&[provider_id.to_string()])
.await
.expect_err("invalid stored ciphertext must fail closed");
assert!(matches!(&error, GatewayError::Internal(_)));
assert!(!error.into_message().contains("must-not-open"));
}
}
#[tokio::test]
async fn incoming_proxy_ciphertext_and_ambiguous_userinfo_are_rejected() {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
Vec::new(),
Vec::new(),
Vec::new(),
));
let state = state_with_repository(Arc::clone(&repository));
let forged = sample_provider(
"provider-forged",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": "alice",
"password": "aether-runtime-secret-v1:not-client-controlled"
})),
);
let ambiguous = sample_provider(
"provider-ambiguous",
Some(json!({
"url": "http://url-user:[email protected]:8080",
"username": "different-user",
"password": "url-pass"
})),
);
let foreign_envelope = sample_provider(
"provider-foreign-envelope",
Some(json!({
"url": "http://proxy.example.test:8080",
"username": "alice",
"password": "aether-payment-gateway-secret-v2:foreign"
})),
);
for provider in [&forged, &foreign_envelope, &ambiguous] {
let error = state
.create_provider_catalog_provider(provider, None)
.await
.expect_err("untrusted proxy credential representation must be rejected");
assert!(matches!(&error, GatewayError::Client { .. }));
}
assert!(repository
.list_providers(false)
.await
.expect("repository should remain readable")
.is_empty());
}
#[test]
fn provider_username_envelope_is_bound_separately_from_password() {
let state = encryption_state();
let sealed = seal_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Provider,
"provider-field-binding",
"username",
"separate-user",
)
.expect("username should seal");
assert!(open_catalog_proxy_secret_v2(
&state,
CatalogProxyScope::Provider,
"provider-field-binding",
"password",
&sealed,
)
.is_none());
}
}