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.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
@@ -163,7 +163,7 @@ pub struct ManagementTokenListQuery {
pub limit: usize,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateManagementTokenRecord {
pub id: String,
pub user_id: String,
@@ -178,6 +178,46 @@ pub struct CreateManagementTokenRecord {
pub is_active: bool,
}
impl std::fmt::Debug for CreateManagementTokenRecord {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CreateManagementTokenRecord")
.field("id", &self.id)
.field("user_id", &self.user_id)
.field("token_hash", &"[REDACTED]")
.field(
"token_prefix",
&self.token_prefix.as_ref().map(|_| "[REDACTED]"),
)
.field("name", &self.name)
.field("expires_at_unix_secs", &self.expires_at_unix_secs)
.field("is_active", &self.is_active)
.finish_non_exhaustive()
}
}
fn validate_management_token_unix_secs_storage_range(
value: u64,
field_name: &str,
) -> Result<(), crate::DataLayerError> {
if i64::try_from(value).is_err() {
return Err(crate::DataLayerError::InvalidInput(format!(
"{field_name} exceeds supported storage range"
)));
}
Ok(())
}
fn validate_optional_management_token_unix_secs_storage_range(
value: Option<u64>,
field_name: &str,
) -> Result<(), crate::DataLayerError> {
match value {
Some(value) => validate_management_token_unix_secs_storage_range(value, field_name),
None => Ok(()),
}
}
impl CreateManagementTokenRecord {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if self.id.trim().is_empty() {
@@ -223,6 +263,10 @@ impl CreateManagementTokenRecord {
}
}
validate_management_token_permissions(self.permissions.as_ref())?;
validate_optional_management_token_unix_secs_storage_range(
self.expires_at_unix_secs,
"expires_at_unix_secs",
)?;
Ok(())
}
}
@@ -248,6 +292,21 @@ impl UpdateManagementTokenRecord {
"token_id is required".to_string(),
));
}
if self.clear_description && self.description.is_some() {
return Err(crate::DataLayerError::InvalidInput(
"description and clear_description are mutually exclusive".to_string(),
));
}
if self.clear_allowed_ips && self.allowed_ips.is_some() {
return Err(crate::DataLayerError::InvalidInput(
"allowed_ips and clear_allowed_ips are mutually exclusive".to_string(),
));
}
if self.clear_expires_at && self.expires_at_unix_secs.is_some() {
return Err(crate::DataLayerError::InvalidInput(
"expires_at_unix_secs and clear_expires_at are mutually exclusive".to_string(),
));
}
if let Some(name) = &self.name {
if name.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(
@@ -273,6 +332,10 @@ impl UpdateManagementTokenRecord {
}
}
validate_management_token_permissions(self.permissions.as_ref())?;
validate_optional_management_token_unix_secs_storage_range(
self.expires_at_unix_secs,
"expires_at_unix_secs",
)?;
Ok(())
}
}
@@ -301,13 +364,127 @@ fn validate_management_token_permissions(
Ok(())
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RegenerateManagementTokenSecret {
pub token_id: String,
pub token_hash: String,
pub token_prefix: Option<String>,
}
impl std::fmt::Debug for RegenerateManagementTokenSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RegenerateManagementTokenSecret")
.field("token_id", &self.token_id)
.field("token_hash", &"[REDACTED]")
.field(
"token_prefix",
&self.token_prefix.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ActivateManagementTokenIfMatches {
pub expected_token: StoredManagementToken,
pub token_hash: String,
pub expected_user_security_version: i64,
pub now_unix_secs: u64,
}
impl std::fmt::Debug for ActivateManagementTokenIfMatches {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ActivateManagementTokenIfMatches")
.field("expected_token", &self.expected_token)
.field("token_hash", &"[REDACTED]")
.field(
"expected_user_security_version",
&self.expected_user_security_version,
)
.field("now_unix_secs", &self.now_unix_secs)
.finish()
}
}
impl ActivateManagementTokenIfMatches {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if self.expected_token.id.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(
"token_id is required".to_string(),
));
}
if self.expected_token.user_id.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(
"user_id is required".to_string(),
));
}
if self.expected_token.name.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(
"token name is required".to_string(),
));
}
if self.expected_token.is_active {
return Err(crate::DataLayerError::InvalidInput(
"activation snapshot must be inactive".to_string(),
));
}
if self.token_hash.trim().is_empty() {
return Err(crate::DataLayerError::InvalidInput(
"token_hash is required".to_string(),
));
}
if self.expected_user_security_version < 0 {
return Err(crate::DataLayerError::InvalidInput(
"expected_user_security_version must not be negative".to_string(),
));
}
validate_management_token_unix_secs_storage_range(self.now_unix_secs, "now_unix_secs")?;
validate_optional_management_token_unix_secs_storage_range(
self.expected_token.expires_at_unix_secs,
"expires_at_unix_secs",
)?;
validate_optional_management_token_unix_secs_storage_range(
self.expected_token.last_used_at_unix_secs,
"last_used_at_unix_secs",
)?;
validate_optional_management_token_unix_secs_storage_range(
self.expected_token.created_at_unix_ms,
"created_at_unix_ms",
)?;
validate_optional_management_token_unix_secs_storage_range(
self.expected_token.updated_at_unix_secs,
"updated_at_unix_secs",
)?;
validate_management_token_unix_secs_storage_range(
self.expected_token.usage_count,
"usage_count",
)?;
if let Some(allowed_ips) = &self.expected_token.allowed_ips {
let Some(items) = allowed_ips.as_array() else {
return Err(crate::DataLayerError::InvalidInput(
"IP 限制规则必须是数组".to_string(),
));
};
if items.is_empty() || items.iter().any(|value| value.as_str().is_none()) {
return Err(crate::DataLayerError::InvalidInput(
"IP 限制规则只能是非空字符串数组".to_string(),
));
}
}
validate_management_token_permissions(self.expected_token.permissions.as_ref())
}
pub fn matches_locked_token_snapshot(
&self,
current: &StoredManagementToken,
current_token_hash: &str,
) -> bool {
current_token_hash == self.token_hash && current == &self.expected_token
}
}
impl RegenerateManagementTokenSecret {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if self.token_id.trim().is_empty() {
@@ -360,22 +537,277 @@ pub trait ManagementTokenWriteRepository: Send + Sync {
record: &UpdateManagementTokenRecord,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
/// Update a token only while it still belongs to `user_id`.
///
/// Self-service callers must use this owner-scoped mutation instead of
/// relying on a preceding read-side ownership check.
async fn update_management_token_for_user(
&self,
record: &UpdateManagementTokenRecord,
user_id: &str,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
async fn delete_management_token(&self, token_id: &str) -> Result<bool, crate::DataLayerError>;
async fn delete_management_token_for_user(
&self,
token_id: &str,
user_id: &str,
) -> Result<bool, crate::DataLayerError>;
async fn set_management_token_active(
&self,
token_id: &str,
is_active: bool,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
async fn set_management_token_active_for_user(
&self,
token_id: &str,
user_id: &str,
is_active: bool,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
/// Atomically activate an inactive one-time-install token only while all
/// security-relevant fields still match the session that issued it.
async fn activate_management_token_if_matches(
&self,
mutation: &ActivateManagementTokenIfMatches,
) -> Result<bool, crate::DataLayerError>;
/// Delete an unclaimed one-time-install token only while it still matches
/// the session that created it and remains inactive.
async fn delete_inactive_management_token_if_matches(
&self,
mutation: &ActivateManagementTokenIfMatches,
) -> Result<bool, crate::DataLayerError>;
async fn regenerate_management_token_secret(
&self,
mutation: &RegenerateManagementTokenSecret,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
async fn regenerate_management_token_secret_for_user(
&self,
mutation: &RegenerateManagementTokenSecret,
user_id: &str,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
async fn record_management_token_usage(
&self,
token_id: &str,
last_used_ip: Option<&str>,
) -> Result<Option<StoredManagementToken>, crate::DataLayerError>;
}
#[cfg(test)]
mod tests {
use super::{
ActivateManagementTokenIfMatches, CreateManagementTokenRecord,
RegenerateManagementTokenSecret, StoredManagementTokenUserSummary,
UpdateManagementTokenRecord,
};
fn activation() -> ActivateManagementTokenIfMatches {
ActivateManagementTokenIfMatches {
expected_token: super::StoredManagementToken::new(
"token-1".to_string(),
"user-1".to_string(),
"install token".to_string(),
)
.expect("token should build")
.with_display_fields(None, Some("ae_install".to_string()), None)
.with_permissions(Some(serde_json::json!(["admin:proxy_nodes:write"])))
.with_runtime_fields(Some(2_000_000_000), None, None, 0, false)
.with_timestamps(Some(1_800_000_000), Some(1_800_000_000)),
token_hash: "hash-1".to_string(),
expected_user_security_version: 7,
now_unix_secs: 1_900_000_000,
}
}
#[test]
fn activation_rejects_timestamps_outside_sql_storage_range() {
let mut mutation = activation();
mutation.expected_token.expires_at_unix_secs = Some(i64::MAX as u64 + 1);
assert!(mutation.validate().is_err());
let mut mutation = activation();
mutation.now_unix_secs = i64::MAX as u64 + 1;
assert!(mutation.validate().is_err());
}
#[test]
fn activation_requires_non_negative_user_security_version() {
let mut mutation = activation();
mutation.expected_user_security_version = -1;
assert!(mutation.validate().is_err());
}
#[test]
fn activation_matches_the_complete_canonical_token_snapshot() {
let mutation = activation();
assert!(
mutation.matches_locked_token_snapshot(&mutation.expected_token, &mutation.token_hash,)
);
let mut changed = mutation.expected_token.clone();
changed.description = Some("changed after session creation".to_string());
assert!(!mutation.matches_locked_token_snapshot(&changed, &mutation.token_hash));
let mut changed = mutation.expected_token.clone();
changed.updated_at_unix_secs = changed.updated_at_unix_secs.map(|value| value + 1);
assert!(!mutation.matches_locked_token_snapshot(&changed, &mutation.token_hash));
assert!(
!mutation.matches_locked_token_snapshot(&mutation.expected_token, "different-hash",)
);
}
#[test]
fn management_token_secret_debug_output_is_redacted() {
let activation = ActivateManagementTokenIfMatches {
token_hash: "activation-token-hash-canary".to_string(),
..activation()
};
let activation_debug = format!("{activation:?}");
assert!(activation_debug.contains("[REDACTED]"));
assert!(!activation_debug.contains("activation-token-hash-canary"));
let regenerate = RegenerateManagementTokenSecret {
token_id: "token-1".to_string(),
token_hash: "regenerate-token-hash-canary".to_string(),
token_prefix: Some("regenerate-token-prefix-canary".to_string()),
};
let regenerate_debug = format!("{regenerate:?}");
assert!(regenerate_debug.contains("[REDACTED]"));
assert!(!regenerate_debug.contains("regenerate-token-hash-canary"));
assert!(!regenerate_debug.contains("regenerate-token-prefix-canary"));
let user = StoredManagementTokenUserSummary::new(
"user-1".to_string(),
None,
"admin".to_string(),
"admin".to_string(),
)
.expect("user summary should build");
let create = CreateManagementTokenRecord {
id: "token-1".to_string(),
user_id: user.id.clone(),
user,
token_hash: "create-token-hash-canary".to_string(),
token_prefix: Some("create-token-prefix-canary".to_string()),
name: "token".to_string(),
description: None,
allowed_ips: None,
permissions: None,
expires_at_unix_secs: None,
is_active: true,
};
let create_debug = format!("{create:?}");
assert!(create_debug.contains("[REDACTED]"));
assert!(!create_debug.contains("create-token-hash-canary"));
assert!(!create_debug.contains("create-token-prefix-canary"));
}
#[test]
fn activation_rejects_json_null_without_conflating_it_with_sql_null() {
let mutation = activation();
let mut json_null_allowed_ips = mutation.expected_token.clone();
json_null_allowed_ips.allowed_ips = Some(serde_json::Value::Null);
assert!(
!mutation.matches_locked_token_snapshot(&json_null_allowed_ips, &mutation.token_hash)
);
let mut invalid = mutation.clone();
invalid.expected_token = json_null_allowed_ips;
assert!(invalid.validate().is_err());
let mut json_null_permissions = mutation.expected_token.clone();
json_null_permissions.permissions = Some(serde_json::Value::Null);
assert!(
!mutation.matches_locked_token_snapshot(&json_null_permissions, &mutation.token_hash)
);
let mut invalid = mutation;
invalid.expected_token = json_null_permissions;
assert!(invalid.validate().is_err());
}
#[test]
fn create_and_update_reject_expiry_outside_sql_storage_range() {
let unsupported_expiry = i64::MAX as u64 + 1;
let user = StoredManagementTokenUserSummary::new(
"user-1".to_string(),
None,
"admin".to_string(),
"admin".to_string(),
)
.expect("user summary should build");
let create = CreateManagementTokenRecord {
id: "token-1".to_string(),
user_id: user.id.clone(),
user,
token_hash: "hash-1".to_string(),
token_prefix: Some("ae_1234".to_string()),
name: "token".to_string(),
description: None,
allowed_ips: None,
permissions: Some(serde_json::json!(["admin:proxy_nodes:write"])),
expires_at_unix_secs: Some(unsupported_expiry),
is_active: false,
};
assert!(create.validate().is_err());
let update = UpdateManagementTokenRecord {
token_id: "token-1".to_string(),
name: None,
description: None,
clear_description: false,
allowed_ips: None,
clear_allowed_ips: false,
permissions: None,
expires_at_unix_secs: Some(unsupported_expiry),
clear_expires_at: false,
is_active: None,
};
assert!(update.validate().is_err());
}
#[test]
fn management_token_update_rejects_ambiguous_set_and_clear_operations() {
let base = UpdateManagementTokenRecord {
token_id: "token-1".to_string(),
name: None,
description: None,
clear_description: false,
allowed_ips: None,
clear_allowed_ips: false,
permissions: None,
expires_at_unix_secs: None,
clear_expires_at: false,
is_active: None,
};
assert!(UpdateManagementTokenRecord {
description: Some("description".to_string()),
clear_description: true,
..base.clone()
}
.validate()
.is_err());
assert!(UpdateManagementTokenRecord {
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
clear_allowed_ips: true,
..base.clone()
}
.validate()
.is_err());
assert!(UpdateManagementTokenRecord {
expires_at_unix_secs: Some(100),
clear_expires_at: true,
..base
}
.validate()
.is_err());
}
}