diffusers-workflow validates file paths, names, user input and URLs against
path traversal, injection, server-side request forgery and resource
exhaustion. The validators are in dw/security.py, the trust gate for what a
workflow may import or execute is dw/trust.py, the policy for where a
workflow may read and fetch is dw/locations.py, and the HTTP layer is
dw/server/http_security.py. SECURITY_QUICKREF.md is
the one-page digest.
Every validator raises PathTraversalError or InvalidInputError (both
SecurityError) on refusal.
validate_path(path, base_dir=None, allow_create=True)- Refuses an empty path, a null byte, a path over 4096 characters or a file name over 255,..anywhere,~/(or~\), and a path rooted at/dev/,/proc/or/sys/. Returns the absolute, realpath'd path. Withbase_dir, the resolved path must equal it or lie under it (PathTraversalErrorotherwise); symlinks are resolved on both sides first. Withoutbase_dirit confines nothing, and CodeQL still reports the path.allow_create=Falsealso requires the path to exist.validate_workflow_path(path, workflow_dir=None),validate_prompt_path(path, prompt_dir),validate_lora_path(path, lora_dir)validate_pathwithallow_create=Falseplus a required.jsonextension.
validate_output_path(path, output_dir)-validate_pathwithallow_create=True, for files and directories that need not exist yet.validate_file_extension(path, allowed)- The extension against an allowed set (ALLOWED_*_EXTENSIONS).contained(path, root)- Whetherpath, symlinks resolved, lies insideroot, for directory walks that must not follow a link out.validate_weight_name(name, ...)- A file name inside a Hub repo: no leading/or~, no backslash or drive, no empty,.or..segment.
Each name is checked before it is joined onto a directory; containment is
then checked by the validate_path call that joins it. Every segment starts
with a letter, digit or underscore, which excludes .., hidden names and
absolute paths. Patterns end in \Z, so a trailing newline is refused.
| Validator | Shape | Max |
|---|---|---|
validate_variable_name |
^[a-zA-Z_][a-zA-Z0-9_-]*$ |
100 |
validate_prompt_reference, validate_lora_name |
a name, at most one folder deep | 200 |
validate_asset_reference |
a file name with its extension, at most four folders deep; @ allowed |
400 |
validate_output_reference |
<workflow>/<run>/<file>, 2 to 7 segments; @ allowed |
500 |
validate_output_prefix |
<workflow>/<run id> or <workflow> |
500 |
validate_subfolder |
relative segments under the run directory; no backslash | 200 |
validate_workspace_name |
one segment, not one of the workspace root's own folder names | 100 |
validate_constant_name |
a dotted Python name, identifiers and dots only | 200 |
validate_commit_hash |
7 to 40 hex characters (the diffusers updater's install pin) | 40 |
validate_file_base_name |
no / or \ |
- |
validate_content_type |
a MIME type with exactly one / |
- |
validate_string_input(value, max_length=1000)- Refuses an over-long value, a null byte, and any control character other than tab, newline and CR. A caller-supplied variable value (dw/variables.py,dw/run.py) is held toMAX_VARIABLE_VALUE_LENGTH, 20,000 characters. A variable's default is not capped separately; the whole workflow file is.validate_json_size(path)- A JSON file over 50MB is refused before it is read.validate_url(url)- Schemehttporhttps, a non-empty host, and no backslash anywhere:urllib.parseand the HTTP client disagree on which hosthttp://169.254.169.254\@example.com/names, so a\that belongs in a path is written%5C. It checks shape only; the host policy isdw/locations.py's.safe_join_path(*parts)- Joins components after refusing any containing..,/or\. Not called anywhere indw/.
sanitize_command_args(args)- Refuses an argument containing`,$,|,&,;,>,<, newline or CR. It does not quote: withshell=Falsethe OS separates arguments, so this is defense in depth.dw/runs three subprocesses, allshell=Falsewith fixed argument lists:pipin the diffusers updater (dw/server/updater.py, throughsanitize_command_args, its commit pinned byvalidate_commit_hash),sysctlfor the Apple chip name (dw/__init__.py) andnvidia-smifor the driver version (dw/server/sysinfo.py). The worker is amultiprocessing.Process, not a shelled-out command.
A workflow JSON file can execute arbitrary Python. The dynamic-import
machinery that lets a workflow name any diffusers pipeline, scheduler or
quantization backend also makes loading a workflow more than a data-load.
Three things in a workflow 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) - modules imported before the pipeline loads, for their registration side effects (sdnqregistering its quantization method).- 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. - A
constant:reference (dw/type_helpers.py'sload_constant_from_name) - imports the module the constant is declared in before reading the attribute.
Treat an untrusted workflow file exactly like an untrusted Python script.
Do not 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-serve and dw-validate take --trust-workflows, off by default.
dw-run is a client of dw-serve, so its jobs run under the server's
setting. The choice is process-wide, reaches the spawned worker through
DW_TRUST_WORKFLOWS, and GET /api/server reports it as trust_workflows.
It is not scoped per workflow or per request: a server that accepts jobs from
anything other than yourself, an MCP client included, should run without it.
Untrusted (the default), these are refused, each with an error naming what
triggered it and pointing at --trust-workflows:
- A
pre_load_modulesentry or dotted type outside the allowed packages (require_trusted_dotted_name). Allowed:diffusers,torch,torchvision,transformers,accelerate,peft, the quantization backendssdnq,torchao,optimum(optimum-quanto),gguf,bitsandbytes, anddwitself (fordw.community_pipelines). This is checked before anything is imported. - A type reference that is not a constructible class
(
require_loadable_type,require_constructible_class). A*_typevalue is constructed with the workflow's own arguments, so it must be a class defined inside an allowed package (not merely re-exported by one) and one of: a subclass of a diffusers or transformers model, pipeline, modular pipeline, scheduler, tokenizer, processor, feature extractor, image processor or quantization config, or of torchao'sAOBaseConfig; a class in an auto-pipeline/auto-model factory module; a dataclass indiffusers.pipelinesordiffusers.modular_pipelineswith no unreviewed__post_init__; or a diffusers attention processor."torch.hub.load"is intorchand is refused as not a class. Under adtypeor*_dtypekey atorch.dtype("torch.bfloat16") is accepted too. A bare name ("FluxPipeline") resolves againstdiffusersunder the same rules. - A
constant:walk that leaves the allowed packages. The module is gated like a type before it imports; no segment may start with_(checked before any import); no module the walk passes through may sit outside the allowed packages (constant:torch.os.environis refused attorch.os); andfetch_constantrefuses anything callable. Reading a field off a value declared in an allowed module works. A bareconstant:SOME_NAMEreads fromdiffusers. - Remote code in
from_pretrained_arguments:trust_remote_codeandcustom_pipeline, for every component and pipeline (require_trusted_from_pretrained_arguments). Neither goes through an import of ours, so the dotted-name gate alone would leave them open. - Remote code in a model's
generate():custom_generate(a Hub repo whosecustom_generate/generate.pytransformers downloads and runs) andtrust_remote_code, intext_generation'sgenerate_kwargsand inspeech_generation'sgenerate_kwargsandforward_params(require_trusted_generate_arguments), before the model loads.text_generation'sgenerate_kwargsandchat_template_kwargshold only scalars or lists of them, and none may be one of the pipeline call's own arguments (tokenizer_encode_kwargs,images, a template). - Remote-code keys anywhere in a task's arguments:
trust_remote_code,custom_pipelineorcustom_generate, top level or nested, is a validation error naming the key and its path, andTask.runrepeats the check for every command before its handler runs (require_trusted_task_arguments, GHSA-hhfj-p9xj-xjmr). Trusted, the same keys given as a step argument outsidegenerate_kwargs/forward_paramsare still refused, as arguments the task does not take.
validate_workflow reports each refusal at the key's path (a variable's
default at variables.<name>), so nothing is queued to find out. Every
bundled workflow loads untrusted: tests/test_workflow_trust.py sweeps the
catalog, and tests/test_catalog_structure.py refuses a catalog entry that
sets trust_remote_code or custom_pipeline. --trust-workflows lifts all
of the above except the trusted-mode argument check.
Model code that dw vendors runs no code from its repo, trusted or not:
- Audio8-TTS (
speech_generation,dw/vendored/audio8_tts_model.py): five files fetched byhf_hub_downloadat one pinned 40-hex revision; safetensors weights; thecodec.pthpickle read withtorch.load(weights_only=True). Itsgenerate_kwargs/forward_paramspass the trust gate, then an allowlist (temperature,top_p,top_k,do_sample,max_new_tokens); any other key is refused before the model loads. - MMAudio (
dw/vendored/mmaudio_model.py,dw/vendored/mmaudio_sync_model.py,dw/tasks/mmaudio_decoder.py): the network and Synchformer encoder vendored at upstream commit974010a0. The two pickle checkpoints (weights/mmaudio_large_44k_v2.pth,ext_weights/synchformer_state_dict.pth) are pinned to one 40-hex revision and read withtorch.load(weights_only=True)and a strict load. The VAE and vocoder are Kandinsky 6 safetensors at a pinned sha, built fromconfig.jsonread as JSON. The modules take local paths and never fetch.
text_generation's system_prompt may be a Hub file ({"from_hub": {...}}),
pinned to a 40-hex commit. Before every use the Hub is asked, without
credentials (token=False), whether it serves the file publicly, and the
download carries no token, so the server's login never reaches a gated or
private file on a workflow's say-so. It fails closed: any error from that
ask, being offline, or HF_HUB_OFFLINE refuses the run. It is capped at 1
MiB, checked against the Hub's reported size before the download (no
reported size is a refusal) and against the file after it.
A workflow's arguments also choose locations: which file to open, which URL to fetch, which directory a glob expands over. Untrusted:
- A path must resolve inside a root this installation works in: the
workflow file's own directory, the asset libraries on the search path, or
the output root. A relative path that climbs out is refused on its
..segments, at validation as well as at the loader (#124). Containment is checked before existence, so a refusal is not a file-existence oracle. The remedy for a file outside is anasset:reference. Validation finds a path by its argument's name (image,*_video,location, ...) or by a task's declaration (@register_command(media_arguments=...), read throughTASK_MEDIA_ARGUMENTS, #692).apply_lut'slut,paste_face_track'strackandrestore_to_source'sfitare read only from a server file, so a URL there is refused (LOCAL_ONLY_TASK_ARGUMENTS). - A URL with any other scheme than http(s) (
file://,s3://, ...) is refused at validation and at the loader, trusted or not (#618). - A glob is contained 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, cloud metadata), private ranges, and anything else not globally routable (is_global), checked after DNS resolution. That includes100.64.0.0/10, Tailscale's tailnet range: a workflow that fetches media from another tailnet machine needs--trust-workflows. - Every redirect is re-checked. A media fetch (
safe_getindw/outbound.py) follows at most 5 hops (MAX_MEDIA_REDIRECTS), eachLocationpassing the same scheme and host policy before it is dialed. A refused hop is never fetched, and its error beginsRefusing a redirect to '<target>' (from '<url>'):followed by the rule; an upstream error status keeps the HTTP client's own message (#1055). Each hop's host is resolved once, by the policy, and dialed at the checked address, so DNS rebinding reaches only what was checked; a host that does not resolve or is percent-encoded is refused. An untrusted fetch ignores proxy and~/.netrcsettings. The body is capped atMAX_MEDIA_BYTES(1 GiB) and the fetch atMEDIA_TOTAL_TIMEOUT(600 s), closing the connection at the deadline.remote_text_encoderPOSTs through the same path. Two limits: a trusted fetch through a SOCKS proxy is not deadline-bounded, and the TLS handshake is bounded only by the per-operation timeout. remote_text_encoder.urlis https-only, and the Hugging Face token is attached only forhuggingface.co,huggingface.cloudandhf.space. A redirect that leaves the host, or drops to http, is followed without the token.model_namemust be a Hub repo id or a contained path; a URL is refused (#117).
location_errors runs inside validation_errors, so validate_workflow
refuses before a model load is spent, 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, except the scheme rule.
- Bearer token, opt-in. With
--token/DW_API_TOKENset, every/api/*and/mcprequest needsAuthorization: Bearer <token>, compared in constant time; anything else is a 401. Without a token there is no authentication. - Query token. Only a GET or HEAD route marked
query_token_ok(EventSource streams, thumbnails, download links, which a browser loads without headers) accepts?token=. The marker is matched on the real route, not a path suffix. - Not gated by the token: the web UI's static files,
/outputs,/inputsand/exports/{id}.zip(#592). Anyone who can reach the port can read generated files and input assets by name. - Binding. The default bind is
127.0.0.1. A non-loopback bind without a token logs a warning and starts;--mcpon a non-loopback host without a token exits 2. The connection is plaintext HTTP; use a network you control, Tailscale, or a TLS proxy (REMOTE.md). - Origin check. A request carrying an
Originis refused (403) unless its host is a loopback name, the bind host, or the host the request was addressed to. This stops a drive-by web page queuing jobs, and DNS rebinding, where Origin and Host differ. A request withoutOriginpasses. - Host check. Unless bound to a wildcard address, a
Hostthat is not a loopback name or the bind host is a 400. - Response headers. Every response carries
X-Content-Type-Options: nosniffandX-Frame-Options: DENY(#407).
| 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 |
arguments.py, argument_media.py |
Media locations, file paths and extensions (validate_media_location; fetch_image, fetch_video) |
locations.py, outbound.py |
Every location a workflow supplies, and every outbound media fetch |
tasks/gather.py |
URLs passed to the gather task |
result.py, writers.py, content_types.py |
Output directories, file names (output_file_path), result content types |
server/http_security.py |
Token, Origin and Host on every request |
server/jobs.py, server/outputs.py, server/routes/*.py |
Every HTTP-supplied path: workflows confined to the workflow directories, gallery files to the output directory, assets to their library, an inline workflow's base_dir |
dw_mcp/ adds no authentication and no file access of its own, with two
exceptions below. It is an HTTP client of dw.serve: every path a tool sends
(a workflow name, a gallery file, a job id) is validated by the REST API
exactly as a browser request from the web UI is. A remote dw.serve is
allowed only with a token (MCP Server,
REMOTE.md).
download_output writes a local file and upload_asset(file_path=...) reads
one, outside the API. Over a stdio dw-mcp, "local" is the user's own
machine, and both act as the user would with a shell redirect. Over
dw.serve --mcp it is the operator's box, so both are confined:
download_output's destination to the workspace (#113), and
upload_asset's file_path to the server's workspace, workflows, assets,
outputs and prompts directories (#138), with the refusal ordered before the
existence and extension checks so it is not a path-existence oracle. On both
transports a .. segment in destination is refused and an existing file is
kept unless overwrite=True.
Nothing in dw_mcp/ inspects what a workflow it saves (save_workflow) or
runs (run_workflow) contains. An MCP-authored or MCP-submitted workflow is
protected by dw-serve's untrusted default, the same gate as any other
untrusted workflow. dw-serve --trust-workflows removes that gate for every
job the server accepts.
SecurityError
PathTraversalError - path traversal attempt
InvalidInputError - input validation failure
UntrustedWorkflowError - an untrusted workflow reached the code-execution surface
DELETE /api/caches/{name} (dw/disk_caches.py) empties a directory whose
path comes from an environment variable, so it clears only a root carrying a
.dw-cache marker. dw.serve writes the marker at start into a root that is
missing or empty (a missing triton root is never created), or into a
populated root equal to the cache's default exactly (~/.cache/dw/torchinductor
or its $XDG_CACHE_HOME form; ~/.triton/cache only, not a populated
$TRITON_HOME/.triton/cache). Any other populated root is listed with
clearable: false and its clear is a 400 until someone creates the marker by
hand; deleting the marker stops clearing. quantized
(DW_QUANTIZED_CACHE_DIR) follows the same rule, and
DELETE /api/caches/{name}/entries/{entry} deletes one entry only if the id
matches ^[0-9a-f]{64}$, checked before any path is built. A clear refuses
while a job is running or queued (for quantized, also while a model download
is active), and renames the root's children into .clearing-* before deleting
them. Like model deletion, it needs only the bearer token.
- Validate a path before any file operation, with a base directory.
- Validate a URL with
validate_url(), and a workflow-supplied location withdw/locations.py, before loading a remote resource. - Run a subprocess with
shell=Falseand its arguments throughsanitize_command_args(). - Never use
eval,execorshell=True.
- Path traversal: no file outside the allowed directories.
- Command injection: no shell interpretation anywhere in
dw/. - Arbitrary code from a workflow: the trust gate, unless
--trust-workflows. - Arbitrary file read through a media argument: a workflow-supplied
location is confined to the roots it may read (
dw/locations.py). - Malicious URLs and SSRF: http/https only, and an untrusted workflow may not reach a host inside the deployment, including through a redirect.
- Resource exhaustion: 50MB workflow JSON, 1 GiB media fetch with a 600 s deadline.
- 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 one, the gallery thumbnail answers 413,compare_outputsrefuses a video whose frame exceeds it (413), and embedded metadata is read from PNG header chunks without decoding. Other video decodes and all audio decodes are not limited. - Script on the UI's origin through an output: a result
content_typemay not be an active document type (text/html,application/xhtml+xml,text/xml,application/xml,image/svg+xml, compared without case or parameters). Validation refuses it atsteps[i].result.content_type, the writer refuses it again (dw/content_types.py), and a file of an active type that reaches/outputsor/inputsanyway is served withContent-Security-Policy: sandbox.
Not protected: generated files and input assets are readable without the token by anyone who can reach the port, and traffic is plaintext.
pytest tests/test_security.py tests/test_security_auth.py tests/test_security_trust_gate.py \
tests/test_security_symlinks.py tests/test_locations.py tests/test_outbound.py \
tests/test_workflow_trust.py -v