{"record":{"id":"e9a7f6834a228e9c","repo":"windmill-labs/windmill","slug":"path-traverses-a-symlink-which-is-not-allowed","errorCode":null,"errorMessage":"Path traverses a symlink, which is not allowed.","messagePattern":"Path traverses a symlink, which is not allowed\\.","errorType":"exception","errorClass":"std::io::Error (PermissionDenied)","httpStatus":null,"severity":"error","filePath":"backend/windmill-common/src/worker.rs","lineNumber":927,"sourceCode":"    // it out of the job dir while still passing the textual `starts_with` check.\n    // Walk the *normalized* relative path (`..`/`.` already collapsed) so each\n    // step matches the real on-disk resolution, and reject any existing component\n    // that is a symlink. Walking the raw user path would drift on an in-bounds\n    // `..` (e.g. `foo/../link`, which normalizes back inside the job dir) and miss\n    // the real symlinked component. Not-yet-existing components are safe: a path\n    // that does not exist cannot itself be a symlink.\n    let relative = normalized_full_path\n        .strip_prefix(&normalized_job_dir)\n        .unwrap_or(&normalized_full_path);\n    let mut current = normalized_job_dir.clone();\n    for component in relative.components() {\n        if let Component::Normal(c) = component {\n            current.push(c);\n            if std::fs::symlink_metadata(&current)\n                .map(|m| m.file_type().is_symlink())\n                .unwrap_or(false)\n            {\n                return Err(std::io::Error::new(\n                    std::io::ErrorKind::PermissionDenied,\n                    \"Path traverses a symlink, which is not allowed.\",\n                )\n                .into());\n            }\n        }\n    }\n\n    Ok(normalized_full_path)\n}\n\npub fn write_file_at_user_defined_location(\n    job_dir: &str,\n    user_defined_path: &str,\n    content: &str,\n    mode: Option<u32>,\n) -> error::Result<PathBuf> {\n    let normalized_full_path = is_allowed_file_location(job_dir, user_defined_path)?;","sourceCodeStart":909,"sourceCodeEnd":945,"githubUrl":"https://github.com/windmill-labs/windmill/blob/e474e8803ce2ff5c2df09a58dab51d45f5c922ca/backend/windmill-common/src/worker.rs#L909-L945","documentation":"During the same job-directory path validation, `is_allowed_file_location` walks each path component and checks `symlink_metadata` at every step. If any intermediate component is a symlink, the path could resolve outside the sandbox, so it is rejected. This closes the symlink-following variant of path traversal.","triggerScenarios":"A relative path whose intermediate directory (or final component checked during the walk) is a symlink — e.g. a script creating `link -> /etc` then writing `link/passwd` — passed to write_file_at_user_defined_location or the git clone helpers.","commonSituations":"Job directories that contain symlinks created by earlier steps or by the runtime image (e.g. `/tmp` symlinks, node_modules symlinks); reusing pre-created symlinked directories inside the job dir.","solutions":["Remove/avoid symlinked intermediate directories inside the job directory; use real directories.","Target a path with only regular directories and files.","If a symlink is intentional, resolve the real path first and use one that stays inside the job dir without traversal.","Check the job image/base setup for symlinks pre-created in the working directory."],"exampleFix":"// before\nwrite_file_at_user_defined_location(job_dir, \"link_to_out/result.txt\", data).await?;\n// after\nstd::fs::remove_file(\"link_to_out\"); // or use a real directory\nwrite_file_at_user_defined_location(job_dir, \"out/result.txt\", data).await?;","handlingStrategy":"validation","validationCode":"fn has_symlink_components(job_dir: &Path, rel: &str) -> bool {\n    let mut current = job_dir.to_path_buf();\n    for c in rel.split('/') {\n        current.push(c);\n        if std::fs::symlink_metadata(&current).map(|m| m.file_type().is_symlink()).unwrap_or(false) {\n            return true;\n        }\n    }\n    false\n}\nif has_symlink_components(&job_dir, user_path) { return Err(\"path must not traverse symlinks\"); }","typeGuard":"fn path_is_symlink_free(job_dir: &Path, rel: &str) -> bool { !has_symlink_components(job_dir, rel) }","tryCatchPattern":"match write_file_at_user_defined_location(&job_dir, &user_path, data).await {\n    Err(e) if e.to_string().contains(\"symlink\") => {\n        eprintln!(\"{user_path:?} traverses a symlink; use a real directory inside the job dir\");\n    }\n    other => other?,\n}","preventionTips":["Don't create symlinks inside job working directories","Use real directories for outputs consumed by later steps","Audit base images for pre-existing symlinks in the working directory","Resolve intended targets up front with canonicalize and verify containment"],"tags":["symlink","path-traversal","security","filesystem"],"backgroundTag":"symlink-traversal-blocked","analyzedSha":"e474e8803ce2ff5c2df09a58dab51d45f5c922ca","analyzedAt":"2026-09-03T12:38:19.024Z","contentChangedAt":"2026-09-03T12:38:19.024Z","schemaVersion":2},"datasetVersion":"2026-09-08T15:18:49.778Z"}