{"record":{"id":"3b1be5ec89d8afc2","repo":"zeroclaw-labs/zeroclaw","slug":"attachment-path-canonicalizes-to-which-escap","errorCode":null,"errorMessage":"attachment path {} canonicalizes to {} which escapes workspace {}","messagePattern":"attachment path (.+?) canonicalizes to (.+?) which escapes workspace (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/wechat.rs","lineNumber":1058,"sourceCode":"        let workspace_canon = std::fs::canonicalize(workspace_dir).with_context(|| {\n            format!(\n                \"workspace_dir {} could not be canonicalized\",\n                workspace_dir.display()\n            )\n        })?;\n        if !candidate_canon.starts_with(&workspace_canon) {\n            ::zeroclaw_log::record!(\n                WARN,\n                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)\n                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),\n                &format!(\n                    \"attachment path {} canonicalizes to {} which escapes workspace {}\",\n                    raw_target,\n                    candidate_canon.display(),\n                    workspace_canon.display(),\n                )\n            );\n            anyhow::bail!(\n                \"attachment path {} canonicalizes to {} which escapes workspace {}\",\n                raw_target,\n                candidate_canon.display(),\n                workspace_canon.display(),\n            );\n        }\n        Ok(candidate_canon)\n    }\n\n    fn resolve_local_attachment_path(&self, target: &str) -> anyhow::Result<PathBuf> {\n        let workspace_dir = self.workspace_dir.as_deref().ok_or_else(|| {\n            ::zeroclaw_log::record!(\n                WARN,\n                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)\n                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),\n                \"workspace directory is not configured; cannot resolve local attachment path\"\n            );\n            anyhow::Error::msg(","sourceCodeStart":1040,"sourceCodeEnd":1076,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/wechat.rs#L1040-L1076","documentation":"`canonicalize_within_workspace` is the second (symlink-aware) stage of the WeChat attachment sandbox. A candidate path already passed the lexical check, but `std::fs::canonicalize` resolved a symlink component so the real path no longer starts with the canonicalized `workspace_dir`. The bail is a deliberate security control: WeChat outgoing attachments may only read files physically inside the configured workspace, preventing symlink-based sandbox escape.","triggerScenarios":"Sending a WeChat message with an attachment whose target is a path inside `workspace_dir` that is itself a symlink pointing outside (e.g. `/workspace/docs` -> `/etc`), or whose path contains a symlinked directory component leading outside the workspace root. Also fires when `workspace_dir` is reconfigured between the lexical check and canonicalization so the two roots disagree. Triggered via `send` with a local file attachment that passes `resolve_local_attachment_path` lexically but fails the `candidate_canon.starts_with(&workspace_canon)` check.","commonSituations":"Users symlink shared asset directories (media, fonts, model files) into the workspace from elsewhere on disk; CI setups where the workspace is a symlinked artifact directory whose target sits outside; hardening tests that specifically probe symlink escapes; container mounts where `/workspace` is a bind mount and attachments point through `/mnt/data`.","solutions":["Make the real file physically reside under `workspace_dir` (copy or move it there) instead of symlinking from outside.","Repoint the symlink target to a location inside the workspace, or make `workspace_dir` the common ancestor that contains both the symlink and its target.","Send such files by absolute path of the real file only if that real path is itself inside the workspace; otherwise serve the file over HTTPS and pass the URL as the attachment target.","If the escape is intentional in your deployment, widen `workspace_dir` in the WeChat channel configuration so the canonicalized target is within it — do not try to bypass the check."],"exampleFix":"# before: symlink escapes the sandbox\nln -s /srv/shared/big-video.mp4 /workspace/files/big-video.mp4\n# send attachment target=\"files/big-video.mp4\" -> error 331\n\n# after: real file inside the workspace\ncp /srv/shared/big-video.mp4 /workspace/files/big-video.mp4\n# send attachment target=\"files/big-video.mp4\" -> ok","handlingStrategy":"validation","validationCode":"// verify the resolved real path stays inside the workspace BEFORE sending\nfn is_within(real: &std::path::Path, workspace: &std::path::Path) -> bool {\n    let Ok(real) = real.canonicalize() else { return true }; // nonexistent targets skip the check too\n    let Ok(ws) = workspace.canonicalize() else { return false };\n    real.starts_with(ws)\n}\n\nlet target = workspace.join(\"files/big-video.mp4\");\nassert!(is_within(&target, workspace), \"symlink escape; copy the file instead\");","typeGuard":"fn is_safe_attachment_path(resolved: &std::path::Path, workspace: &std::path::Path) -> bool {\n    match (resolved.canonicalize(), workspace.canonicalize()) {\n        (Ok(r), Ok(w)) => r.starts_with(w),\n        (Err(_), _) => true, // not yet on disk: only the lexical rule applies\n        _ => false,\n    }\n}","tryCatchPattern":"match channel.send(msg_with_attachment(target)).await {\n    Err(err) if err.to_string().contains(\"which escapes workspace\") => {\n        // copy the real file into the workspace and retry once\n        let inside = copy_into_workspace(&real_source)?;\n        channel.send(msg_with_attachment(inside)).await?;\n    }\n    other => other?,\n}","preventionTips":["Never send attachments through symlinks that cross the workspace boundary; materialize real files inside workspace_dir.","In deployment docs, declare workspace_dir as the single attachment root and forbid symlinked asset dirs.","Add a harness test that creates an escaping symlink and asserts the send fails closed (the repo already has such tests for resolve_local_attachment_path)."],"tags":["security","path-traversal","symlink","filesystem","wechat","attachment","sandbox"],"backgroundTag":"path-traversal","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}