修复应用状态调试输出密钥泄漏 #150

Merged
kdletters merged 3 commits from codex/fix-issue-148 into master 2026-08-07 23:56:02 +08:00
5 changed files with 199 additions and 5 deletions
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## 派生 Debug 会让完整配置经应用状态递归进入日志
- 现象:配置和状态当前没有直接日志调用,但新增一行 `debug!(?state, ...)``format!("{config:?}")` 就能把 JWT、后台口令、支付私钥、OSS / provider key 与 SpacetimeDB token 一次性写入日志及 OTel 留存面。
- 原因:`AppConfig``AppState``AppStateInner` 曾使用派生 `Debug`;状态继续递归格式化多个含配置的 client。即使顶层状态停止下钻,`SpacetimeClientConfig``SpacetimeClient` 的独立手写路径仍会绕过顶层防线。
- 处理:配置和聚合状态只实现封闭的手写安全摘要,不格式化任一自由字符串或含凭据的嵌套 client;`SpacetimeClientConfig` 独立脱敏,`SpacetimeClient` 只复用该安全摘要。不要以默认 `info` 级别或当前零调用点代替代码约束。
- 验证:同一唯一哨兵同时填入全部凭据字段、可能带凭据的 SpacetimeDB URL 和数据库名,逐一格式化 `AppConfig``AppStateInner``AppState``SpacetimeClientConfig``SpacetimeClient`,断言哨兵零出现且安全运行摘要仍存在。
- 关联:`server-rs/crates/api-server/src/config.rs``server-rs/crates/api-server/src/state.rs``server-rs/crates/spacetime-client/src/active.rs`、Issue #148
## Chat 生成预算字段不能按模型名猜测或失败后自动重放
- 现象:同一个 OpenAI-compatible Chat endpoint 调用 reasoning 模型时返回 `Unsupported parameter: max_tokens`;直接把全局请求字段改成 `max_completion_tokens` 后,旧兼容网关又可能拒绝新字段。
@@ -47,6 +47,7 @@
- 新增 Markdown 文档时,文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;只在任务需要时重命名历史文档,避免无关大 diff。
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
- 运行时日志禁止对完整配置、应用状态或 provider client 做递归 `Debug` 输出;当前 `AppConfig``AppState``AppStateInner``SpacetimeClientConfig``SpacetimeClient` 必须保持封闭的手写安全摘要,并用唯一哨兵测试锁定顶层与可独立格式化路径。其它仍使用派生 `Debug` 的历史 provider 类型不得新增整对象日志调用,后续按类型独立脱敏。新增字段默认不进入摘要,确需排障时只增加枚举、数值、布尔值或是否配置等非敏感字段。
- `packages/shared` 用于前后端 DTO、公开契约及跨页面复用的无业务真相 UI 组件和纯工具;不得把领域规则、后端副作用或正式状态放入其中。
- 修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义时,必须同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 和对应契约测试;Rust 实现与 OpenAPI 未对齐时不得完成、提交或发布。
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run dev:api-server``/healthz`
+37 -2
View File
@@ -1,4 +1,4 @@
use std::{env, fs, net::SocketAddr, path::PathBuf, time::Duration};
use std::{env, fmt, fs, net::SocketAddr, path::PathBuf, time::Duration};
use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
@@ -27,7 +27,7 @@ const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com
const DEFAULT_ALIYUN_MATTING_REQUEST_TIMEOUT_MS: u64 = 30_000;
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
@@ -212,6 +212,41 @@ pub struct AppConfig {
pub slow_request_threshold_ms: u64,
}
impl fmt::Debug for AppConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 配置由环境变量和私密文件聚合而来。这里只输出排障所需的封闭运行摘要,
// 不递归格式化任一字符串配置,避免 URL 凭据、Token、私钥或未来新增字段进入日志。
f.debug_struct("AppConfig")
.field("bind_port", &self.bind_port)
.field("listen_backlog", &self.listen_backlog)
.field("worker_threads", &self.worker_threads)
.field("process_role", &self.process_role)
.field("external_generation_mode", &self.external_generation_mode)
.field(
"external_generation_worker_concurrency",
&self.external_generation_worker_concurrency,
)
.field("max_concurrent_requests", &self.max_concurrent_requests)
.field(
"admin_max_concurrent_requests",
&self.admin_max_concurrent_requests,
)
.field("spacetime_pool_size", &self.spacetime_pool_size)
.field("sms_auth_enabled", &self.sms_auth_enabled)
.field("wechat_auth_enabled", &self.wechat_auth_enabled)
.field("wechat_pay_enabled", &self.wechat_pay_enabled)
.field("aliyun_matting_enabled", &self.aliyun_matting_enabled)
.field("tracking_outbox_enabled", &self.tracking_outbox_enabled)
.field(
"wallet_refund_outbox_enabled",
&self.wallet_refund_outbox_enabled,
)
.field("otel_enabled", &self.otel_enabled)
.field("credentials", &"<redacted>")
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProcessRole {
Api,
+135 -2
View File
@@ -122,9 +122,15 @@ impl BackpressureState {
}
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AppState(Arc<AppStateInner>);
impl fmt::Debug for AppState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("AppState").field(self.0.as_ref()).finish()
}
}
impl std::ops::Deref for AppState {
type Target = AppStateInner;
@@ -225,7 +231,6 @@ impl FromRef<AppState> for PuzzleApiState {
}
// Axum/Hyper 会在路由树和连接 service 上频繁 clone stateAppState 外层必须保持浅拷贝。
#[derive(Debug)]
pub struct AppStateInner {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
@@ -285,6 +290,35 @@ pub struct AppStateInner {
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
impl fmt::Debug for AppStateInner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// AppState 聚合多个仍可独立 Debug 的平台 client。这里使用封闭摘要,禁止 Debug
// 递归下钻到 JWT、管理员口令、OSS、微信、LLM 等运行时凭据。
f.debug_struct("AppStateInner")
.field("config", &self.config)
.field("ready", &self.ready.load(Ordering::Relaxed))
.field(
"bgfilter_worker_reached",
&self.bgfilter_worker_reached.load(Ordering::Relaxed),
)
.field("admin_runtime_enabled", &self.admin_runtime.is_some())
.field("oss_client_enabled", &self.oss_client.is_some())
.field("spacetime_client", &self.spacetime_client)
.field("tracking_outbox_enabled", &self.tracking_outbox.is_some())
.field(
"wallet_refund_outbox_enabled",
&self.wallet_refund_outbox.is_some(),
)
.field("llm_client_enabled", &self.llm_client.is_some())
.field(
"editor_agent_llm_client_enabled",
&self.editor_agent_llm_client.is_some(),
)
.field("matting_client_enabled", &self.matting_client.is_some())
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
#[cfg(any())]
struct CreativeAgentSessionRuntimeRecord {
@@ -2253,6 +2287,105 @@ mod tests {
use super::*;
#[test]
fn debug_summaries_redact_all_runtime_credentials() {
const SENSITIVE_KEY_LURE: &str = "ISSUE_148_DEBUG_SECRET_LURE";
let secret = || Some(SENSITIVE_KEY_LURE.to_string());
let config = AppConfig {
bgfilter_internal_token: secret(),
editor_bgfilter_token: secret(),
aliyun_matting_access_key_id: secret(),
aliyun_matting_access_key_secret: secret(),
admin_username: Some("debug-admin".to_string()),
admin_password: secret(),
internal_api_secret: secret(),
jwt_secret: SENSITIVE_KEY_LURE.to_string(),
sms_access_key_id: secret(),
sms_access_key_secret: secret(),
sms_mock_verify_code: SENSITIVE_KEY_LURE.to_string(),
wechat_app_secret: secret(),
wechat_mini_program_app_secret: secret(),
wechat_pay_private_key_pem: secret(),
wechat_pay_private_key_path: Some(std::path::PathBuf::from(SENSITIVE_KEY_LURE)),
wechat_pay_platform_public_key_pem: secret(),
wechat_pay_platform_public_key_path: Some(std::path::PathBuf::from(SENSITIVE_KEY_LURE)),
wechat_pay_api_v3_key: secret(),
wechat_mini_program_virtual_payment_offer_id: secret(),
wechat_mini_program_virtual_payment_app_key: secret(),
wechat_mini_program_virtual_payment_sandbox_app_key: secret(),
wechat_mini_program_message_token: secret(),
wechat_mini_program_message_encoding_aes_key: secret(),
oss_bucket: Some("debug-bucket".to_string()),
oss_endpoint: Some("oss.example.invalid".to_string()),
oss_access_key_id: secret(),
oss_access_key_secret: secret(),
spacetime_server_url: format!("https://spacetime.invalid/?token={SENSITIVE_KEY_LURE}"),
spacetime_database: format!("debug-{SENSITIVE_KEY_LURE}"),
spacetime_token: secret(),
spacetime_runtime_service_bootstrap_secret: secret(),
llm_base_url: "https://llm.example.invalid".to_string(),
llm_api_key: secret(),
llm_model: "debug-model".to_string(),
dashscope_api_key: secret(),
vector_engine_base_url: "https://vector.example.invalid".to_string(),
vector_engine_api_key: secret(),
hyper3d_api_key: secret(),
volcengine_speech_api_key: secret(),
volcengine_speech_app_id: secret(),
volcengine_speech_access_key: secret(),
ark_character_video_api_key: secret(),
..AppConfig::default()
};
let spacetime_config = spacetime_client_config_for_process(&config);
let spacetime_client = SpacetimeClient::new(spacetime_config.clone());
let state = AppState::new(config.clone()).expect("state should build");
let config_debug = format!("{config:?}");
let expected_config_debug = format!(
"AppConfig {{ bind_port: {:?}, listen_backlog: {:?}, worker_threads: {:?}, process_role: {:?}, external_generation_mode: {:?}, external_generation_worker_concurrency: {:?}, max_concurrent_requests: {:?}, admin_max_concurrent_requests: {:?}, spacetime_pool_size: {:?}, sms_auth_enabled: {:?}, wechat_auth_enabled: {:?}, wechat_pay_enabled: {:?}, aliyun_matting_enabled: {:?}, tracking_outbox_enabled: {:?}, wallet_refund_outbox_enabled: {:?}, otel_enabled: {:?}, credentials: \"<redacted>\", .. }}",
config.bind_port,
config.listen_backlog,
config.worker_threads,
config.process_role,
config.external_generation_mode,
config.external_generation_worker_concurrency,
config.max_concurrent_requests,
config.admin_max_concurrent_requests,
config.spacetime_pool_size,
config.sms_auth_enabled,
config.wechat_auth_enabled,
config.wechat_pay_enabled,
config.aliyun_matting_enabled,
config.tracking_outbox_enabled,
config.wallet_refund_outbox_enabled,
config.otel_enabled,
);
assert_eq!(
config_debug, expected_config_debug,
"AppConfig Debug 只能输出显式允许的枚举、数值、布尔值和脱敏占位;新增自由字符串必须默认缺席"
);
let outputs = [
("AppConfig", config_debug.clone()),
("SpacetimeClientConfig", format!("{spacetime_config:?}")),
("SpacetimeClient", format!("{spacetime_client:?}")),
("AppStateInner", format!("{:?}", state.0.as_ref())),
("AppState", format!("{state:?}")),
];
for (type_name, output) in outputs {
assert!(
!output.contains(SENSITIVE_KEY_LURE),
"{type_name} Debug leaked the credential lure: {output}"
);
}
assert!(config_debug.contains("process_role"));
assert!(config_debug.contains("<redacted>"));
assert!(format!("{spacetime_config:?}").contains("pool_size"));
assert!(format!("{state:?}").contains("ready: true"));
}
#[test]
fn app_state_reuses_character_animation_oss_client_and_eight_permits() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -106,7 +106,7 @@ use tracing::warn;
use crate::module_bindings::*;
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct SpacetimeClientConfig {
pub server_url: String,
pub database: String,
@@ -116,6 +116,23 @@ pub struct SpacetimeClientConfig {
pub subscribe_cached_read_models: bool,
}
impl fmt::Debug for SpacetimeClientConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// server_url 也可能包含 userinfo 或查询凭据,因此与 token 一并只输出安全摘要。
f.debug_struct("SpacetimeClientConfig")
.field("server_url_configured", &!self.server_url.trim().is_empty())
.field("database_configured", &!self.database.trim().is_empty())
.field("token", &"<redacted>")
.field("pool_size", &self.pool_size)
.field("procedure_timeout", &self.procedure_timeout)
.field(
"subscribe_cached_read_models",
&self.subscribe_cached_read_models,
)
.finish()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpacetimeClientStage {
Ready,