Harden auth/vault path sanitization and clean WeKnora docs
This commit is contained in:
@@ -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 下指向外部的 symlink(exists/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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user