mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07: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:
@@ -1,8 +1,10 @@
|
||||
use crate::handlers::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
|
||||
bark_device_key_binding, canonical_bark_server_url, decrypt_or_migrate_bark_device_key,
|
||||
system_config_bool, system_config_string,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
pub(crate) const BARK_PUSH_ENABLED_KEY: &str = "module.bark_push.enabled";
|
||||
pub(crate) const BARK_PUSH_DEVICE_KEY_KEY: &str = "module.bark_push.device_key";
|
||||
@@ -10,8 +12,20 @@ pub(crate) const BARK_PUSH_SERVER_URL_KEY: &str = "module.bark_push.server_url";
|
||||
pub(crate) const BARK_PUSH_TEMPLATE_KEY: &str = "module.bark_push.template";
|
||||
|
||||
const DEFAULT_BARK_API_BASE: &str = "https://api.day.app";
|
||||
const BARK_ALLOW_HTTP_ENV: &str = "AETHER_BARK_ALLOW_HTTP";
|
||||
const BARK_ALLOW_PRIVATE_TARGETS_ENV: &str = "AETHER_BARK_ALLOW_PRIVATE_TARGETS";
|
||||
const MAX_BARK_RESPONSE_BYTES: usize = 64 * 1024;
|
||||
const BARK_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||||
const BARK_REQUEST_TIMEOUT_MS: u64 = 300_000;
|
||||
const MAX_BARK_SERVER_URL_BYTES: usize = 2 * 1024;
|
||||
const MAX_BARK_DEVICE_KEY_BYTES: usize = 512;
|
||||
const MAX_BARK_TEMPLATE_BYTES: usize = 256 * 1024;
|
||||
const MAX_BARK_TITLE_BYTES: usize = 512;
|
||||
const MAX_BARK_BODY_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_BARK_RENDERED_BODY_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_BARK_RESOLVED_ADDRESSES: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BarkPushConfig {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) device_key: Option<String>,
|
||||
@@ -19,6 +33,21 @@ pub(crate) struct BarkPushConfig {
|
||||
pub(crate) template: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BarkPushConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BarkPushConfig")
|
||||
.field("enabled", &self.enabled)
|
||||
.field(
|
||||
"device_key",
|
||||
&self.device_key.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("server_url", &self.server_url)
|
||||
.field("template", &self.template.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn bark_push_module_enabled(state: &AppState) -> Result<bool, GatewayError> {
|
||||
let value = state
|
||||
.read_system_config_json_value(BARK_PUSH_ENABLED_KEY)
|
||||
@@ -35,23 +64,34 @@ pub(crate) async fn read_bark_push_config(
|
||||
state: &AppState,
|
||||
) -> Result<BarkPushConfig, GatewayError> {
|
||||
let enabled = bark_push_module_enabled(state).await?;
|
||||
let device_key = state
|
||||
.read_system_config_json_value(BARK_PUSH_DEVICE_KEY_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)))
|
||||
.map(|value| {
|
||||
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
||||
});
|
||||
let server_url = state
|
||||
.read_system_config_json_value(BARK_PUSH_SERVER_URL_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_BARK_API_BASE.to_string());
|
||||
validate_bark_config_field("server_url", &server_url, MAX_BARK_SERVER_URL_BYTES)?;
|
||||
let server_url = normalized_bark_server_url(&server_url)?;
|
||||
let binding = bark_device_key_binding(&server_url)
|
||||
.ok_or_else(|| GatewayError::Internal("Bark 服务器地址不是合法 URL".to_string()))?;
|
||||
let device_key = state
|
||||
.read_system_config_json_value(BARK_PUSH_DEVICE_KEY_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)));
|
||||
let device_key = match device_key {
|
||||
Some(value) => Some(decrypt_or_migrate_bark_device_key(state, &binding, value).await?),
|
||||
None => None,
|
||||
};
|
||||
if let Some(device_key) = device_key.as_deref() {
|
||||
validate_bark_config_field("device_key", device_key, MAX_BARK_DEVICE_KEY_BYTES)?;
|
||||
}
|
||||
let template = state
|
||||
.read_system_config_json_value(BARK_PUSH_TEMPLATE_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)));
|
||||
if let Some(template) = template.as_deref() {
|
||||
validate_bark_config_field("template", template, MAX_BARK_TEMPLATE_BYTES)?;
|
||||
}
|
||||
|
||||
Ok(BarkPushConfig {
|
||||
enabled,
|
||||
@@ -62,7 +102,7 @@ pub(crate) async fn read_bark_push_config(
|
||||
}
|
||||
|
||||
pub(crate) async fn send_bark_push(
|
||||
state: &AppState,
|
||||
_state: &AppState,
|
||||
config: &BarkPushConfig,
|
||||
title: &str,
|
||||
markdown_body: &str,
|
||||
@@ -76,11 +116,13 @@ pub(crate) async fn send_bark_push(
|
||||
"Bark Device Key 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let server_url = normalized_bark_server_url(&config.server_url)?;
|
||||
let body = render_bark_body(config.template.as_deref(), title, markdown_body);
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{server_url}/push"))
|
||||
validate_bark_config_field("device_key", device_key, MAX_BARK_DEVICE_KEY_BYTES)?;
|
||||
validate_bark_content_field("title", title, MAX_BARK_TITLE_BYTES)?;
|
||||
validate_bark_content_field("body", markdown_body, MAX_BARK_BODY_BYTES)?;
|
||||
let (client, push_url) = build_bark_push_client_and_url(&config.server_url).await?;
|
||||
let body = render_bark_body(config.template.as_deref(), title, markdown_body)?;
|
||||
let response = client
|
||||
.post(push_url)
|
||||
.json(&json!({
|
||||
"device_key": device_key,
|
||||
"title": title,
|
||||
@@ -88,16 +130,14 @@ pub(crate) async fn send_bark_push(
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
.map_err(|err| GatewayError::Internal(bark_request_error_message(&err)))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
let body = aether_http::read_response_bytes_with_limit(response, MAX_BARK_RESPONSE_BYTES)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
.map_err(|err| GatewayError::Internal(bark_response_body_error_message(&err)))?;
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
if !status.is_success() {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"Bark 返回 HTTP {status}: {text}"
|
||||
)));
|
||||
return Err(GatewayError::Internal(format!("Bark 返回 HTTP {status}")));
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(&text) {
|
||||
let code_is_ok = payload
|
||||
@@ -114,53 +154,249 @@ pub(crate) async fn send_bark_push(
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if !code_is_ok {
|
||||
return Err(GatewayError::Internal(format!("Bark 返回失败: {payload}")));
|
||||
return Err(GatewayError::Internal("Bark 返回失败".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalized_bark_server_url(server_url: &str) -> Result<String, GatewayError> {
|
||||
let server_url = server_url.trim().trim_end_matches('/');
|
||||
if server_url.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器地址不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if !server_url.starts_with("https://") && !server_url.starts_with("http://") {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器地址必须以 http:// 或 https:// 开头".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(server_url.to_string())
|
||||
fn bark_request_error_message(error: &reqwest::Error) -> String {
|
||||
format!("Bark 请求失败 ({})", bark_reqwest_error_kind(error))
|
||||
}
|
||||
|
||||
fn render_bark_body(template: Option<&str>, title: &str, markdown_body: &str) -> String {
|
||||
match template {
|
||||
Some(template) if !template.trim().is_empty() => template
|
||||
.replace("{title}", title)
|
||||
.replace("{body}", markdown_body),
|
||||
_ => markdown_body.to_string(),
|
||||
fn bark_response_body_error_message(error: &aether_http::ResponseBodyReadError) -> String {
|
||||
match error {
|
||||
aether_http::ResponseBodyReadError::TooLarge { max_bytes } => {
|
||||
format!("Bark 响应超过 {max_bytes} 字节")
|
||||
}
|
||||
aether_http::ResponseBodyReadError::Read(error) => {
|
||||
format!("Bark 响应读取失败 ({})", bark_reqwest_error_kind(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bark_reqwest_error_kind(error: &reqwest::Error) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
"timeout"
|
||||
} else if error.is_connect() {
|
||||
"connect"
|
||||
} else if error.is_request() {
|
||||
"request"
|
||||
} else {
|
||||
"transport"
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_bark_server_url(server_url: &str) -> Result<String, GatewayError> {
|
||||
canonical_bark_server_url(server_url)
|
||||
.ok_or_else(|| GatewayError::Internal("Bark 服务器地址不是合法 URL".to_string()))
|
||||
}
|
||||
|
||||
async fn build_bark_push_client_and_url(
|
||||
server_url: &str,
|
||||
) -> Result<(reqwest::Client, url::Url), GatewayError> {
|
||||
validate_bark_config_field("server_url", server_url, MAX_BARK_SERVER_URL_BYTES)?;
|
||||
let normalized = normalized_bark_server_url(server_url)?;
|
||||
let mut push_url = url::Url::parse(&normalized)
|
||||
.map_err(|_| GatewayError::Internal("Bark 服务器地址不是合法 URL".to_string()))?;
|
||||
validate_bark_transport_policy(&push_url, env_flag_enabled(BARK_ALLOW_HTTP_ENV))?;
|
||||
|
||||
let host = push_url
|
||||
.host_str()
|
||||
.ok_or_else(|| GatewayError::Internal("Bark 服务器地址缺少主机名".to_string()))?
|
||||
.to_string();
|
||||
let port = push_url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| GatewayError::Internal("Bark 服务器地址缺少端口".to_string()))?;
|
||||
let addresses = if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
vec![SocketAddr::new(ip, port)]
|
||||
} else {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(BARK_CONNECT_TIMEOUT_MS),
|
||||
tokio::net::lookup_host((host.as_str(), port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| GatewayError::Internal("Bark 服务器 DNS 解析超时".to_string()))?
|
||||
.map_err(|_| GatewayError::Internal("Bark 服务器 DNS 解析失败".to_string()))?
|
||||
.take(MAX_BARK_RESOLVED_ADDRESSES)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
validate_bark_resolved_addresses(&addresses, env_flag_enabled(BARK_ALLOW_PRIVATE_TARGETS_ENV))?;
|
||||
|
||||
push_url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| GatewayError::Internal("Bark 服务器地址不能作为基础 URL".to_string()))?
|
||||
.pop_if_empty()
|
||||
.push("push");
|
||||
|
||||
let mut builder = aether_http::apply_http_client_config(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none()),
|
||||
&aether_http::HttpClientConfig {
|
||||
connect_timeout_ms: Some(BARK_CONNECT_TIMEOUT_MS),
|
||||
request_timeout_ms: Some(BARK_REQUEST_TIMEOUT_MS),
|
||||
http2_adaptive_window: true,
|
||||
..aether_http::HttpClientConfig::default()
|
||||
},
|
||||
);
|
||||
if host.parse::<IpAddr>().is_err() {
|
||||
builder = builder.resolve_to_addrs(&host, &addresses);
|
||||
}
|
||||
let client = builder
|
||||
.build()
|
||||
.map_err(|_| GatewayError::Internal("Bark HTTP 客户端初始化失败".to_string()))?;
|
||||
Ok((client, push_url))
|
||||
}
|
||||
|
||||
fn validate_bark_transport_policy(url: &url::Url, allow_http: bool) -> Result<(), GatewayError> {
|
||||
if url.scheme() == "http" && !allow_http {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"Bark 服务器必须使用 HTTPS;如确需明文 HTTP,请显式设置 {BARK_ALLOW_HTTP_ENV}=true"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bark_resolved_addresses(
|
||||
addresses: &[SocketAddr],
|
||||
allow_private: bool,
|
||||
) -> Result<(), GatewayError> {
|
||||
if addresses.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器 DNS 解析未返回地址".to_string(),
|
||||
));
|
||||
}
|
||||
if !allow_private
|
||||
&& addresses
|
||||
.iter()
|
||||
.any(|address| aether_http::is_private_or_reserved_ip(address.ip()))
|
||||
{
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"Bark 服务器解析到私有或保留地址;如确需内网自建服务,请显式设置 {BARK_ALLOW_PRIVATE_TARGETS_ENV}=true"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn env_flag_enabled(key: &str) -> bool {
|
||||
std::env::var(key).ok().is_some_and(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_bark_config_field(
|
||||
field: &str,
|
||||
value: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<(), GatewayError> {
|
||||
if value.len() > max_bytes || value.bytes().any(|byte| byte == 0) {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"Bark {field} exceeds the allowed size or contains a NUL byte"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bark_content_field(
|
||||
field: &str,
|
||||
value: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<(), GatewayError> {
|
||||
validate_bark_config_field(field, value, max_bytes)
|
||||
}
|
||||
|
||||
fn render_bark_body(
|
||||
template: Option<&str>,
|
||||
title: &str,
|
||||
markdown_body: &str,
|
||||
) -> Result<String, GatewayError> {
|
||||
validate_bark_content_field("title", title, MAX_BARK_TITLE_BYTES)?;
|
||||
validate_bark_content_field("body", markdown_body, MAX_BARK_BODY_BYTES)?;
|
||||
let template = template
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("{body}");
|
||||
validate_bark_config_field("template", template, MAX_BARK_TEMPLATE_BYTES)?;
|
||||
|
||||
let mut rendered = String::with_capacity(template.len().min(MAX_BARK_RENDERED_BODY_BYTES));
|
||||
let mut cursor = 0usize;
|
||||
while cursor < template.len() {
|
||||
let remaining = &template[cursor..];
|
||||
let title_match = remaining.find("{title}");
|
||||
let body_match = remaining.find("{body}");
|
||||
let next = match (title_match, body_match) {
|
||||
(None, None) => {
|
||||
append_bark_rendered_part(&mut rendered, remaining)?;
|
||||
cursor = template.len();
|
||||
continue;
|
||||
}
|
||||
(Some(index), None) => (index, "{title}", title),
|
||||
(None, Some(index)) => (index, "{body}", markdown_body),
|
||||
(Some(title_index), Some(body_index)) if title_index <= body_index => {
|
||||
(title_index, "{title}", title)
|
||||
}
|
||||
(Some(_), Some(body_index)) => (body_index, "{body}", markdown_body),
|
||||
};
|
||||
append_bark_rendered_part(&mut rendered, &remaining[..next.0])?;
|
||||
append_bark_rendered_part(&mut rendered, next.2)?;
|
||||
cursor += next.0 + next.1.len();
|
||||
}
|
||||
if rendered.is_empty() && template.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn append_bark_rendered_part(output: &mut String, part: &str) -> Result<(), GatewayError> {
|
||||
let next_len = output
|
||||
.len()
|
||||
.checked_add(part.len())
|
||||
.ok_or_else(|| GatewayError::Internal("Bark rendered body is too large".to_string()))?;
|
||||
if next_len > MAX_BARK_RENDERED_BODY_BYTES {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark rendered body exceeds the allowed size".to_string(),
|
||||
));
|
||||
}
|
||||
output.push_str(part);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalized_bark_server_url, render_bark_body};
|
||||
use super::{
|
||||
bark_request_error_message, bark_response_body_error_message, normalized_bark_server_url,
|
||||
render_bark_body, validate_bark_resolved_addresses, validate_bark_transport_policy,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn bark_body_uses_template_when_provided() {
|
||||
let rendered = render_bark_body(Some("{title}\n\n{body}"), "告警", "原始正文");
|
||||
let rendered = render_bark_body(Some("{title}\n\n{body}"), "告警", "原始正文")
|
||||
.expect("template should render");
|
||||
assert_eq!(rendered, "告警\n\n原始正文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_body_falls_back_to_markdown_body_for_empty_template() {
|
||||
assert_eq!(render_bark_body(None, "告警", "原始正文"), "原始正文");
|
||||
assert_eq!(
|
||||
render_bark_body(Some(" "), "告警", "原始正文"),
|
||||
render_bark_body(None, "告警", "原始正文").expect("fallback should render"),
|
||||
"原始正文"
|
||||
);
|
||||
assert_eq!(
|
||||
render_bark_body(Some(" "), "告警", "原始正文").expect("fallback should render"),
|
||||
"原始正文"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_body_rejects_template_expansion_bombs_and_oversized_content() {
|
||||
let template = "x".repeat(super::MAX_BARK_TEMPLATE_BYTES + 1);
|
||||
assert!(render_bark_body(Some(&template), "告警", "正文").is_err());
|
||||
let body = "x".repeat(super::MAX_BARK_BODY_BYTES + 1);
|
||||
assert!(render_bark_body(None, "告警", &body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -170,4 +406,48 @@ mod tests {
|
||||
"https://api.day.app"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_server_url_rejects_credentials_query_and_fragments() {
|
||||
for invalid in [
|
||||
"https://[email protected]",
|
||||
"https://example.com?target=internal",
|
||||
"https://example.com/#fragment",
|
||||
] {
|
||||
assert!(normalized_bark_server_url(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_http_transport_requires_explicit_opt_in() {
|
||||
let url = url::Url::parse("http://bark.example.com").unwrap();
|
||||
assert!(validate_bark_transport_policy(&url, false).is_err());
|
||||
assert!(validate_bark_transport_policy(&url, true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_private_targets_require_explicit_opt_in() {
|
||||
let private = [SocketAddr::from(([127, 0, 0, 1], 443))];
|
||||
assert!(validate_bark_resolved_addresses(&private, false).is_err());
|
||||
assert!(validate_bark_resolved_addresses(&private, true).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bark_transport_errors_do_not_expose_server_url_or_response_body() {
|
||||
let secret = "bark-secret-query";
|
||||
let error = reqwest::Client::new()
|
||||
.post(format!("ftp://bark.example.test/push?token={secret}"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("unsupported URL scheme should fail before network I/O");
|
||||
|
||||
let message = bark_request_error_message(&error);
|
||||
assert!(!message.contains(secret));
|
||||
assert!(!message.contains("bark.example.test"));
|
||||
|
||||
let body_error = aether_http::ResponseBodyReadError::Read(error);
|
||||
let message = bark_response_body_error_message(&body_error);
|
||||
assert!(!message.contains(secret));
|
||||
assert!(!message.contains("bark.example.test"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user