mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47: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:
@@ -26,13 +26,12 @@ pub(crate) struct VideoTaskStatsResponse {
|
||||
pub(crate) processing_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum VideoTaskVideoSource {
|
||||
Redirect {
|
||||
url: String,
|
||||
url: url::Url,
|
||||
},
|
||||
Proxy {
|
||||
url: String,
|
||||
url: url::Url,
|
||||
header_name: String,
|
||||
header_value: String,
|
||||
filename: String,
|
||||
@@ -102,6 +101,14 @@ pub(crate) async fn read_video_task_detail(
|
||||
state.find_video_task_by_id(task_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_video_task_detail_for_user(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<StoredVideoTask>, GatewayError> {
|
||||
state.find_video_task_by_id_for_user(task_id, user_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_video_task_video_source(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
@@ -109,6 +116,13 @@ pub(crate) async fn read_video_task_video_source(
|
||||
let Some(task) = read_video_task_detail(state, task_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
video_task_video_source_from_task(state, &task).await
|
||||
}
|
||||
|
||||
pub(crate) async fn video_task_video_source_from_task(
|
||||
state: &AppState,
|
||||
task: &StoredVideoTask,
|
||||
) -> Result<Option<VideoTaskVideoSource>, GatewayError> {
|
||||
let Some(video_url) = task
|
||||
.video_url
|
||||
.as_deref()
|
||||
@@ -119,7 +133,9 @@ pub(crate) async fn read_video_task_video_source(
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if !video_url.contains("generativelanguage.googleapis.com") {
|
||||
let video_url = parse_video_url(&video_url)?;
|
||||
|
||||
if task.effective_api_format() != Some("gemini:video") {
|
||||
return Ok(Some(VideoTaskVideoSource::Redirect { url: video_url }));
|
||||
}
|
||||
|
||||
@@ -148,6 +164,15 @@ pub(crate) async fn read_video_task_video_source(
|
||||
));
|
||||
};
|
||||
|
||||
let endpoint_url = parse_video_url(transport.endpoint.base_url.trim()).map_err(|_| {
|
||||
GatewayError::Internal("provider endpoint URL is invalid for proxied video".to_string())
|
||||
})?;
|
||||
if !video_urls_share_origin(&endpoint_url, &video_url) {
|
||||
return Err(GatewayError::Client {
|
||||
status: axum::http::StatusCode::BAD_GATEWAY,
|
||||
message: "video URL origin does not match its provider endpoint".to_string(),
|
||||
});
|
||||
}
|
||||
let api_key = transport.key.decrypted_api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
@@ -159,10 +184,34 @@ pub(crate) async fn read_video_task_video_source(
|
||||
url: video_url,
|
||||
header_name: "x-goog-api-key".to_string(),
|
||||
header_value: api_key.to_string(),
|
||||
filename: format!("video_{task_id}.mp4"),
|
||||
filename: format!("video_{}.mp4", task.id),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_video_url(raw_url: &str) -> Result<url::Url, GatewayError> {
|
||||
let url = url::Url::parse(raw_url.trim()).map_err(|_| GatewayError::Client {
|
||||
status: axum::http::StatusCode::BAD_GATEWAY,
|
||||
message: "video URL is invalid".to_string(),
|
||||
})?;
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
return Err(GatewayError::Client {
|
||||
status: axum::http::StatusCode::BAD_GATEWAY,
|
||||
message: "video URL must be an absolute HTTP(S) URL without credentials".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn video_urls_share_origin(left: &url::Url, right: &url::Url) -> bool {
|
||||
left.scheme() == right.scheme()
|
||||
&& left.host() == right.host()
|
||||
&& left.port_or_known_default() == right.port_or_known_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn read_video_task_stats(
|
||||
state: &AppState,
|
||||
filter: &VideoTaskQueryFilter,
|
||||
@@ -226,3 +275,212 @@ fn status_key(status: VideoTaskStatus) -> String {
|
||||
fn start_of_utc_day(now_unix_secs: u64) -> u64 {
|
||||
now_unix_secs - (now_unix_secs % 86_400)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::video_tasks::InMemoryVideoTaskRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_data_contracts::repository::video_tasks::{UpsertVideoTask, VideoTaskStatus};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
parse_video_url, video_task_video_source_from_task, video_urls_share_origin,
|
||||
VideoTaskVideoSource,
|
||||
};
|
||||
use crate::{data::GatewayDataState, AppState};
|
||||
|
||||
fn legacy_gemini_video_task() -> aether_data_contracts::repository::video_tasks::StoredVideoTask
|
||||
{
|
||||
UpsertVideoTask {
|
||||
id: "legacy-gemini-task".to_string(),
|
||||
short_id: Some("legacy-short".to_string()),
|
||||
request_id: "legacy-request".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("client-key-1".to_string()),
|
||||
username: None,
|
||||
api_key_name: None,
|
||||
external_task_id: Some("operations/upstream-1".to_string()),
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
endpoint_id: Some("endpoint-1".to_string()),
|
||||
key_id: Some("provider-key-1".to_string()),
|
||||
client_api_format: Some("gemini:video".to_string()),
|
||||
provider_api_format: None,
|
||||
format_converted: false,
|
||||
model: Some("veo-3".to_string()),
|
||||
prompt: None,
|
||||
original_request_body: None,
|
||||
duration_seconds: Some(8),
|
||||
resolution: Some("720p".to_string()),
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
size: Some("1280x720".to_string()),
|
||||
status: VideoTaskStatus::Completed,
|
||||
progress_percent: 100,
|
||||
progress_message: None,
|
||||
retry_count: 0,
|
||||
poll_interval_seconds: 10,
|
||||
next_poll_at_unix_secs: None,
|
||||
poll_count: 1,
|
||||
max_poll_count: 360,
|
||||
created_at_unix_ms: 1,
|
||||
submitted_at_unix_secs: Some(1),
|
||||
completed_at_unix_secs: Some(2),
|
||||
updated_at_unix_secs: 2,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
video_url: Some(
|
||||
"https://generativelanguage.googleapis.com/v1beta/files/video-1:download?alt=media"
|
||||
.to_string(),
|
||||
),
|
||||
request_metadata: None,
|
||||
}
|
||||
.into_stored()
|
||||
}
|
||||
|
||||
fn state_with_gemini_transport() -> AppState {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
let provider = StoredProviderCatalogProvider::new(
|
||||
"provider-1".to_string(),
|
||||
"Gemini".to_string(),
|
||||
Some("https://ai.google.dev".to_string()),
|
||||
"gemini".to_string(),
|
||||
)
|
||||
.expect("provider should build");
|
||||
let endpoint = StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"gemini:video".to_string(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://generativelanguage.googleapis.com".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build");
|
||||
let encrypted_api_key = state
|
||||
.seal_provider_catalog_key_api_key(
|
||||
"provider-1",
|
||||
"provider-key-1",
|
||||
"gemini-provider-secret",
|
||||
)
|
||||
.expect("provider key should encrypt");
|
||||
let key = StoredProviderCatalogKey::new(
|
||||
"provider-key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"default".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("provider key should build")
|
||||
.with_transport_fields(
|
||||
Some(json!(["gemini:video"])),
|
||||
encrypted_api_key,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("provider key transport should build");
|
||||
let provider_catalog: Arc<dyn ProviderCatalogReadRepository> = Arc::new(
|
||||
InMemoryProviderCatalogReadRepository::seed(vec![provider], vec![endpoint], vec![key]),
|
||||
);
|
||||
let video_tasks = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
let data = GatewayDataState::with_video_task_repository_and_provider_transport_for_tests(
|
||||
video_tasks,
|
||||
provider_catalog,
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
);
|
||||
state.with_data_state_for_tests(data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_url_parser_rejects_non_http_and_embedded_credentials() {
|
||||
for raw_url in [
|
||||
"file:///etc/passwd",
|
||||
"data:video/mp4;base64,AAAA",
|
||||
"https://[email protected]/video.mp4",
|
||||
"https://user:[email protected]/video.mp4",
|
||||
"/relative/video.mp4",
|
||||
] {
|
||||
assert!(
|
||||
parse_video_url(raw_url).is_err(),
|
||||
"URL should be rejected: {raw_url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_origin_comparison_uses_scheme_host_and_effective_port() {
|
||||
let base = parse_video_url("https://generativelanguage.googleapis.com/v1beta").unwrap();
|
||||
for same_origin in [
|
||||
"https://generativelanguage.googleapis.com/file",
|
||||
"https://generativelanguage.googleapis.com:443/file",
|
||||
] {
|
||||
assert!(video_urls_share_origin(
|
||||
&base,
|
||||
&parse_video_url(same_origin).unwrap()
|
||||
));
|
||||
}
|
||||
for different_origin in [
|
||||
"http://generativelanguage.googleapis.com/file",
|
||||
"https://generativelanguage.googleapis.com:444/file",
|
||||
"https://generativelanguage.googleapis.com.evil.test/file",
|
||||
"https://evil.test/generativelanguage.googleapis.com/file",
|
||||
] {
|
||||
assert!(!video_urls_share_origin(
|
||||
&base,
|
||||
&parse_video_url(different_origin).unwrap()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_gemini_client_format_uses_authenticated_proxy_source() {
|
||||
let source = video_task_video_source_from_task(
|
||||
&state_with_gemini_transport(),
|
||||
&legacy_gemini_video_task(),
|
||||
)
|
||||
.await
|
||||
.expect("video source should resolve")
|
||||
.expect("video source should exist");
|
||||
|
||||
match source {
|
||||
VideoTaskVideoSource::Proxy {
|
||||
url,
|
||||
header_name,
|
||||
header_value,
|
||||
filename,
|
||||
} => {
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://generativelanguage.googleapis.com/v1beta/files/video-1:download?alt=media"
|
||||
);
|
||||
assert_eq!(header_name, "x-goog-api-key");
|
||||
assert_eq!(header_value, "gemini-provider-secret");
|
||||
assert_eq!(filename, "video_legacy-gemini-task.mp4");
|
||||
}
|
||||
VideoTaskVideoSource::Redirect { .. } => {
|
||||
panic!("legacy Gemini video must not bypass the authenticated proxy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user