mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
fix: restore security hardening compatibility and validation
Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
mod keys;
|
||||
mod quota;
|
||||
mod routes;
|
||||
mod rules_reveal;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use axum::body::Body;
|
||||
use http::{HeaderMap, HeaderValue, Method, Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::super::{build_router_with_state, sample_endpoint, sample_provider, AppState};
|
||||
use crate::admin_api::{maybe_build_local_admin_response, AdminRouteRequest};
|
||||
use crate::audit::AdminAuditEvent;
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||
};
|
||||
use crate::control::resolve_public_request_context;
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::tests::send_request;
|
||||
|
||||
fn seeded_state() -> AppState {
|
||||
let mut endpoint = sample_endpoint(
|
||||
"endpoint-rules",
|
||||
"provider-rules",
|
||||
"openai:chat",
|
||||
"https://example.test",
|
||||
);
|
||||
endpoint.header_rules =
|
||||
Some(json!([{"action": "set", "key": "x-auth", "value": "request-secret"}]));
|
||||
endpoint.body_rules =
|
||||
Some(json!([{"action": "set", "path": "auth.token", "value": "body-secret"}]));
|
||||
endpoint.config = Some(json!({
|
||||
"private_token": "unrelated-secret",
|
||||
"response_header_rules": [{"action": "set", "key": "x-auth", "value": "response-secret"}]
|
||||
}));
|
||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-rules", "custom", 10)],
|
||||
vec![endpoint],
|
||||
vec![],
|
||||
));
|
||||
AppState::new().unwrap().with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(repository),
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in [
|
||||
(GATEWAY_HEADER, "rust-phase3b"),
|
||||
(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user"),
|
||||
(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin"),
|
||||
(TRUSTED_ADMIN_SESSION_ID_HEADER, "admin-session"),
|
||||
] {
|
||||
headers.insert(name, HeaderValue::from_static(value));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_rules_reveal_is_scoped_audited_and_not_cached() {
|
||||
let state = seeded_state();
|
||||
let context = resolve_public_request_context(
|
||||
&state,
|
||||
&Method::GET,
|
||||
&"/api/admin/endpoints/endpoint-rules/rules/reveal"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
&admin_headers(),
|
||||
"reveal-test",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = maybe_build_local_admin_response(AdminRouteRequest::new(
|
||||
&state,
|
||||
&context,
|
||||
&"127.0.0.1:12345".parse().unwrap(),
|
||||
&admin_headers(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[http::header::CACHE_CONTROL], "no-store");
|
||||
assert_eq!(response.headers()[http::header::PRAGMA], "no-cache");
|
||||
let audit = response.extensions().get::<AdminAuditEvent>().unwrap();
|
||||
assert_eq!(audit.event_name, "admin_endpoint_rules_revealed");
|
||||
assert_eq!(audit.action, "reveal_endpoint_rules");
|
||||
assert_eq!(audit.target_id, "endpoint-rules");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let payload: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(payload["header_rules"][0]["value"], "request-secret");
|
||||
assert_eq!(payload["body_rules"][0]["value"], "body-secret");
|
||||
assert_eq!(
|
||||
payload["response_header_rules"][0]["value"],
|
||||
"response-secret"
|
||||
);
|
||||
assert_eq!(payload.as_object().unwrap().len(), 3);
|
||||
assert!(!payload.to_string().contains("unrelated-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_rules_reveal_denies_anonymous_and_non_admin_requests() {
|
||||
let router = build_router_with_state(seeded_state());
|
||||
for role in [None, Some("user")] {
|
||||
let mut request =
|
||||
Request::builder().uri("/api/admin/endpoints/endpoint-rules/rules/reveal");
|
||||
if let Some(role) = role {
|
||||
request = request
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "normal-user")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, role)
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "user-session");
|
||||
}
|
||||
let response = send_request(router.clone(), request.body(Body::empty()).unwrap()).await;
|
||||
assert!(matches!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("request-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_rules_reveal_returns_not_found_and_data_unavailable_without_fallback() {
|
||||
for (state, expected) in [
|
||||
(seeded_state(), StatusCode::NOT_FOUND),
|
||||
(AppState::new().unwrap(), StatusCode::SERVICE_UNAVAILABLE),
|
||||
] {
|
||||
let context = resolve_public_request_context(
|
||||
&state,
|
||||
&Method::GET,
|
||||
&"/api/admin/endpoints/missing/rules/reveal".parse().unwrap(),
|
||||
&admin_headers(),
|
||||
"reveal-missing-test",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = maybe_build_local_admin_response(AdminRouteRequest::new(
|
||||
&state,
|
||||
&context,
|
||||
&"127.0.0.1:12345".parse().unwrap(),
|
||||
&admin_headers(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected);
|
||||
}
|
||||
}
|
||||
@@ -221,13 +221,17 @@ async fn gateway_handles_admin_video_tasks_list_locally_with_trusted_admin_princ
|
||||
assert_eq!(payload["pages"], json!(1));
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(payload["items"][0]["id"], "task-completed");
|
||||
// Video-task persistence intentionally drops user-facing PII. The admin
|
||||
// projection must therefore use the privacy-safe fallback when no separate
|
||||
// user snapshot is joined.
|
||||
assert_eq!(payload["items"][0]["username"], "Unknown");
|
||||
assert_eq!(payload["items"][0]["username"], "alice");
|
||||
assert_eq!(payload["items"][0]["provider_name"], "OpenAI");
|
||||
assert_eq!(payload["items"][0]["status"], "completed");
|
||||
assert!(payload["items"][0]["prompt"].is_null());
|
||||
assert_eq!(
|
||||
payload["items"][0]["prompt"],
|
||||
format!("{}...", "x".repeat(100))
|
||||
);
|
||||
assert_eq!(
|
||||
payload["items"][0]["video_url"],
|
||||
"https://8.8.8.8/task-completed.mp4"
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -393,7 +397,9 @@ async fn gateway_handles_admin_video_task_detail_locally_with_trusted_admin_prin
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["id"], "task-detail");
|
||||
assert_eq!(payload["username"], "Unknown");
|
||||
assert_eq!(payload["prompt"], "detail prompt");
|
||||
assert_eq!(payload["video_url"], "https://8.8.8.8/task-detail.mp4");
|
||||
assert_eq!(payload["username"], "charlie");
|
||||
assert_eq!(payload["provider_name"], "OpenAI");
|
||||
assert_eq!(payload["endpoint"]["id"], "endpoint-1");
|
||||
assert_eq!(payload["endpoint"]["api_format"], "openai:video");
|
||||
@@ -734,7 +740,7 @@ async fn local_admin_video_task_cancel_attaches_explicit_audit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstream() {
|
||||
async fn gateway_redirects_persisted_openai_video_url_without_forwarding_admin_request() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
@@ -762,7 +768,10 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstrea
|
||||
))
|
||||
.await
|
||||
.expect("task should upsert");
|
||||
assert_eq!(stored.video_url, None);
|
||||
assert_eq!(
|
||||
stored.video_url.as_deref(),
|
||||
Some("https://8.8.8.8/task-redirect.mp4")
|
||||
);
|
||||
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
@@ -788,7 +797,14 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstrea
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
stored.video_url.as_deref()
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -796,22 +812,22 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstrea
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_admin_video_task_video_is_unavailable_after_openai_url_sanitization() {
|
||||
async fn local_admin_video_task_download_preserves_signed_url_and_attaches_audit() {
|
||||
let repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
let stored = repository
|
||||
.upsert(sample_admin_video_task(
|
||||
"task-video-audit",
|
||||
VideoTaskStatus::Completed,
|
||||
1_710_000_550,
|
||||
"user-5",
|
||||
"frank",
|
||||
"provider-openai",
|
||||
"gpt-video",
|
||||
"video audit prompt",
|
||||
))
|
||||
.await
|
||||
.expect("task should upsert");
|
||||
assert_eq!(stored.video_url, None);
|
||||
let mut task = sample_admin_video_task(
|
||||
"task-video-audit",
|
||||
VideoTaskStatus::Completed,
|
||||
1_710_000_550,
|
||||
"user-5",
|
||||
"frank",
|
||||
"provider-openai",
|
||||
"gpt-video",
|
||||
"video audit prompt",
|
||||
);
|
||||
task.video_url =
|
||||
Some("https://8.8.8.8/video.mp4?signature=a%2Fb%2Bc%3D&part=2&part=1".to_string());
|
||||
let stored = repository.upsert(task).await.expect("task should upsert");
|
||||
assert_eq!(stored.prompt.as_deref(), Some("video audit prompt"));
|
||||
|
||||
let state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
@@ -825,8 +841,15 @@ async fn local_admin_video_task_video_is_unavailable_after_openai_url_sanitizati
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert!(response.extensions().get::<AdminAuditEvent>().is_none());
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
stored.video_url.as_deref()
|
||||
);
|
||||
assert!(response.extensions().get::<AdminAuditEvent>().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user