From 591f95cc18ce95eb6fc87cf46a3526c5b3ecba36 Mon Sep 17 00:00:00 2001 From: Ray <19251440+swjturay@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:34:20 +0800 Subject: [PATCH] fix(store): accept managed PostgreSQL and Redis passwords (#467) * fix(store): accept managed PostgreSQL passwords Co-authored-by: Codex * fix(store): unify PostgreSQL and Redis password validation Co-authored-by: Codex * fix(store): preserve literal percent sequences in passwords Co-authored-by: Codex --------- Co-authored-by: Codex Co-authored-by: Zyy --- backend/Cargo.lock | 1 + .../apps/gateway/tests/bootstrap/config.rs | 55 ++++++- backend/crates/gateway-store/Cargo.toml | 1 + backend/crates/gateway-store/src/config.rs | 12 +- backend/crates/gateway-store/tests/bundle.rs | 140 +++++++++++++++++- backend/migrations/README.md | 5 +- deploy/README.md | 11 +- deploy/config.example.yaml | 2 + 8 files changed, 207 insertions(+), 20 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 61f701932..ca126e6de 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1915,6 +1915,7 @@ dependencies = [ "gateway-admin", "gateway-core", "hex", + "percent-encoding", "redis", "secrecy", "serde", diff --git a/backend/apps/gateway/tests/bootstrap/config.rs b/backend/apps/gateway/tests/bootstrap/config.rs index 32aee4a97..adb777744 100644 --- a/backend/apps/gateway/tests/bootstrap/config.rs +++ b/backend/apps/gateway/tests/bootstrap/config.rs @@ -106,8 +106,11 @@ fn config_loader_should_apply_only_explicit_topology_overrides() { "postgres://codex_proxy@127.0.0.1:5432/codex_proxy", ) .env("CPR_REDIS_URL", "redis://127.0.0.1:6379/") - .env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD) - .env("CPR_REDIS_PASSWORD", REDIS_PASSWORD) + .env( + "CPR_DATABASE_PASSWORD", + "RdsTest9!abcdefGhRdsTest9!abcdefGh", + ) + .env("CPR_REDIS_PASSWORD", "ManagedRedis9!abcdefGh") .status() .expect("run isolated environment override test"); assert!(status.success()); @@ -282,13 +285,53 @@ fn config_loader_should_reject_embedded_database_password() { } #[test] -fn config_loader_should_reject_non_hex_postgres_password() { - assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48))); +fn config_loader_should_accept_managed_store_passwords() { + for password in [ + "RdsTest9!abcdefGh".to_owned(), + "RdsTest9!abcdefGhRdsTest9!abcdefGh".to_owned(), + POSTGRES_PASSWORD.to_owned(), + "x".repeat(128), + "Managed@:/?#%&+Password9!".to_owned(), + "数据库密码".repeat(4), + ] { + for field in ["database", "redis"] { + let mut document = valid_config_document(); + document["store"][field]["password"] = serde_json::json!(password); + let (config, _directory) = + parse_config(&document.to_string()).expect("managed store password"); + assert!(!format!("{config:?}").contains(&password)); + } + } } #[test] -fn config_loader_should_reject_wrong_length_redis_password() { - assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234")); +fn config_loader_should_reject_invalid_store_passwords() { + for password in [ + String::new(), + "x".repeat(15), + "x".repeat(129), + " ".repeat(32), + "\u{3000}".repeat(6), + "数据库密码".repeat(9), + "RdsTest9!abcdefGh\u{0000}".to_owned(), + "RdsTest9!abcdefGh\n".to_owned(), + "RdsTest9!abcdefGh\t".to_owned(), + "RdsTest9!abcdefGh\u{007f}".to_owned(), + ] { + for field in ["database", "redis"] { + let mut document = valid_config_document(); + document["store"][field]["password"] = serde_json::json!(password); + assert_rejected(document.to_string()); + } + } +} + +#[test] +fn config_loader_should_reject_embedded_redis_password() { + assert_rejected(valid_config().replace( + "redis://127.0.0.1:6379/", + "redis://default:embedded@127.0.0.1:6379/", + )); } #[test] diff --git a/backend/crates/gateway-store/Cargo.toml b/backend/crates/gateway-store/Cargo.toml index cc3fadc8e..913d5a140 100644 --- a/backend/crates/gateway-store/Cargo.toml +++ b/backend/crates/gateway-store/Cargo.toml @@ -37,6 +37,7 @@ url = "2.5.8" uuid = { version = "1.23.3", features = ["v4", "v7"] } [dev-dependencies] +percent-encoding = "2.3" tempfile = "3.23" tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "fs", "io-util", "net", "process", "time", "test-util"] } diff --git a/backend/crates/gateway-store/src/config.rs b/backend/crates/gateway-store/src/config.rs index 1dfd46326..625612963 100644 --- a/backend/crates/gateway-store/src/config.rs +++ b/backend/crates/gateway-store/src/config.rs @@ -184,12 +184,17 @@ pub(crate) struct StoreConnectionConfig { impl StoreConnectionConfig { fn validate(&self, field: &'static str) -> StoreResult<()> { require_nonempty("store config", field, &self.url)?; - if self.password.len() != 48 || !self.password.bytes().all(|byte| byte.is_ascii_hexdigit()) + // 托管存储的具体密码策略由服务端约束,客户端共用长度与控制字符边界 + if !(16..=128).contains(&self.password.len()) + || self.password.chars().any(char::is_control) + || self.password.trim().is_empty() { return Err(StoreError::InvalidData { source: None, entity: "store config", - message: format!("{field}.password must be exactly 48 hexadecimal characters"), + message: format!( + "{field}.password must be 16 to 128 bytes, not blank, without control characters" + ), }); } self.connection_url(field).map(|_| ()) @@ -208,7 +213,8 @@ impl StoreConnectionConfig { message: format!("{field}.url must not contain a password"), }); } - url.set_password(Some(&self.password)) + // set_password 保留已有百分号序列,先转义字面量 %,避免驱动解码后改变原始密码 + url.set_password(Some(&self.password.replace('%', "%25"))) .map_err(|()| StoreError::InvalidData { source: None, entity: "store config", diff --git a/backend/crates/gateway-store/tests/bundle.rs b/backend/crates/gateway-store/tests/bundle.rs index 8be9e20f0..3a7481110 100644 --- a/backend/crates/gateway-store/tests/bundle.rs +++ b/backend/crates/gateway-store/tests/bundle.rs @@ -1,4 +1,4 @@ -//! 验证只读检查 Bundle 不执行迁移且拒绝业务写入 +//! 验证 Store 认证、凭据编码与只读检查 Bundle 的写入边界 use gateway_core::{ account::{ @@ -33,11 +33,6 @@ async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() { database .query_pairs_mut() .append_pair("options", &format!("-csearch_path={schema}")); - let config_connection = |mut url: url::Url| { - let password = url.password().unwrap().to_owned(); - url.set_password(None).unwrap(); - json!({"url":url.as_str(),"password":password}) - }; let mut config: StoreConfig = serde_json::from_value(json!({ "database":config_connection(database), "redis":config_connection(url::Url::parse(&redis).unwrap()), })).unwrap(); @@ -109,3 +104,136 @@ async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() { .unwrap(); admin.close().await; } + +#[tokio::test] +async fn store_bundle_authenticates_configured_and_redis_acl_credentials() { + let (Some(database), Some(redis_url)) = ( + crate::support::test_env("CPR_TEST_DATABASE_URL"), + crate::support::test_env("CPR_TEST_REDIS_URL"), + ) else { + return; + }; + let document = json!({ + "database": config_connection(url::Url::parse(&database).unwrap()), + "redis": config_connection(url::Url::parse(&redis_url).unwrap()), + }); + let mut admin = redis::Client::open(redis_url.as_str()) + .unwrap() + .get_connection_manager() + .await + .unwrap(); + let username = format!("cpr_password_{}", uuid::Uuid::new_v4().simple()); + let password = "Managed%41%25%FF@:/?#%&+$'\"\\ secret9!"; + redis::cmd("ACL") + .arg("SETUSER") + .arg(&username) + .arg("on") + .arg(format!(">{password}")) + .arg("~*") + .arg("&*") + .arg("+@all") + .query_async::<()>(&mut admin) + .await + .unwrap(); + let mut acl_url = url::Url::parse(&redis_url).unwrap(); + acl_url.set_username(&username).unwrap(); + acl_url.set_password(None).unwrap(); + let mut acl_document = document.clone(); + acl_document["redis"] = json!({"url": acl_url.as_str(), "password": password}); + let directory = tempfile::tempdir().unwrap(); + let mut results = Vec::new(); + // 同时走 Store 的配置解析、URL 构造与真实连接路径,断言前先清理临时 ACL 用户 + for document in [document, acl_document] { + let mut config: StoreConfig = serde_json::from_value(document).unwrap(); + let result = async { + config.resolve_and_validate(directory.path())?; + let bundle = gateway_store::initialize_read_only(config).await?; + let mut states = Vec::new(); + for probe in bundle.health_probes() { + states.push(probe.check().await); + } + Ok::<_, gateway_store::StoreError>(states) + } + .await; + results.push(result); + } + redis::cmd("ACL") + .arg("DELUSER") + .arg(&username) + .query_async::(&mut admin) + .await + .unwrap(); + for result in results { + let states = result.expect("Store credentials authenticate without double encoding"); + assert!(!states.is_empty()); + assert!( + states + .iter() + .all(|state| *state == gateway_core::health::HealthState::Healthy) + ); + } +} + +#[tokio::test] +async fn store_bundle_authenticates_postgres_password_with_percent_sequences() { + let (Some(database), Some(redis_url)) = ( + crate::support::test_env("CPR_TEST_DATABASE_URL"), + crate::support::test_env("CPR_TEST_REDIS_URL"), + ) else { + return; + }; + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&database) + .await + .unwrap(); + let username = format!("cpr_password_{}", uuid::Uuid::new_v4().simple()); + let password = "Managed%41%25%FFPassword9!"; + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "CREATE ROLE {username} LOGIN PASSWORD '{password}'" + ))) + .execute(&admin) + .await + .unwrap(); + let mut database_url = url::Url::parse(&database).unwrap(); + database_url.set_username(&username).unwrap(); + database_url.set_password(None).unwrap(); + let mut config: StoreConfig = serde_json::from_value(json!({ + "database": {"url": database_url.as_str(), "password": password}, + "redis": config_connection(url::Url::parse(&redis_url).unwrap()), + })) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let result = async { + config.resolve_and_validate(directory.path())?; + let bundle = gateway_store::initialize_read_only(config).await?; + let mut states = Vec::new(); + for probe in bundle.health_probes() { + states.push(probe.check().await); + } + Ok::<_, gateway_store::StoreError>(states) + } + .await; + // 连接失败时也先清理临时角色,避免回归断言留下测试账号 + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP ROLE {username}"))) + .execute(&admin) + .await + .unwrap(); + admin.close().await; + let states = result.expect("PostgreSQL password percent sequences remain literal"); + assert!(!states.is_empty()); + assert!( + states + .iter() + .all(|state| *state == gateway_core::health::HealthState::Healthy) + ); +} + +fn config_connection(mut url: url::Url) -> serde_json::Value { + let password = percent_encoding::percent_decode_str(url.password().unwrap()) + .decode_utf8() + .expect("UTF-8 fixture password") + .into_owned(); + url.set_password(None).unwrap(); + json!({"url": url.as_str(), "password": password}) +} diff --git a/backend/migrations/README.md b/backend/migrations/README.md index 2705970bb..7b341d9fc 100644 --- a/backend/migrations/README.md +++ b/backend/migrations/README.md @@ -48,7 +48,8 @@ export CPR_TEST_REDIS_URL='redis://:@127.0.0.1:6379' PostgreSQL 的临时 schema 测试连接使用异步提交,保留事务可见性、回滚和完整迁移检查 需要验证数据库崩溃后的持久性时,使用独立数据库与生产连接配置,不复用这类 fixture -运行包含 `StoreBundle` 初始化的完整集成测试时,两条测试 URL 都须包含密码,且密码满足 Store -启动配置的 48 位十六进制要求;仅能连接数据库并不代表该初始化合同通过。专用服务使用对应测试密码, +运行包含 `StoreBundle` 初始化的完整集成测试时,两条测试 URL 都须包含密码,原始密码须满足 +[Store 密码要求](../../deploy/README.md#手动安装),在测试 URL 中按 URL 规则编码。 +仅能连接数据库并不代表该初始化合同通过。专用服务使用对应测试密码, 并在测试进程中清除 `CPR_DATABASE_URL`、`CPR_REDIS_URL`、`CPR_DATABASE_PASSWORD` 和 `CPR_REDIS_PASSWORD`,避免启动配置被部署环境覆盖 diff --git a/deploy/README.md b/deploy/README.md index 2586f061c..3e189b34e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -159,8 +159,13 @@ openssl rand -hex 24 推荐使用模板中的 7 天不活动期限和 30 天最长登录期限。修改配置需重启,不能延长已建立会话的最长有效期。 浏览器会话与续期规则见[认证 API](../docs/api.md#统一登录与会话),独立于 `/v1/*` 鉴权和限额 -PostgreSQL 与 Redis 密码必须是 48 位十六进制字符。Compose 通过 `config.yaml` 的凭据桥接区 -引用同一密码;三个值都不需要额外导出为环境变量,数据库和 Redis 密码也不能嵌入连接 URL +PostgreSQL 与 Redis 密码统一接受 16–128 字节,不能全为空白或包含控制字符;仍须满足 +各自服务端的密码策略。托管服务可使用服务商允许的高强度密码,默认安装继续生成 48 位随机十六进制密码 + +Compose 通过 `config.yaml` 的凭据桥接区引用同一密码;三个值都不需要额外导出为环境变量, +数据库和 Redis 密码不能嵌入连接 URL。通过环境变量提供密码时分别使用 +`CPR_DATABASE_PASSWORD` 与 `CPR_REDIS_PASSWORD`,原始密码由连接 URL 构造器编码,不要预先做 URL 编码。Compose 凭据桥接建议沿用上面的 +随机十六进制密码,避免 `$` 的变量插值语义 Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和日志目录设为 `0770`, 配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。 @@ -174,7 +179,7 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和 store: redis: url: 'redis://u1@redis-host:6379/0' - password: &redis_password '' + password: &redis_password '' ``` 将这两项合并到已有配置,保留其他配置和密码锚点;Redis 服务端需事先创建并授权该用户。 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index ac683e2c1..fb97ad6d7 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -44,11 +44,13 @@ store: database: # Docker 内由 CPR_DATABASE_URL 覆盖;密码不要写进 URL。 url: 'postgres://codex_proxy@127.0.0.1:5432/codex_proxy' + # 16–128 字节,不能全为空白或含控制字符,并满足服务端密码策略 password: &postgres_password '' redis: # Docker 内由 CPR_REDIS_URL 覆盖;密码不要写进 URL。 # ACL 用户写在 URL 中,例如 redis://u1@redis-host:6379/0;配置步骤见 deploy/README.md。 url: 'redis://127.0.0.1:6379/' + # 与 PostgreSQL 共用密码格式要求,并满足 Redis 服务端密码策略 password: &redis_password '' # 可选:PostgreSQL 连接池预算;下列值与当前推荐默认一致。 # pool: