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:
AAEE86
2026-08-17 14:53:46 +08:00
committed by ZheFox
parent dbf2809bd6
commit 6fc02dad3e
8 changed files with 603 additions and 27 deletions
@@ -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",