diffusers-workflow validates all file paths, user inputs, and URLs to protect against path traversal, injection, and resource exhaustion. A command-argument sanitizer (sanitize_command_args()) is available for any future subprocess use, though dw/ currently invokes no subprocess/shell commands.
validate_path()— Blocks../(anywhere in the path),~/(or~\), and paths rooted at/dev/,/proc/,/sys/. Rejects null bytes and overlong paths (> 4096 chars). Resolves to an absolute, realpath'd path. Ifbase_diris given, raisesPathTraversalErrorwhen the resolved path falls outside it.validate_workflow_path()—validate_path()plus a required.jsonextension (viavalidate_file_extension())validate_output_path()—validate_path()withallow_create=True, for directories/files that don't need to exist yetvalidate_file_extension()— Checks a path's extension against an allowed set (used internally byvalidate_workflow_path()and byarguments.pyfor media files)
validate_variable_name()— Alphanumeric, underscore, hyphen only (pattern:^[a-zA-Z_][a-zA-Z0-9_-]*$), max 100 charsvalidate_string_input()— Max length (default 1000 chars), no null bytes, no control characters other than tab/newline/CRvalidate_json_size()— Limits JSON files to 50MBvalidate_url()— Scheme must behttporhttps; must have a non-empty domain (netloc)validate_constant_name()— Guardsconstant:references before import: dotted-name pattern only, module must already be importable, and anything callable is refusedsafe_join_path()— Joins path components after rejecting any that contain..,/, or\\. Defined insecurity.pybut not currently called elsewhere indw/.
sanitize_command_args()— Rejects arguments containing shell metacharacters (`$|&;><and newline/CR). It does not callshlex.quote()— withshell=False, argument list separation is handled safely by Python/the OS, so this function is a defense-in-depth check, not an escaping step.- As of this writing,
dw/does not invokesubprocessanywhere — the REPL's worker process (dw/repl_worker.py,dw/worker.py) is amultiprocessing.Processcommunicating overmultiprocessing.Queue, not a shelled-out command.sanitize_command_args()is exercised bytests/test_security.pybut is otherwise unused; it exists for any future code path that shells out.
A workflow JSON file can execute arbitrary Python. This is a deliberate
design choice, in the same spirit as ComfyUI custom nodes - the engine's
dynamic-import machinery is what lets a workflow name any diffusers
pipeline, scheduler, or quantization backend without dw shipping a bespoke
adapter for each one. But the same machinery means loading a workflow file
is not a passive data-load. Three things in a workflow JSON run
importlib.import_module() on a name the file supplies, which executes
that module's top-level code:
pre_load_modules(dw/pipeline_processors/pipeline.py) - a list of module names imported before the pipeline loads, for their import-time registration side effects (sdnqregistering its quantization method with diffusers, for instance)- A dotted
*_type/*_dtype/dtype/config_typevalue (dw/type_helpers.py, reached fromdw/arguments.pyanddw/pipeline_processors/config_objects.py) -"sdnq.SDNQConfig"importssdnqand readsSDNQConfigoff it; nothing stops the module part from naming something with no legitimate reason to appear in a workflow - A
constant:-prefixed reference (dw/type_helpers.py'sload_constant_from_name, reached fromdw/arguments.py) - imports the module the constant is declared in the same way, before reading the attribute off it.fetch_constantrefuses anything callable it finds, but the import itself has already run by that point
Treat an untrusted workflow file exactly like an untrusted Python
script. Don't run one from a source you would not run a .py file from -
a random download, a link in an issue, an LLM-authored file you have not
read.
dw-run, dw-serve, and dw-repl all take a --trust-workflows flag,
off by default. Untrusted (the default), pre_load_modules and any
dotted *_type/*_dtype/dtype/config_type value are refused unless
they resolve under a top-level package the tool already depends on for
exactly this purpose - the framework packages (diffusers, torch,
torchvision, transformers, accelerate, peft) and the quantization
backends pyproject.toml declares for config_objects.py's dynamic
loading (sdnq, torchao, optimum for optimum-quanto, gguf,
bitsandbytes), plus dw itself (a workflow's component_type can name a
pipeline under dw.community_pipelines, which ships in this repo, not a
third party one). The refusal names exactly what triggered it and points
back at --trust-workflows. The bundled examples under workflows/ all
stay inside this set and load untrusted; a
workflow that needs to reach outside it - a community pipeline module from
somewhere else, a custom scheduler package - needs --trust-workflows.
A dotted constant: reference is gated the same way
(dw/type_helpers.load_constant_from_name): the module it names is
imported before fetch_constant gets to refuse a callable, so the import
itself is what the gate has to stop. A bare name (constant:SOME_NAME)
reads from diffusers and is always allowed.
Two from_pretrained_arguments keys are refused untrusted as well, for
every component and pipeline: trust_remote_code (runs the model repo's
own modeling code) and custom_pipeline (fetches and imports a pipeline
module from the Hub or a local path). Neither goes through an
importlib call of ours, so the dotted-name gate alone would leave
diffusers' remote-code paths open. No bundled catalog entry sets either
(tests/test_catalog_structure.py refuses one that does); a workflow that
needs them needs --trust-workflows.
Code execution is not the only thing a workflow file chooses. Its arguments
choose locations - which image to open, which URL to fetch, which
directory a glob expands over - and until 2026-09-13 each loader trusted the
one it was handed. A workflow could name /usr/share/pixmaps/debian-logo.png
as its image and get it decoded, glob /usr/share/pixmaps/*.png and get
every match republished verbatim as an output, or point an image at
http://127.0.0.1:8765 and have the server fetch its own loopback.
remote_text_encoder.url was the worst of them: the request carries this
machine's HuggingFace token.
One policy now answers all of it, untrusted:
- A path must resolve inside a root this installation already works in -
the workflow file's own directory, the asset libraries on the search path,
the output root.
validate_pathalready refuses.., so in practice this closes the absolute path that pointed somewhere else entirely; a relative one that climbs out is refused on its..segments, at validation time as well as at the loader, so both spellings are answered at the same moment rather than one of them three seconds into a queued job (#124). The remedy for a file outside is to put it in the asset library and use anasset:reference. Containment is checked before existence, so the refusal cannot be used as a file-existence oracle. - A glob is contained the same way, on the fixed directory its pattern starts from, and every match is re-checked on its real path so a symlink cannot carry the expansion out.
- An
http(s)URL must not resolve to an address inside the deployment - loopback, link-local (169.254.0.0/16, the cloud metadata address), private ranges. Checked after DNS resolution, not on the literal string. remote_text_encoder.urlis https-only, and the HuggingFace token is attached only forhuggingface.co,huggingface.cloudandhf.space. An endpoint elsewhere is still reachable; it just does not get the credential.model_namemust be a Hub repo id or a path inside a root - the same shape checkdownload_modelhas always applied torepo_id. A URL is neither, and is refused as such rather than resolving into the workflow's own directory as a path-shaped name (#117).
Enforced twice: location_errors runs inside validation_errors, so
validate_workflow refuses before a model load is spent on the run, and the
loaders call the same functions for a location that arrives through a
variable or a previous result. All of it yields to --trust-workflows.
GET /api/server reports trust_workflows, so a client can read the posture
it is running against rather than infer it.
--trust-workflows is a blanket, process-wide choice - it is not scoped
per-workflow or per-request. A dw-serve instance that accepts jobs from
anything other than yourself (including an MCP client - see below) should
be run without it.
The MCP server's save_workflow and run_workflow tools let an LLM write
and then execute a workflow through dw.serve - save_workflow writes a
JSON file into the workflow directory, run_workflow queues it (or an
inline definition) as a job. Nothing in dw_mcp/ inspects what a workflow
it saves or runs actually contains. What protects a server used this way
is exactly the mechanism above: dw-serve's own --trust-workflows
default is untrusted, so an MCP-authored or MCP-submitted workflow gets
the same code-execution gate a workflow from any other untrusted source
does, with no code change needed in dw_mcp itself. Running dw-serve --trust-workflows removes that gate for every job the server accepts,
MCP-submitted or not - see the blanket-choice note just above.
| Entry Point | What's Validated |
|---|---|
workflow.py |
Workflow file paths, JSON size, output directories, sub-workflow paths |
run.py, validate.py |
CLI arguments, variable names and values |
repl.py, repl_commands.py |
Interactive command arguments — paths, workflow paths, output paths, variable names/values |
arguments.py |
Image/video/audio URLs, file paths, file extensions (validate_media_location, fetch_image, fetch_video) |
tasks/gather.py |
URLs passed to the gather task |
result.py |
Output directories and filenames |
server/app.py, server/jobs.py |
Every HTTP-supplied path — workflow files confined to the workflow directory, gallery files to the output directory, inline-workflow base_dir, Origin-header guard on every request |
dw_mcp/ introduces no new file access and no authentication of its own. It
is an HTTP client of a running dw.serve: every path a tool touches (a
workflow name, a gallery file, a job id) is sent to the REST API as-is and
validated there, exactly as it would be for a browser request from the web
UI. A remote dw.serve is allowed only with a token — see
MCP Server and REMOTE.md.
The two exceptions are download_output, which writes a local file for the
MCP client, and upload_asset(file_path=...), which reads one — neither goes
through the API for that half of its work. Both turn on whose machine "local"
is. Over a stdio dw-mcp it is the user's own, so both act for the local
user the way a shell redirect would: download_output writes anywhere the
process may (a full path, a directory, or the working directory by default,
~ expanded) and upload_asset reads anything it may. Over dw.serve --mcp it is the operator's box, which the caller never chose, so both are
confined there: download_output's destination to the workspace (#113) and
upload_asset's file_path to the directories the server works in — its
workspace, workflows, assets, outputs and prompts (#138). upload_asset's
refusal is ordered ahead of the existence and extension checks so it cannot
be used as a path-existence oracle. A .. path segment in destination is
refused regardless, and an existing file at the resolved path is left alone
unless the caller passes overwrite=True.
SecurityError
PathTraversalError — path traversal attempt
InvalidInputError — input validation failure
- Always validate paths before file operations
- Use
validate_url()before loading remote resources - If a subprocess is ever introduced, use
shell=Falseand pass args throughsanitize_command_args() - Never use dynamic code execution (
eval/exec) or shell interpretation
- Path traversal — Cannot access files outside allowed directories
- Command injection — No shell interpretation is used anywhere in
dw/;sanitize_command_args()is available as a guard should a subprocess call be added - Resource exhaustion — File size limits prevent memory exhaustion
- Malicious URLs — Only http/https schemes allowed, and an untrusted workflow may not name a host inside the deployment (SSRF)
- Arbitrary file read through a media argument — a location a workflow
supplies is confined to the roots it may read (
dw/locations.py)
pytest tests/test_security.py tests/test_locations.py tests/test_workflow_trust.py -v