fix(store): accept managed PostgreSQL and Redis passwords (#467)

* fix(store): accept managed PostgreSQL passwords

Co-authored-by: Codex <noreply@openai.com>

* fix(store): unify PostgreSQL and Redis password validation

Co-authored-by: Codex <noreply@openai.com>

* fix(store): preserve literal percent sequences in passwords

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
This commit is contained in:
Ray
2026-10-18 20:34:20 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 99ba5b1120
commit 591f95cc18
8 changed files with 207 additions and 20 deletions
+1
View File
@@ -1915,6 +1915,7 @@ dependencies = [
"gateway-admin",
"gateway-core",
"hex",
"percent-encoding",
"redis",
"secrecy",
"serde",
+49 -6
View File
@@ -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]
+1
View File
@@ -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"] }
+9 -3
View File
@@ -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",
+134 -6
View File
@@ -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::<i64>(&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})
}
+3 -2
View File
@@ -48,7 +48,8 @@ export CPR_TEST_REDIS_URL='redis://:<password>@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`,避免启动配置被部署环境覆盖
+8 -3
View File
@@ -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 '<u1 的 48 位十六进制密码>'
password: &redis_password '<u1 的密码>'
```
将这两项合并到已有配置,保留其他配置和密码锚点;Redis 服务端需事先创建并授权该用户。
+2
View File
@@ -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: