mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
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:
@@ -8,22 +8,25 @@ use serde_json::Value;
|
||||
|
||||
use crate::GatewayError;
|
||||
|
||||
const INVALID_ROUTING_MUTATION_MESSAGE: &str = "invalid routing mutation";
|
||||
|
||||
pub(crate) fn apply_routing_mutation_plan(
|
||||
body: &mut Value,
|
||||
headers: &mut HeaderMap,
|
||||
plan: &MutationPlan,
|
||||
) -> Result<(), GatewayError> {
|
||||
apply_json_patch_operations(body, &plan.body_patch).map_err(|err| GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
apply_header_patch(headers, &plan.header_patch).map_err(|err| GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
apply_json_patch_operations(body, &plan.body_patch).map_err(|_| invalid_routing_mutation())?;
|
||||
apply_header_patch(headers, &plan.header_patch).map_err(|_| invalid_routing_mutation())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalid_routing_mutation() -> GatewayError {
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: INVALID_ROUTING_MUTATION_MESSAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_header_patch(
|
||||
headers: &mut HeaderMap,
|
||||
patch: &[RoutingHeaderPatch],
|
||||
@@ -47,3 +50,64 @@ fn apply_header_patch(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aether_routing_core::RoutingJsonPatchOperation;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn body_mutation_errors_do_not_echo_json_pointer() {
|
||||
let secret = "https://internal.example/?token=Bearer-secret";
|
||||
let plan = MutationPlan {
|
||||
body_patch: vec![RoutingJsonPatchOperation::Replace {
|
||||
path: secret.to_string(),
|
||||
value: json!("replacement"),
|
||||
}],
|
||||
..MutationPlan::default()
|
||||
};
|
||||
|
||||
let error = apply_routing_mutation_plan(
|
||||
&mut json!({"model": "test"}),
|
||||
&mut HeaderMap::new(),
|
||||
&plan,
|
||||
)
|
||||
.expect_err("invalid pointer should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
ref message,
|
||||
} if message == INVALID_ROUTING_MUTATION_MESSAGE && !message.contains(secret)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_mutation_errors_do_not_echo_header_name() {
|
||||
let secret = "Authorization: Bearer secret";
|
||||
let plan = MutationPlan {
|
||||
header_patch: vec![RoutingHeaderPatch::Remove {
|
||||
name: secret.to_string(),
|
||||
}],
|
||||
..MutationPlan::default()
|
||||
};
|
||||
|
||||
let error = apply_routing_mutation_plan(
|
||||
&mut json!({"model": "test"}),
|
||||
&mut HeaderMap::new(),
|
||||
&plan,
|
||||
)
|
||||
.expect_err("invalid header should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
ref message,
|
||||
} if message == INVALID_ROUTING_MUTATION_MESSAGE && !message.contains(secret)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use aether_routing_core::{
|
||||
resolve_routing_policy, MutationPlan, RankingOverlay, ResolvedRoutingPolicy,
|
||||
RoutingDefaultPolicy, RoutingGroupConfig, RoutingPolicyInput, RoutingRulePhase,
|
||||
RoutingSchedulingMode, RoutingSetPriorityMode, DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
RoutingDefaultPolicy, RoutingGroupConfig, RoutingPolicyError, RoutingPolicyInput,
|
||||
RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode, DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use serde_json::Value;
|
||||
@@ -9,6 +9,10 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::GatewayError;
|
||||
|
||||
const INVALID_ROUTING_GROUP_CONFIG_MESSAGE: &str = "invalid routing group config";
|
||||
const INVALID_ROUTING_MUTATION_MESSAGE: &str = "invalid routing mutation";
|
||||
const ROUTING_MODEL_NOT_ALLOWED_MESSAGE: &str = "requested model is not allowed by routing policy";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct GatewayRoutingPolicyInput<'a> {
|
||||
pub group_id: Option<&'a str>,
|
||||
@@ -52,10 +56,7 @@ pub(crate) fn resolve_gateway_routing_policy(
|
||||
}
|
||||
|
||||
let config = serde_json::from_value::<RoutingGroupConfig>(input.group_config_json.clone())
|
||||
.map_err(|err| GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: format!("invalid routing group config: {err}"),
|
||||
})?;
|
||||
.map_err(|_| invalid_routing_group_config())?;
|
||||
resolve_routing_policy(
|
||||
&config,
|
||||
RoutingPolicyInput {
|
||||
@@ -72,10 +73,7 @@ pub(crate) fn resolve_gateway_routing_policy(
|
||||
phase: input.phase,
|
||||
},
|
||||
)
|
||||
.map_err(|err| GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: err.to_string(),
|
||||
})
|
||||
.map_err(routing_policy_error)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_gateway_static_default_routing_policy(
|
||||
@@ -134,18 +132,14 @@ fn static_default_policy_fields(
|
||||
RoutingSchedulingMode::default,
|
||||
)?;
|
||||
let keep_priority_on_conversion = match default_policy.get("keep_priority_on_conversion") {
|
||||
Some(value) => value.as_bool().ok_or_else(|| {
|
||||
invalid_routing_group_config("keep_priority_on_conversion must be a boolean")
|
||||
})?,
|
||||
Some(value) => value.as_bool().ok_or_else(invalid_routing_group_config)?,
|
||||
None => false,
|
||||
};
|
||||
let sticky_key_attempts = match default_policy.get("sticky_key_attempts") {
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or_else(|| {
|
||||
invalid_routing_group_config("sticky_key_attempts must be a non-negative integer")
|
||||
})?,
|
||||
.ok_or_else(invalid_routing_group_config)?,
|
||||
None => DEFAULT_STICKY_KEY_ATTEMPTS,
|
||||
};
|
||||
let enable_cf_heartbeat = routing_bool_field(
|
||||
@@ -180,11 +174,9 @@ fn static_default_policy_fields(
|
||||
}))
|
||||
}
|
||||
|
||||
fn routing_bool_field(value: Option<&Value>, field: &str) -> Result<bool, GatewayError> {
|
||||
fn routing_bool_field(value: Option<&Value>, _field: &str) -> Result<bool, GatewayError> {
|
||||
match value {
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| invalid_routing_group_config(format!("{field} must be a boolean"))),
|
||||
Some(value) => value.as_bool().ok_or_else(invalid_routing_group_config),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
@@ -208,17 +200,29 @@ where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
match value {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|err| {
|
||||
invalid_routing_group_config(format!("invalid default routing policy: {err}"))
|
||||
}),
|
||||
Some(value) => {
|
||||
serde_json::from_value(value.clone()).map_err(|_| invalid_routing_group_config())
|
||||
}
|
||||
None => Ok(default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_routing_group_config(message: impl Into<String>) -> GatewayError {
|
||||
fn routing_policy_error(error: RoutingPolicyError) -> GatewayError {
|
||||
let message = match error {
|
||||
RoutingPolicyError::InvalidConfig(_) => INVALID_ROUTING_GROUP_CONFIG_MESSAGE,
|
||||
RoutingPolicyError::InvalidMutation(_) => INVALID_ROUTING_MUTATION_MESSAGE,
|
||||
RoutingPolicyError::ModelNotAllowed(_) => ROUTING_MODEL_NOT_ALLOWED_MESSAGE,
|
||||
};
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: format!("invalid routing group config: {}", message.into()),
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_routing_group_config() -> GatewayError {
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: INVALID_ROUTING_GROUP_CONFIG_MESSAGE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,4 +313,72 @@ mod tests {
|
||||
|
||||
assert!(policy.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_config_errors_do_not_echo_config_values() {
|
||||
let secret = "https://internal.example/?token=Bearer-secret";
|
||||
let config = json!({
|
||||
"default_policy": {
|
||||
"priority_mode": secret
|
||||
}
|
||||
});
|
||||
|
||||
let error =
|
||||
resolve_gateway_static_default_routing_policy(GatewayStaticRoutingPolicyInput {
|
||||
group_id: Some("group-1"),
|
||||
group_version: Some(1),
|
||||
group_config_json: &config,
|
||||
selection_source: "system_default",
|
||||
requested_model: "mock-model",
|
||||
resolved_model: "mock-model",
|
||||
})
|
||||
.expect_err("invalid config should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
ref message,
|
||||
} if message == INVALID_ROUTING_GROUP_CONFIG_MESSAGE && !message.contains(secret)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_policy_errors_do_not_echo_requested_model() {
|
||||
let secret = "model?token=Bearer-secret";
|
||||
let config = json!({
|
||||
"rules": [{
|
||||
"id": "restrict-model",
|
||||
"conditions": {},
|
||||
"actions": [{
|
||||
"type": "restrict_models",
|
||||
"models": ["allowed-model"]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let error = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
|
||||
group_id: Some("group-1"),
|
||||
group_version: Some(1),
|
||||
group_config_json: &config,
|
||||
selection_source: "system_default",
|
||||
requested_model: secret,
|
||||
resolved_model: secret,
|
||||
api_format: "openai:chat",
|
||||
user_id: Some("user-1"),
|
||||
api_key_id: Some("key-1"),
|
||||
headers: &json!({}),
|
||||
body: &json!({"model": secret}),
|
||||
phase: RoutingRulePhase::ClientRequest,
|
||||
})
|
||||
.expect_err("disallowed model should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
GatewayError::Client {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
ref message,
|
||||
} if message == ROUTING_MODEL_NOT_ALLOWED_MESSAGE && !message.contains(secret)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user