windmill-labs/windmill · error · std::io::Error (PermissionDenied)
Path traverses a symlink, which is not allowed.
Error message
Path traverses a symlink, which is not allowed.
What it means
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.
Source
Thrown at backend/windmill-common/src/worker.rs:927
// it out of the job dir while still passing the textual `starts_with` check.
// Walk the *normalized* relative path (`..`/`.` already collapsed) so each
// step matches the real on-disk resolution, and reject any existing component
// that is a symlink. Walking the raw user path would drift on an in-bounds
// `..` (e.g. `foo/../link`, which normalizes back inside the job dir) and miss
// the real symlinked component. Not-yet-existing components are safe: a path
// that does not exist cannot itself be a symlink.
let relative = normalized_full_path
.strip_prefix(&normalized_job_dir)
.unwrap_or(&normalized_full_path);
let mut current = normalized_job_dir.clone();
for component in relative.components() {
if let Component::Normal(c) = component {
current.push(c);
if std::fs::symlink_metadata(¤t)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Path traverses a symlink, which is not allowed.",
)
.into());
}
}
}
Ok(normalized_full_path)
}
pub fn write_file_at_user_defined_location(
job_dir: &str,
user_defined_path: &str,
content: &str,
mode: Option<u32>,
) -> error::Result<PathBuf> {
let normalized_full_path = is_allowed_file_location(job_dir, user_defined_path)?;View on GitHub (pinned to e474e8803c)
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.
Example fix
// before
write_file_at_user_defined_location(job_dir, "link_to_out/result.txt", data).await?;
// after
std::fs::remove_file("link_to_out"); // or use a real directory
write_file_at_user_defined_location(job_dir, "out/result.txt", data).await?; Defensive patterns
Strategy: validation
Validate before calling
fn has_symlink_components(job_dir: &Path, rel: &str) -> bool {
let mut current = job_dir.to_path_buf();
for c in rel.split('/') {
current.push(c);
if std::fs::symlink_metadata(¤t).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
return true;
}
}
false
}
if has_symlink_components(&job_dir, user_path) { return Err("path must not traverse symlinks"); } Type guard
fn path_is_symlink_free(job_dir: &Path, rel: &str) -> bool { !has_symlink_components(job_dir, rel) } Try / catch
match write_file_at_user_defined_location(&job_dir, &user_path, data).await {
Err(e) if e.to_string().contains("symlink") => {
eprintln!("{user_path:?} traverses a symlink; use a real directory inside the job dir");
}
other => other?,
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- Path is outside the allowed job directory.
- raw app path ${JSON.stringify(relPath)} escapes the app fold
- bundle produced no javascript:\n${buildOutput}
- File not found: ${filePath}
- Workspace folder not found, are you in the right directory?
AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03).
Data as JSON: /api/errors/e9a7f6834a228e9c.
Report an issue: GitHub.