mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
fix(ws): restore redacted PII in provider frames before client delivery
Responses WebSocket 只实现了脱敏的一半:请求侧 mask 之后,provider 事件帧在推给
客户端之前没有还原,于是 session 映射内的占位符以 <AETHER:EMAIL:...> 的形式直接
透给客户端。这里补齐响应侧,语义与 HTTP 路径对齐。
- 还原点是 relay loop 的最后一跳(send_client_message 之前、capture_client_frame
之前),对应 HTTP 的 restore_sync_response_body / StreamingResponseRestorer 所在
位置。审计与终态观测继续消费脱敏态事件,只有发往客户端的那一份拷贝被还原。
- 复用 privacy::restore_json_strings(改为 pub(crate))与
RedactionSession::restore_text,不复制任何还原逻辑:只还原本 session mask 过的
映射,未映射的占位符原样保留;type / model / id 等协议字段不可能命中 sentinel,
因此不受影响。批量 {"chunks":[...]} 帧一并递归还原。
- session 生命周期:mask 仍然是 per-turn(slot 依旧每轮新建),但 session 改由连接
持有,按有界 FIFO 留最近 8 轮。理由是 WS 的会话历史留在上游,continuation 只发
增量输入,per-turn 释放会漏还原后续响应里回显的更早轮次占位符;HTTP 不会漏,是
因为它每次重发整段历史、重新 mask 会派生出同一个 sentinel。被挤出窗口的轮次退回
「占位符原样透传」,不会错误还原成别的值。
- 未命中还原时不改写字节;连接上没有任何 mask session 时(未启用脱敏)连事件 clone
都不做。
测试:redaction.rs 新增 8 条单测(还原命中/批量帧/未映射占位符原样/未命中不改写/
无 session 不介入/空 session 不留存/审计侧入参不被改写/跨轮还原/窗口有界);
responses_websocket_e2e 新增一条用例,mock 上游回显收到的 input,断言上游只看到
占位符而客户端拿到真实邮箱。
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::WebSocket;
|
||||
use axum::extract::ws::{Message as AxumWsMessage, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::time::sleep;
|
||||
@@ -486,10 +486,22 @@ pub(super) async fn relay_bound_connection(
|
||||
detach_exhausted_upstream(bound, directive, &context.trace_id).await;
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = send_client_message(
|
||||
client_socket,
|
||||
upstream_message_to_client(upstream_message.clone()),
|
||||
).await {
|
||||
// 响应侧还原:HTTP 在把响应体交给客户端之前会把占位符换回真实值
|
||||
// (`privacy::restore_sync_response_body` /
|
||||
// `privacy::StreamingResponseRestorer`),这里是 WS 的同一个位置
|
||||
// ——最后一跳之前,并且在 `capture_client_frame` 之前,所以审计与
|
||||
// 终态观测继续消费脱敏态的事件。没有命中还原时保持上游原字节。
|
||||
let restored_client_frame = parsed_upstream_frame
|
||||
.as_ref()
|
||||
.map(ParsedResponsesWebSocketFrame::event)
|
||||
.and_then(|event| {
|
||||
bound.redaction_restorer.restore_provider_frame_text(event)
|
||||
});
|
||||
let client_frame = match restored_client_frame {
|
||||
Some(restored) => AxumWsMessage::Text(restored.into()),
|
||||
None => upstream_message_to_client(upstream_message.clone()),
|
||||
};
|
||||
if let Err(error) = send_client_message(client_socket, client_frame).await {
|
||||
warn!(
|
||||
event_name = "responses_websocket_client_send_failed",
|
||||
log_type = "ops",
|
||||
|
||||
Reference in New Issue
Block a user