Files
Aether/apps/aether-gateway/src/handlers/shared/provider_ops_credential.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

382 lines
14 KiB
Rust

use aether_crypto::looks_like_python_fernet_ciphertext;
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::AppState;
use super::{
decrypt_catalog_secret_with_fallbacks, open_runtime_secret_payload, seal_runtime_secret_payload,
};
const PROVIDER_OPS_CREDENTIAL_ENVELOPE_FAMILY: &str = "aether-provider-ops-credential-";
const PROVIDER_OPS_CREDENTIAL_ENVELOPE_V2: &str = "aether-provider-ops-credential-v2:";
const PROVIDER_OPS_CREDENTIAL_PURPOSE_V2: &str = "provider-ops-credential-bound-v2";
pub(crate) const PROVIDER_OPS_PERSISTENT_SECRET_FIELDS: &[&str] = &[
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
];
pub(crate) const PROVIDER_OPS_TRANSIENT_SECRET_FIELDS: &[&str] = &["_cached_access_token"];
pub(crate) const PROVIDER_OPS_TRANSIENT_METADATA_FIELDS: &[&str] = &["_cached_token_expires_at"];
pub(crate) fn provider_ops_credential_field_is_secret(field: &str) -> bool {
PROVIDER_OPS_PERSISTENT_SECRET_FIELDS.contains(&field)
|| PROVIDER_OPS_TRANSIENT_SECRET_FIELDS.contains(&field)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProviderOpsCanonicalDestination {
canonical_base_url: String,
canonical_origin: String,
}
impl ProviderOpsCanonicalDestination {
pub(crate) fn base_url(&self) -> &str {
&self.canonical_base_url
}
pub(crate) fn origin(&self) -> &str {
&self.canonical_origin
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProviderOpsCredentialBinding {
pub(crate) provider_id: String,
pub(crate) architecture_id: String,
pub(crate) auth_type: String,
pub(crate) destination: ProviderOpsCanonicalDestination,
pub(crate) outbound_policy_digest: String,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ProviderOpsCredentialProjection {
pub(crate) plaintext: String,
pub(crate) protected: String,
pub(crate) migration_required: bool,
}
pub(crate) fn canonicalize_provider_ops_base_url(
raw: &str,
) -> Result<ProviderOpsCanonicalDestination, &'static str> {
let raw = raw.trim();
if raw.is_empty() {
return Err("Provider Ops base_url 不能为空");
}
let mut parsed = url::Url::parse(raw).map_err(|_| "Provider Ops base_url 无效")?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err("Provider Ops base_url 必须是有效的 HTTP(S) URL");
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("Provider Ops base_url 不允许包含认证信息");
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err("Provider Ops base_url 不允许包含 query 或 fragment");
}
let normalized_path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(if normalized_path.is_empty() {
"/"
} else {
normalized_path.as_str()
});
let canonical_origin = parsed.origin().ascii_serialization();
let mut canonical_base_url = parsed.to_string();
if parsed.path() == "/" {
canonical_base_url.truncate(canonical_base_url.len().saturating_sub(1));
}
Ok(ProviderOpsCanonicalDestination {
canonical_base_url,
canonical_origin,
})
}
pub(crate) fn resolve_provider_ops_same_origin_url(
destination: &ProviderOpsCanonicalDestination,
endpoint: &str,
) -> Result<String, &'static str> {
let endpoint = endpoint.trim();
if endpoint.is_empty() {
return Ok(destination.canonical_base_url.clone());
}
if endpoint.starts_with("//") {
return Err("Provider Ops endpoint 不允许使用 scheme-relative URL");
}
let candidate = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
url::Url::parse(endpoint).map_err(|_| "Provider Ops endpoint URL 无效")?
} else {
if !endpoint.starts_with('/') {
return Err("Provider Ops endpoint 必须是以 / 开头的路径或同源绝对 URL");
}
let base = url::Url::parse(&format!("{}/", destination.canonical_origin))
.map_err(|_| "Provider Ops canonical origin 无效")?;
base.join(endpoint)
.map_err(|_| "Provider Ops endpoint 路径无效")?
};
if !matches!(candidate.scheme(), "http" | "https")
|| candidate.host_str().is_none()
|| !candidate.username().is_empty()
|| candidate.password().is_some()
|| candidate.fragment().is_some()
{
return Err("Provider Ops endpoint URL 无效");
}
if candidate.origin().ascii_serialization() != destination.canonical_origin {
return Err("Provider Ops endpoint 必须与 base_url 同源");
}
Ok(candidate.to_string())
}
pub(crate) fn provider_ops_outbound_policy_digest(
architecture_id: &str,
auth_type: &str,
destination: &ProviderOpsCanonicalDestination,
connector_config: Option<&Value>,
actions: Option<&Value>,
) -> String {
let policy = serde_json::json!({
"architecture_id": architecture_id,
"auth_type": auth_type,
"canonical_base_url": destination.base_url(),
"canonical_origin": destination.origin(),
"connector_config": connector_config.cloned().unwrap_or_else(|| serde_json::json!({})),
"actions": actions.cloned().unwrap_or_else(|| serde_json::json!({})),
});
let mut canonical = String::new();
append_canonical_json(&policy, &mut canonical);
format!("{:x}", Sha256::digest(canonical.as_bytes()))
}
pub(crate) fn provider_ops_credential_binding_from_config(
provider_id: &str,
provider_ops_config: &serde_json::Map<String, Value>,
effective_base_url: &str,
) -> Result<ProviderOpsCredentialBinding, &'static str> {
if provider_id.trim().is_empty() {
return Err("Provider Ops provider_id 不能为空");
}
let raw_architecture_id = provider_ops_config
.get("architecture_id")
.and_then(Value::as_str)
.unwrap_or("generic_api")
.trim();
let architecture_id =
aether_admin::provider::ops::normalize_architecture_id(raw_architecture_id);
if !raw_architecture_id.is_empty() && raw_architecture_id != architecture_id {
return Err("Provider Ops architecture_id 无效");
}
let connector = provider_ops_config
.get("connector")
.and_then(Value::as_object);
let auth_type = connector
.and_then(|connector| connector.get("auth_type"))
.and_then(Value::as_str)
.unwrap_or("api_key")
.trim();
if !aether_admin::provider::ops::admin_provider_ops_is_supported_auth_type(auth_type) {
return Err("Provider Ops connector.auth_type 无效");
}
let destination = canonicalize_provider_ops_base_url(effective_base_url)?;
let outbound_policy_digest = provider_ops_outbound_policy_digest(
architecture_id,
auth_type,
&destination,
connector.and_then(|connector| connector.get("config")),
provider_ops_config.get("actions"),
);
Ok(ProviderOpsCredentialBinding {
provider_id: provider_id.to_string(),
architecture_id: architecture_id.to_string(),
auth_type: auth_type.to_string(),
destination,
outbound_policy_digest,
})
}
pub(crate) fn seal_provider_ops_credential(
state: &AppState,
binding: &ProviderOpsCredentialBinding,
field: &str,
plaintext: &str,
) -> Result<String, &'static str> {
if plaintext.contains('\0') {
return Err("Provider Ops credential 包含保留分隔符");
}
let purpose = provider_ops_credential_purpose(binding, field)?;
let sealed = seal_runtime_secret_payload(state, &purpose, plaintext)
.ok_or("gateway 未配置 Provider Ops 加密密钥")?;
Ok(format!("{PROVIDER_OPS_CREDENTIAL_ENVELOPE_V2}{sealed}"))
}
pub(crate) fn open_provider_ops_credential(
state: &AppState,
binding: &ProviderOpsCredentialBinding,
field: &str,
stored: &str,
) -> Result<ProviderOpsCredentialProjection, &'static str> {
let purpose = provider_ops_credential_purpose(binding, field)?;
if let Some(sealed) = stored.strip_prefix(PROVIDER_OPS_CREDENTIAL_ENVELOPE_V2) {
let plaintext = open_runtime_secret_payload(state, &purpose, sealed)
.ok_or("Provider Ops credential 认证或绑定校验失败")?;
if plaintext.contains('\0') {
return Err("Provider Ops credential 包含保留分隔符");
}
return Ok(ProviderOpsCredentialProjection {
plaintext,
protected: stored.to_string(),
migration_required: false,
});
}
if stored.starts_with(PROVIDER_OPS_CREDENTIAL_ENVELOPE_FAMILY) {
return Err("不支持的 Provider Ops credential envelope 版本");
}
if stored.starts_with("aether-") {
return Err("Aether secret envelope 的 Provider Ops 记录绑定错误");
}
let plaintext = if looks_like_python_fernet_ciphertext(stored) {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), stored)
.ok_or("历史 Provider Ops credential 密文无法解密")?
} else {
stored.to_string()
};
if plaintext.contains('\0') {
return Err("历史 Provider Ops credential 包含保留分隔符");
}
let protected = seal_provider_ops_credential(state, binding, field, &plaintext)?;
Ok(ProviderOpsCredentialProjection {
plaintext,
protected,
migration_required: !stored.is_empty(),
})
}
fn provider_ops_credential_purpose(
binding: &ProviderOpsCredentialBinding,
field: &str,
) -> Result<String, &'static str> {
for value in [
binding.provider_id.as_str(),
binding.architecture_id.as_str(),
binding.auth_type.as_str(),
binding.destination.base_url(),
binding.destination.origin(),
binding.outbound_policy_digest.as_str(),
field,
] {
if value.is_empty() || value.contains('\0') {
return Err("Provider Ops credential binding 无效");
}
}
Ok(format!(
"{PROVIDER_OPS_CREDENTIAL_PURPOSE_V2}\0provider-id-bytes={}\0{}\0architecture-id-bytes={}\0{}\0auth-type-bytes={}\0{}\0base-url-bytes={}\0{}\0origin-bytes={}\0{}\0policy-sha256={}\0field-bytes={}\0{}",
binding.provider_id.len(),
binding.provider_id,
binding.architecture_id.len(),
binding.architecture_id,
binding.auth_type.len(),
binding.auth_type,
binding.destination.base_url().len(),
binding.destination.base_url(),
binding.destination.origin().len(),
binding.destination.origin(),
binding.outbound_policy_digest,
field.len(),
field,
))
}
fn append_canonical_json(value: &Value, output: &mut String) {
match value {
Value::Null => output.push_str("null"),
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => output.push_str(&value.to_string()),
Value::String(value) => output.push_str(
&serde_json::to_string(value).expect("serializing a JSON string cannot fail"),
),
Value::Array(items) => {
output.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
output.push(',');
}
append_canonical_json(item, output);
}
output.push(']');
}
Value::Object(map) => {
output.push('{');
let mut keys = map.keys().collect::<Vec<_>>();
keys.sort_unstable();
for (index, key) in keys.into_iter().enumerate() {
if index > 0 {
output.push(',');
}
output.push_str(
&serde_json::to_string(key).expect("serializing a JSON key cannot fail"),
);
output.push(':');
append_canonical_json(&map[key], output);
}
output.push('}');
}
}
}
#[cfg(test)]
mod tests {
use super::{
canonicalize_provider_ops_base_url, provider_ops_outbound_policy_digest,
resolve_provider_ops_same_origin_url,
};
#[test]
fn canonical_destination_normalizes_and_enforces_origin() {
let destination = canonicalize_provider_ops_base_url(" HTTPS://Example.COM:443/api/ ")
.expect("base URL should normalize");
assert_eq!(destination.base_url(), "https://example.com/api");
assert_eq!(destination.origin(), "https://example.com");
assert!(resolve_provider_ops_same_origin_url(&destination, "/v1/me").is_ok());
assert!(
resolve_provider_ops_same_origin_url(&destination, "https://example.com/v1/me").is_ok()
);
assert!(
resolve_provider_ops_same_origin_url(&destination, "https://evil.test/v1/me").is_err()
);
assert!(resolve_provider_ops_same_origin_url(&destination, "//evil.test/v1/me").is_err());
}
#[test]
fn policy_digest_is_independent_of_json_object_order() {
let destination = canonicalize_provider_ops_base_url("https://example.com")
.expect("base URL should normalize");
let left = serde_json::json!({"b": 2, "a": 1});
let right = serde_json::json!({"a": 1, "b": 2});
assert_eq!(
provider_ops_outbound_policy_digest(
"generic_api",
"api_key",
&destination,
Some(&left),
None,
),
provider_ops_outbound_policy_digest(
"generic_api",
"api_key",
&destination,
Some(&right),
None,
)
);
}
}