Repository navigation
test(security): cover the boundaries the live MCP suite can't reach - #391
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
…it through Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
…utes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tests only. No production code changes. This adds six files under
tests/test_security_*.py: 325 passing tests and 18 strict xfails. Each xfail is a finding. All run on CPU, offline and fast (the slowest is 0.6s). Every probe targetstmp_path."Refused too late" is checked directly, not taken from the exception:
sys.pathand writes a marker file if its code runs. Asys.meta_pathrecorder logs any attempt to find it.inet_aton) and answers names only from a table.HTTPAdapter.sendrecords every request that would have gone out, headers included.ImageFile.loadis replaced with a version that records the attempt and raises before allocating anything.Coverage
test_security_auth.py::TestTheTokenGate(path spellings, wrong schemes,?token=on routes that don't allow it, GET-only query token, token doesn't excuse a foreign Origin),TestOriginAndHostSpoofing(userinfo/suffix/fragment lookalikes, Host check on/outputsand/inputs),TestUngatedRoutesServeOnlyTheirRoots(what/outputs,/inputs,/exportsserve without a token; encoded traversal; absolute names; hostile?workspace=),TestServeRefusesAnOpenMcpEndpoint(--mcpon 0.0.0.0,::, LAN IPs; empty token;DW_API_TOKEN; nothing starts before the refusal)test_an_unparseable_origin_is_a_403_not_a_500test_security_trust_gate.py::TestUntrustedRefusesBeforeImport(*_type,component_type,_dtype,dtype,config_type, nested;pre_load_modulesincl. trusted-first ordering;constant:;trust_remote_codetruthy variants;custom_pipelineHub and local path),TestValidationRefusesBeforeImport,TestTrustedLetsEachSurfaceThrough(viaDW_TRUST_WORKFLOWS=1and viaset_trust_workflows(True)),TestHowAProcessBecomesTrusted(only exactly"1"trusts;dw.serveanddw.validatewithout the flag override an inherited1)test_config_type_cannot_name_a_code_loader_in_an_allowed_package,test_a_constant_cannot_walk_out_of_an_allowed_packagetest_security_symlinks.py: read/write/delete on outputs, assets, sharedcommon/assets, workflows, prompts, exports and workspaces;keep_output; uploads, including shared ones;asset:,output:andprompt:references;gather_images/gather_videosglobs/exports/<job>.ziptest_security_decoder_bombs.py: over Pillow's error limit viaget_output_imageand thumbnail; gallery listing doesn't decode;MAX_IMAGE_PIXELSnot disabledget_output_image, withcrop, thumbnail, metadatatest_security_ssrf.py::TestInternalAddressSpellings: RFC1918 edges, link-local, 127/8, 0.0.0.0, IPv6 loopback/ULA/link-local incl. zone id andfd00:ec2::254, IPv4-mapped IPv6, decimal/hex/octal/short spellings, names resolving inside, mixed DNS answers, userinfo, public addresses still allowedTestHuggingFaceTokenScope: allowed hosts, lookalikes, userinfo/fragment/path tricks end to end on outgoing headers, cross-host 307 drops the token, https→http downgrade drops it, trust lifts the scopetest_the_token_goes_only_where_the_connection_goes[evil.example\@huggingface.co]download_outputTestDownloadOutputDestination: relative destinations land inside;.., absolute and~refused; symlinked parent and grandparent refused (with and without overwrite); overwrite can't clobber a linked file; a dangling link is replaced, not followed; a symlinked workspace root still confinestest_security_input_caps.py: 20,000 accepted, 20,001 and 25,000 refused (top level, list entries, nested dicts);/api/validate,/api/jobs(worker never sentexecute), CLI (nothing loaded); variable name cap; 33for_eachentries inline and via arguments; 50MB workflow file refused without being opened; 200MB upload refused from Content-Length without the body being readtest_a_default_in_the_definition_is_held_to_the_same_capFindings
"MCP consumer" below means anyone who can submit an inline workflow over MCP or
/api/jobsunder the default untrusted posture. I confirmed every xfail with--runxfail: each fails on the assertion it names, not on a setup error.High
config_typereaches arbitrary code through an allowlisted package.create_quantization_config(dw/pipeline_processors/config_objects.py) calls the realizedconfig_typewith the workflow's ownarguments.torchis inTRUSTED_TOP_LEVEL_PACKAGES, so"config_type": "torch.hub.load"with{"repo_or_dir": "attacker/repo", "trust_repo": true}is called untrusted.hubconf.py. The workflow also passesvalidation_errorswith[].torch.hub.loadwas called with the attacker's kwargs.remote_text_encoder(dw/pipeline_processors/remote.py) picks the token's destination withurlparse(url).hostname. Forhttps://evil.example\@huggingface.co/encodethat host ishuggingface.co.evil.example.https://evil.example/%5C@huggingface.co/encodecarryingAuthorization: Bearer <token>.validate_media_url(dw/locations.py) checksurlparse's host. Forhttp://169.254.169.254\@example.com/,example.comis checked and169.254.169.254is dialed.Medium
4. Redirects aren't re-checked.
fetch_image(dw/arguments.py, via diffusersload_image) andload_audio(dw/tasks/audio_utils.py) validate only the first URL. requests then follows a 302/307 to 169.254.169.254 or 127.0.0.1.video_utils.py:479has the same pattern; I didn't test it separately.constant:torch.os.environreads the server's environment untrusted.load_constant_from_name(dw/type_helpers.py) checks only the top-level package and then walks attributes from there, sotorch.os.environpasses.DW_API_TOKENreadable from the returned value, and validation passes.compose_textrefuses a non-text part. It is one stringifying sink away. Reachable by an MCP consumer.MAX_IMAGE_PIXELS, so a 144M-pixel PNG is fully decoded by:get_output_image(dw_mcp/media.py), including withcropgallery_thumbnail(draft()does nothing for PNG)read_embedded_metadataindw/result.pyreadsPngImageFile.text, which loads the whole imageLow (the symlink cases need someone with local filesystem access to plant the link)
GET /exports/<job>.ziphas no token, andos.walk+ZipFile.writefollow a planted file symlink. Observed: the target's bytes in the zip.workflow_details(dw/server/app.py) andprompt_detailsopen every*.jsonfound byos.walk, with no containment check. Observed: a linked file's description, variable names and prompt text inGET /api/workflowsandGET /api/prompts._iter_gallery_files) and asset listings list a symlink pointing outside, with the target's size and mtime. Reading the file itself is refused.reject_foreign_originscallsurlparseoutside atry. An Origin likehttp://[::1].evil.examplegives a 500, not a 403. Nothing is processed, and a test pins that.100.64.0.0/10isn't treated as internal (_is_internalnever checksis_global). This covers Alibaba Cloud's metadata endpoint at 100.100.100.200.Already failing on
developNone.
developatc3ee829in this environment: 5761 passed, 15 skipped, 0 failed.develop(9c78993) into this branch: full suite 6116 passed, 15 skipped, 18 xfailed, 0 failed.Environment caveat: the
pyproject.tomlfloors (e.g.numpy>=2.5.2,torch>=2.13) weren't available from the PyPI this sandbox reaches, and download.pytorch.org was blocked. I installed the newest versions available (torch 2.14 from PyPI, numpy 2.4.6, diffusers from git), and ruff is clean on the new files. CI will be the real check.Separately, while writing these I found that importing the bitsandbytes CPU backend (reached lazily when a real diffusers class name is validated) calls the Hub for a kernel at import time. The tests use an escaped
{Fake}component so they never trigger it. Some existing tests probably do.Not covered, and why
POST /api/jobs): I found no cap and none is documented. A test would need a body over 50MB, which is slower than a couple of seconds for a limit nobody promised.nt.🤖 Generated with Claude Code
https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52
Generated by Claude Code