mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
Generated
+1
@@ -1915,6 +1915,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"percent-encoding",
|
||||
"redis",
|
||||
"secrecy",
|
||||
"serde",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 服务端需事先创建并授权该用户。
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user