修复 BgFilter 内部 Token 单段校验
统一校验 BgFilter 内部 Token 并限制启动角色 部署与 provision 预检拒绝内部空白和多行 Token 补充定向测试、运维门禁与架构文档
This commit is contained in:
@@ -382,7 +382,7 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
|
||||
- 首版限定父 worker 与子 worker 同机部署,内部 listener 只绑定 loopback 固定端口,不挂公共 Axum router、Nginx、BFF 或 OpenAPI。
|
||||
- 使用独立内部 Token;缺失时生产 fail-closed。Token 不复用 BgFilter provider Token。
|
||||
- 父、子进程只从同一个 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE` 读取内部 Token。发布脚本必须在切换 `current` 链接前确认该路径是非符号链接的普通非空文件,owner / group / mode 符合 `root:genarrative 0440`;不能先切换版本、再等 readiness 暴露首次未 provision 或权限错误。
|
||||
- 父、子进程只从同一个 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE` 读取内部 Token。首尾空白按配置读取规则规范化,规范化后的正文必须是非空且不含任何空白字符的单段值;相关进程启动和发布 preflight 都必须拒绝含内部空格、Tab 或多个非空行的 Token。发布脚本还必须在切换 `current` 链接前确认该路径是非符号链接的普通非空文件,owner / group / mode 符合 `root:genarrative 0440`;不能先切换版本、再等 readiness 暴露首次未 provision、格式或权限错误。
|
||||
- 只接受配置 bucket 下的规范化 object key;禁止 `http://`、`https://`、`data:`、`blob:` 和路径逃逸。
|
||||
- 不在日志、trace、metrics、错误 JSON 或 SpacetimeDB 审计中写签名 URL、Token、图片字节或 Base64。
|
||||
|
||||
@@ -438,7 +438,7 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
|
||||
### 10.2 部署与回滚
|
||||
|
||||
1. 在切换发布目录前完成 preflight:共享 API env 与两类 worker env 均存在;`external-generation-worker.env` 和 `bgfilter-worker.env` 不得把共享 BgFilter 配置覆盖为不同有效值;父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint;内部 Token 文件存在、非空、非符号链接且权限正确;`N / Q` 为正整数且 `Q >= N`。
|
||||
1. 在切换发布目录前完成 preflight:共享 API env 与两类 worker env 均存在;`external-generation-worker.env` 和 `bgfilter-worker.env` 不得把共享 BgFilter 配置覆盖为不同有效值;父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint;内部 Token 文件存在、非空、非符号链接,规范化后是无空白的单段值且权限正确;`N / Q` 为正整数且 `Q >= N`。
|
||||
2. 安装非模板单实例 unit;它先加载 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env`。
|
||||
3. 执行 `stop old -> 等待排空/退出 -> start new`,确认唯一 `bgfilter-worker` 的 loopback readiness、鉴权和 provider smoke,不得滚动重叠。
|
||||
4. 再重启使用内部 client 的 API / external-generation-worker / controller。
|
||||
@@ -484,7 +484,7 @@ BgFilter 成功二进制不是一份新的业务资产:
|
||||
- `external-generation-worker.env` 后加载时不得把内部 base URL、Token / Token 文件、connect timeout、`N`、`est`、OSS bucket 或 endpoint 覆盖为与共享 API env 不同的有效值;父侧必须把源对象写到子 worker 将要签名读取的同一 OSS 位置。外部生成 worker 可使用同 bucket 下权限等价或更小的独立 AK,不要求凭据文本相同。
|
||||
- 父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和部署 readiness URL 必须指向同一个 `127.0.0.1:<port>` endpoint;旧非空配置不能因为“无需补默认值”而绕过一致性检查。
|
||||
- `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖 `callBudgetMs` 排空上界(约 `321s`)与停止收口余量;停止时排队请求立即类型化失败,不参与排空。
|
||||
- 发布目录切换前拒绝缺失、空、符号链接或权限错误的内部 Token 文件;父、子有效 Token 文件路径必须相同。
|
||||
- 发布目录切换前拒绝缺失、空、含内部空白、包含多个非空行、符号链接或权限错误的内部 Token 文件;允许文件末尾正常换行,父、子有效 Token 文件路径必须相同。
|
||||
- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 worker `readyz` 成功(默认 `127.0.0.1:8083`),不能只依赖 systemd 自动重启。provision / deploy 的发布验活 URL 必须从已验证的 env `HOST/PORT` 派生,不得硬编码默认端口;若运维自定义 `GENARRATIVE_BGFILTER_WORKER_PORT`,必须同步覆盖巡检的 `GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL`(巡检是独立进程,不读取 worker env,默认值不会自动跟随)。
|
||||
- `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ function main() {
|
||||
assertDeployRejectsInlineBgFilterInternalToken();
|
||||
assertDeployRejectsEmptyBgFilterInternalToken();
|
||||
assertDeployRejectsWhitespaceBgFilterInternalToken();
|
||||
assertDeployRejectsInternalWhitespaceBgFilterInternalToken();
|
||||
assertDeployRejectsMultilineBgFilterInternalToken();
|
||||
assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch();
|
||||
assertMissingReleaseManifestFails();
|
||||
assertReleaseManifestMissingApiArtifactFails();
|
||||
@@ -933,7 +935,7 @@ function assertDeployRejectsWhitespaceBgFilterInternalToken() {
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'BgFilter 内部 Token 文件必须至少包含一个非空白字符',
|
||||
'BgFilter 内部 Token 文件必须为不含空白字符的单段值',
|
||||
'纯空白 Token 文件预检必须给出明确错误。',
|
||||
);
|
||||
assertBgFilterPreflightFailedBeforeSwitch(
|
||||
@@ -942,6 +944,44 @@ function assertDeployRejectsWhitespaceBgFilterInternalToken() {
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeployRejectsInternalWhitespaceBgFilterInternalToken() {
|
||||
const fixture = prepareFixture('internal-whitespace-bgfilter-internal-token');
|
||||
writeFileSync(fixture.bgfilterTokenFile, 'fixture bgfilter-token\n', 'utf8');
|
||||
const result = runDeploy(fixture);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('BgFilter 内部 Token 含内部空格时部署必须失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'BgFilter 内部 Token 文件必须为不含空白字符的单段值',
|
||||
'含内部空格的 Token 文件预检必须给出明确错误。',
|
||||
);
|
||||
assertBgFilterPreflightFailedBeforeSwitch(
|
||||
fixture,
|
||||
'BgFilter 含内部空格 Token 文件预检失败',
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeployRejectsMultilineBgFilterInternalToken() {
|
||||
const fixture = prepareFixture('multiline-bgfilter-internal-token');
|
||||
writeFileSync(fixture.bgfilterTokenFile, 'fixture-token\nsecond-token\n', 'utf8');
|
||||
const result = runDeploy(fixture);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('BgFilter 内部 Token 包含多个非空行时部署必须失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'BgFilter 内部 Token 文件必须为不含空白字符的单段值',
|
||||
'多行 Token 文件预检必须给出明确错误。',
|
||||
);
|
||||
assertBgFilterPreflightFailedBeforeSwitch(
|
||||
fixture,
|
||||
'BgFilter 多行 Token 文件预检失败',
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeployRejectsInlineBgFilterInternalToken() {
|
||||
const cases = [
|
||||
['api-env', 'apiEnvFile'],
|
||||
|
||||
@@ -1681,9 +1681,9 @@ const checks = [
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
includes: 'BgFilter 内部 Token 文件不得为空或只包含空白字符',
|
||||
includes: 'BgFilter 内部 Token 文件必须为不含空白字符的单段值',
|
||||
reason:
|
||||
'Server-Provision 必须拒绝仅含空白字符的 BgFilter 内部 Token 文件。',
|
||||
'Server-Provision 必须拒绝纯空白、含内部空白或包含多个非空行的 BgFilter 内部 Token 文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
|
||||
@@ -654,6 +654,22 @@ validate_no_bgfilter_internal_token_plaintext() {
|
||||
done
|
||||
}
|
||||
|
||||
bgfilter_internal_token_file_is_single_segment() {
|
||||
local token_file="$1"
|
||||
|
||||
run_privileged awk '
|
||||
/[^[:space:]]/ {
|
||||
non_empty_lines += 1
|
||||
if ($0 !~ /^[[:space:]]*[^[:space:]]+[[:space:]]*$/) {
|
||||
invalid = 1
|
||||
}
|
||||
}
|
||||
END {
|
||||
exit !(non_empty_lines == 1 && invalid == 0)
|
||||
}
|
||||
' "${token_file}"
|
||||
}
|
||||
|
||||
validate_bgfilter_internal_token_file() {
|
||||
local api_env_file="$1"
|
||||
local token_file token_metadata
|
||||
@@ -667,8 +683,8 @@ validate_bgfilter_internal_token_file() {
|
||||
echo "[production-api-deploy] BgFilter 内部 Token 必须是非空普通文件且不能是符号链接: ${token_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! run_privileged grep -q '[^[:space:]]' -- "${token_file}"; then
|
||||
echo "[production-api-deploy] BgFilter 内部 Token 文件必须至少包含一个非空白字符: ${token_file}" >&2
|
||||
if ! bgfilter_internal_token_file_is_single_segment "${token_file}"; then
|
||||
echo "[production-api-deploy] BgFilter 内部 Token 文件必须为不含空白字符的单段值: ${token_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -809,6 +809,22 @@ ensure_bgfilter_worker_runtime_env_defaults() {
|
||||
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
|
||||
}
|
||||
|
||||
bgfilter_internal_token_file_is_single_segment() {
|
||||
local token_file="$1"
|
||||
|
||||
awk '
|
||||
/[^[:space:]]/ {
|
||||
non_empty_lines += 1
|
||||
if ($0 !~ /^[[:space:]]*[^[:space:]]+[[:space:]]*$/) {
|
||||
invalid = 1
|
||||
}
|
||||
}
|
||||
END {
|
||||
exit !(non_empty_lines == 1 && invalid == 0)
|
||||
}
|
||||
' "${token_file}"
|
||||
}
|
||||
|
||||
ensure_bgfilter_internal_token_file() {
|
||||
local token_file="/etc/genarrative/secrets/bgfilter-worker.token"
|
||||
local token_dir="/etc/genarrative/secrets"
|
||||
@@ -840,9 +856,9 @@ ensure_bgfilter_internal_token_file() {
|
||||
echo "[server-provision] 生成 BgFilter 内部 Token 失败。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '[^[:space:]]' -- "${temporary_file}"; then
|
||||
if ! bgfilter_internal_token_file_is_single_segment "${temporary_file}"; then
|
||||
rm -f "${temporary_file}"
|
||||
echo "[server-provision] 生成的 BgFilter 内部 Token 不得为空或只包含空白字符。" >&2
|
||||
echo "[server-provision] 生成的 BgFilter 内部 Token 必须为不含空白字符的单段值。" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown root:genarrative "${temporary_file}"
|
||||
@@ -850,8 +866,8 @@ ensure_bgfilter_internal_token_file() {
|
||||
mv -T "${temporary_file}" "${token_file}"
|
||||
echo "[server-provision] 已生成 BgFilter 内部 Token 文件: ${token_file}"
|
||||
else
|
||||
if ! grep -q '[^[:space:]]' -- "${token_file}"; then
|
||||
echo "[server-provision] BgFilter 内部 Token 文件不得为空或只包含空白字符: ${token_file}" >&2
|
||||
if ! bgfilter_internal_token_file_is_single_segment "${token_file}"; then
|
||||
echo "[server-provision] BgFilter 内部 Token 文件必须为不含空白字符的单段值: ${token_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown root:genarrative "${token_file}"
|
||||
|
||||
@@ -223,14 +223,10 @@ struct BgfilterWorkerRuntime {
|
||||
|
||||
impl BgfilterWorkerRuntime {
|
||||
fn new(app_state: AppState, task_tracker: BgfilterTaskTracker) -> Result<Self, String> {
|
||||
let token = app_state
|
||||
.config
|
||||
.bgfilter_internal_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| "GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 未配置".to_string())?;
|
||||
let token =
|
||||
validate_bgfilter_internal_token(app_state.config.bgfilter_internal_token.as_deref())?
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| "GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 未配置".to_string())?;
|
||||
if app_state.oss_client().is_none() {
|
||||
return Err("bgfilter-worker 需要完整的 ALIYUN_OSS_* 读取配置".to_string());
|
||||
}
|
||||
@@ -264,6 +260,20 @@ impl BgfilterWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_bgfilter_internal_token(
|
||||
configured: Option<&str>,
|
||||
) -> Result<Option<&str>, String> {
|
||||
let Some(token) = configured.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if token.chars().any(char::is_whitespace) {
|
||||
return Err(
|
||||
"GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 必须为不含空白字符的单段 Token".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BgfilterTaskTracker {
|
||||
inner: Arc<BgfilterTaskTrackerInner>,
|
||||
@@ -2048,12 +2058,8 @@ pub(crate) async fn request_bgfilter_worker(
|
||||
));
|
||||
}
|
||||
let call_budget_ms = state.config.bgfilter_call_budget_ms();
|
||||
let token = state
|
||||
.config
|
||||
.bgfilter_internal_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
let token = validate_bgfilter_internal_token(state.config.bgfilter_internal_token.as_deref())
|
||||
.map_err(|message| BgfilterClientError::local("unauthorized", message))?
|
||||
.ok_or_else(|| {
|
||||
BgfilterClientError::local("unauthorized", "BgFilter 内部调用 Token 未配置")
|
||||
})?;
|
||||
@@ -2391,6 +2397,35 @@ mod tests {
|
||||
.expect("request should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_token_allows_outer_whitespace_but_rejects_multiple_segments() {
|
||||
assert_eq!(
|
||||
validate_bgfilter_internal_token(Some(" shared-token\r\n"))
|
||||
.expect("outer whitespace should normalize"),
|
||||
Some("shared-token")
|
||||
);
|
||||
assert_eq!(
|
||||
validate_bgfilter_internal_token(None).expect("missing token remains optional"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
validate_bgfilter_internal_token(Some(" \r\n\t "))
|
||||
.expect("blank token normalizes to missing"),
|
||||
None
|
||||
);
|
||||
for value in [
|
||||
"shared token",
|
||||
"shared\ttoken",
|
||||
"shared\ntoken",
|
||||
"shared\r\ntoken",
|
||||
] {
|
||||
assert!(
|
||||
validate_bgfilter_internal_token(Some(value)).is_err(),
|
||||
"internal whitespace must be rejected: {value:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_token_comparison_checks_content_and_length() {
|
||||
assert!(constant_time_eq(b"shared-token", b"shared-token"));
|
||||
|
||||
@@ -93,7 +93,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
app::{build_router, build_spacetime_unavailable_router},
|
||||
bgfilter_worker::build_bgfilter_worker_router,
|
||||
bgfilter_worker::{build_bgfilter_worker_router, validate_bgfilter_internal_token},
|
||||
config::{AppConfig, ProcessRole},
|
||||
external_generation_worker::run_external_generation_worker,
|
||||
external_generation_worker_controller::run_external_generation_worker_controller,
|
||||
@@ -143,6 +143,7 @@ fn main() -> Result<(), io::Error> {
|
||||
}
|
||||
|
||||
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
validate_bgfilter_internal_token_for_startup(&config).map_err(io::Error::other)?;
|
||||
init_tracing(
|
||||
&config.log_filter,
|
||||
OtelConfig {
|
||||
@@ -163,6 +164,23 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
run_http_role(config).await
|
||||
}
|
||||
|
||||
fn validate_bgfilter_internal_token_for_startup(config: &AppConfig) -> Result<(), String> {
|
||||
if should_validate_bgfilter_internal_token_for_startup(config.process_role) {
|
||||
validate_bgfilter_internal_token(config.bgfilter_internal_token.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_validate_bgfilter_internal_token_for_startup(process_role: ProcessRole) -> bool {
|
||||
matches!(
|
||||
process_role,
|
||||
ProcessRole::Api
|
||||
| ProcessRole::BgfilterWorker
|
||||
| ProcessRole::ExternalGenerationWorker
|
||||
| ProcessRole::All
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_bgfilter_worker_role(mut config: AppConfig) -> Result<(), io::Error> {
|
||||
let (concurrency, single_image_estimate_ms, max_requests) =
|
||||
required_bgfilter_worker_capacity_from_env()?;
|
||||
@@ -683,9 +701,10 @@ mod tests {
|
||||
parse_required_bgfilter_worker_capacity, protected_env_keys_from,
|
||||
should_initialize_editor_generation_pricing_for_startup,
|
||||
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
|
||||
strip_env_value,
|
||||
should_validate_bgfilter_internal_token_for_startup, strip_env_value,
|
||||
validate_bgfilter_internal_token_for_startup,
|
||||
};
|
||||
use crate::config::ProcessRole;
|
||||
use crate::config::{AppConfig, ProcessRole};
|
||||
|
||||
#[test]
|
||||
fn strip_env_value_removes_wrapping_quotes() {
|
||||
@@ -763,6 +782,32 @@ mod tests {
|
||||
assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgfilter_internal_token_startup_validation_is_limited_to_consumers() {
|
||||
for role in [
|
||||
ProcessRole::Api,
|
||||
ProcessRole::BgfilterWorker,
|
||||
ProcessRole::ExternalGenerationWorker,
|
||||
ProcessRole::All,
|
||||
] {
|
||||
assert!(should_validate_bgfilter_internal_token_for_startup(role));
|
||||
let mut config = AppConfig::default();
|
||||
config.process_role = role;
|
||||
config.bgfilter_internal_token = Some("invalid token".to_string());
|
||||
assert!(validate_bgfilter_internal_token_for_startup(&config).is_err());
|
||||
}
|
||||
assert!(!should_validate_bgfilter_internal_token_for_startup(
|
||||
ProcessRole::ExternalGenerationController
|
||||
));
|
||||
let mut controller_config = AppConfig::default();
|
||||
controller_config.process_role = ProcessRole::ExternalGenerationController;
|
||||
controller_config.bgfilter_internal_token = Some("unused invalid token".to_string());
|
||||
assert!(validate_bgfilter_internal_token_for_startup(&controller_config).is_ok());
|
||||
|
||||
let missing_config = AppConfig::default();
|
||||
assert!(validate_bgfilter_internal_token_for_startup(&missing_config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_store_startup_restore_is_limited_to_http_roles() {
|
||||
assert!(should_restore_auth_store_for_startup(ProcessRole::Api));
|
||||
|
||||
Reference in New Issue
Block a user