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, no null bytes, no control characters other than tab/newline/CR. Every caller that checks a caller-supplied variable value (dw/variables.py,dw/run.py, the REPL) passesMAX_VARIABLE_VALUE_LENGTH, 20,000 characters; file names and paths pass their own, shorter caps, so the function's bare default of 1000 is not the limit anything is held to. A variable's default, written in the definition, is not capped separately: the author controls the file, and the whole file is capped at 50MBvalidate_json_size()— Limits JSON files to 50MBvalidate_url()— Scheme must behttporhttps; must have a non-empty domain (netloc); may not contain a backslash.urllib.parseand the HTTP client disagree on which hosthttp://169.254.169.254\@example.com/names, so the host the check approved need not be the one dialed; a\that belongs in a path is written%5Cvalidate_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.
The package is not the whole check, because an allowed package holds
things other than classes and re-exports modules outside itself. Untrusted,
two more rules apply (dw/type_helpers.py):
- A type reference must resolve to a class. A
*_type/config_typevalue is constructed with the workflow's own arguments, so"torch.hub.load"- intorch, and a function that fetches and runs a GitHub repo's code - is refused as "not a class", as is a module or any other object. Under adtypeor*_dtypekey atorch.dtype("torch.bfloat16") is accepted too, since that is data rather than something called. A bare name ("FluxPipeline") resolves againstdiffusersand is held to the same rule.validate_workflowreports the refusal at the key's path, for every key the run loads as a type (from_pretrained_arguments.torch_dtypeas much asconfig_type). - A
constant:walk stays inside the package. A dottedconstant:reference is gated like a type (the module it names is imported beforefetch_constantgets to refuse a callable, so the import itself is what the gate has to stop), and then every step of the walk is checked: no segment may start with_, checked before anything imports, and no module the walk passes through may sit outside the allowed packages -constant:torch.os.environstarts intorchand ends in the server's environment, and is refused attorch.os. Reading a field off a value declared in an allowed module still works (...ltx2.utils.GEMMA4_PROMPT_ENHANCEMENT_CONFIG.max_new_tokens). A bare name (constant:SOME_NAME) reads fromdiffusers.validate_workflowresolves every literalconstant:in a step the way the run does and reports a refusal at its path (a variable's default atvariables.<name>), so nothing is queued to find out.
Every *_type and constant: in the bundled catalog satisfies both rules,
pinned by tests/test_workflow_trust.py's catalog sweep. --trust-workflows
lifts both, as it lifts the package check.
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, and anything else that is not globally routable (is_global). Checked after DNS resolution, not on the literal string. That last rule covers100.64.0.0/10, the shared address space, which is Tailscale's tailnet range (and Alibaba's metadata address): a workflow that fetches media from another machine on your tailnet is refused unless it runs under--trust-workflows. - Every redirect is re-checked. A media fetch (
safe_get) never lets the HTTP client follow a redirect on its own: it follows at most 5 hops (MAX_MEDIA_REDIRECTS), and eachLocationpasses the same scheme and host policy before it is dialed. A public URL answering302tohttp://127.0.0.1:8765/api/serveris refused with the target named, and nothing is fetched from it. Images are decoded from the fetched bytes and still go through diffusers'load_image, so EXIF orientation and RGB conversion are unchanged. 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
- Decompression bombs — an image a caller names is decoded at no more than
MAX_DECODE_PIXELS(50M; an 8K frame is 33M), checked afterImage.openand before any decode:get_output_image(crops included) refuses it, the gallery thumbnail answers 413, and embedded metadata is read from the PNG header chunks without decoding. Video and audio decode are not limited - 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) - Script on the UI's origin through an output — a step's
result.content_typemay not betext/htmlortext/xml(compared without case or parameters): validation refuses it atsteps[i].result.content_typeand the writer refuses it again (dw/content_types.py). A file of an active type that reaches/outputsor/inputsanyway is served withContent-Security-Policy: sandbox
pytest tests/test_security.py tests/test_locations.py tests/test_workflow_trust.py -v