Harden auth/vault path sanitization and clean WeKnora docs

This commit is contained in:
Agent Board
2026-07-28 17:04:27 +08:00
parent 2deaf59f7b
commit 26ff1a9c9a
190 changed files with 13454 additions and 4987 deletions
@@ -83,12 +83,25 @@ pub async fn block_edit_workflow(
)
.with_context(&context));
}
let actor_id =
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
state.config().dev_user_id.clone()
// Fail-closed: unauthenticated actors cannot write via the page-ai workflow.
// Dev fixtures may use dev_user_id only when explicitly enabled.
let actor_id = {
let raw = context.auth.actor_id.trim();
if raw.is_empty() || raw == "anonymous" {
if state.config().allow_dev_fixtures {
state.config().dev_user_id.clone()
} else {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"page_ai_workflow_auth_required",
"页面 AI 工作流需要先登录",
)
.with_context(&context));
}
} else {
context.auth.actor_id.clone()
};
}
};
let allowed_target_block_ids = ai_context
.get("allowedTargetBlockIds")
.and_then(Value::as_array)
@@ -103,7 +116,17 @@ pub async fn block_edit_workflow(
})
.unwrap_or_default();
let mut markdown_args = json!({
"operations": markdown_operations.clone()
"operations": markdown_operations.clone(),
// 写入守卫 fail-closed:快路径也必须显式授予写权限。
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": [document_id.clone()],
"allowedTargetBlockIds": allowed_target_block_ids.clone(),
},
"commandContext": {
"ai.canWrite": true,
"workspace.readonly": false
}
});
if !allowed_target_block_ids.is_empty() {
if let Value::Object(map) = &mut markdown_args {
@@ -172,13 +195,29 @@ struct MarkdownEditPlan {
summary: Option<String>,
}
/// 模型输出可能把 JSON 再包一层 `choices[0].message.content` 字符串;限制解包深度防栈溢出。
const MODEL_JSON_UNWRAP_MAX_DEPTH: u8 = 4;
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
extract_markdown_plan_from_model_text_depth(text, 0)
}
fn extract_markdown_plan_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<MarkdownEditPlan, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_markdown_plan_from_model_text(content);
return extract_markdown_plan_from_model_text_depth(content, depth + 1);
}
let summary = parsed
.get("summary")
@@ -237,12 +276,26 @@ fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan,
#[allow(dead_code)]
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
extract_operations_from_model_text_depth(text, 0)
}
#[allow(dead_code)]
fn extract_operations_from_model_text_depth(
text: &str,
depth: u8,
) -> Result<Vec<Value>, WebError> {
if depth > MODEL_JSON_UNWRAP_MAX_DEPTH {
return Err(WebError::bad_request_code(
"page_ai_workflow_model_output_too_nested",
"模型输出嵌套过深,拒绝解析",
));
}
let parsed = parse_model_json(text)?;
if let Some(content) = parsed
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
{
return extract_operations_from_model_text(content);
return extract_operations_from_model_text_depth(content, depth + 1);
}
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
return Ok(operations.clone());
@@ -385,7 +438,13 @@ async fn call_block_edit_model(
.with_context(context)
})?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_workflow_model_body_read_failed",
format!("页面 AI workflow 模型响应体读取失败: {error}"),
)
.with_context(context)
})?;
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_workflow_model_failed",
@@ -545,14 +604,50 @@ fn agent_profile_home() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
/// profile 名仅允许安全文件名字符,拒绝 `..` / 路径分隔,防止 profiles join 穿越。
fn sanitize_workflow_profile_name(profile: &str) -> String {
let trimmed = profile.trim();
if trimmed.is_empty() {
return "default".to_string();
}
let mut out = String::with_capacity(trimmed.len().min(64));
for ch in trimmed.chars().take(64) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() || out == "." || out == ".." || out.contains("..") {
return "default".to_string();
}
out
}
fn profile_config_path(profile: &str) -> PathBuf {
let home = agent_profile_home();
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
}
let candidate = home.join("profiles").join(profile);
if candidate.exists() {
let safe = sanitize_workflow_profile_name(profile);
if safe == "default" {
return home.join("config.yaml");
}
let profiles_root = home.join("profiles");
let candidate = profiles_root.join(&safe);
// 防御:消毒后仍须落在 profiles 目录内(不解析 symlink,仅词法检查)。
if candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return home.join("config.yaml");
}
// 禁止跟随 profiles 下指向外部的 symlinkexists/is_dir 会跟随)。
if candidate.is_symlink() {
return home.join("config.yaml");
}
if candidate.is_dir() {
candidate.join("config.yaml")
} else {
home.join("config.yaml")
@@ -673,6 +768,7 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
environment: "dev".into(),
}))
}
@@ -978,4 +1074,56 @@ mod tests {
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[test]
fn profile_config_path_rejects_traversal() {
use super::{profile_config_path, sanitize_workflow_profile_name};
use std::path::Component;
assert_eq!(sanitize_workflow_profile_name("../../etc"), "default");
assert_eq!(sanitize_workflow_profile_name("mnoteai"), "mnoteai");
assert_eq!(sanitize_workflow_profile_name("a/b"), "a_b");
let path = profile_config_path("../../etc/passwd");
assert!(
path.components().all(|c| !matches!(c, Component::ParentDir)),
"profile path must not contain ParentDir: {path:?}"
);
// 危险 profile 回落到 home/config.yaml,不得 join 原始 ../../
assert!(
path.ends_with("config.yaml"),
"expected config.yaml fallback, got {path:?}"
);
let path2 = profile_config_path("mnoteai");
// may or may not exist; must be under profiles/mnoteai or home config
assert!(
path2.components().all(|c| !matches!(c, Component::ParentDir))
);
}
#[test]
fn extract_markdown_plan_rejects_deeply_nested_content() {
use super::extract_markdown_plan_from_model_text;
// 5 层 choices.content 嵌套 → 超过 MODEL_JSON_UNWRAP_MAX_DEPTH(4)
let mut nested = r#"{"operations":[{"search":"a","replace":"b"}]}"#.to_string();
for _ in 0..5 {
nested = format!(
r#"{{"choices":[{{"message":{{"content":{}}}}}]}}"#,
serde_json::to_string(&nested).expect("escape")
);
}
let err = match extract_markdown_plan_from_model_text(&nested) {
Ok(_) => panic!("must reject deeply nested model JSON"),
Err(e) => e,
};
let msg = err.message();
assert!(
msg.contains("嵌套")
|| msg.contains("too_nested")
|| format!("{err:?}").contains("too_nested")
|| format!("{err:?}").contains("嵌套"),
"unexpected err: {err:?}"
);
}
}