Skip to content

fix(security): drop file:// support from createMediaItem mutation#3834

Merged
jasonbahl merged 2 commits into
mainfrom
fix/media-item-create-lfi-hardening
May 19, 2026
Merged

fix(security): drop file:// support from createMediaItem mutation#3834
jasonbahl merged 2 commits into
mainfrom
fix/media-item-create-lfi-hardening

Conversation

@jasonbahl
Copy link
Copy Markdown
Collaborator

@jasonbahl jasonbahl commented May 19, 2026

Summary

  • Removes 'file' from the default $allowed_protocols list in createMediaItem, so the mutation no longer advertises local-file sideload as supported.
  • Deletes the now-unreachable file_get_contents() + wp_upload_bits branch that consumed file:// paths — including the file_get_contents() call named as the sink in a recent external security report.
  • Adds a regression test (testFileProtocolIsRejected) asserting file:// filePaths are rejected and no attachment URL is returned.

Why

'file' was added to the default $allowed_protocols in v1.14.6 (#2840) alongside a wp_http_validate_url() check pushed in the same PR. wp_http_validate_url() strips non-http/https schemes and requires a non-empty host, so file://... URLs have been rejected by core validation before the protocol allowlist is ever consulted. That made:

  • the 'file' allowlist entry effectively a no-op,
  • the wp_upload_bits branch (lines 236–239 prior) unreachable in stock WordPress,
  • the graphql_media_item_create_allowed_protocols filter unable to serve as a real escape hatch for file://, since wp_http_validate_url() runs first and has no filter inside it.

A public GitHub code search for graphql_media_item_create_allowed_protocols returns only WPGraphQL's own source (vendored copies), no consumer code calling add_filter(). So removing file from the default has no known compatibility impact.

An external security researcher reported the design intent (allowing file + calling file_get_contents() on user input) as a Critical LFI vulnerability. The exploit as written is not reachable on stock WordPress for the reasons above, but the design smell is real defense-in-depth weakness: anything that loosened wp_http_validate_url() would flip this to exploitable. This PR removes both the smell and the sink.

What changed

  1. plugins/wp-graphql/src/Mutation/MediaItemCreate.php
    • Line 199: $allowed_protocols = [ 'https', 'http', 'file' ][ 'https', 'http' ]
    • Removed $file_contents = file_get_contents( $input['filePath'] ) and the conditional wp_upload_bits branch that depended on it.
  2. plugins/wp-graphql/tests/wpunit/MediaItemMutationsTest.php
    • Added testFileProtocolIsRejected regression test.

Test plan

  • MediaItemMutationsTest runs green locally (28 tests, 41 assertions)
  • PHPCS clean on changed lines
  • CI matrix passes (WordPress 6.1–trunk × PHP 7.4–8.4 × block/classic × single/multisite)

Follow-up (not in this PR)

Adjacent createMediaItem test coverage is fragile and SSRF-thin. A separate PR will:

  • mock the HTTP layer so happy-path tests don't depend on raw.githubusercontent.com,
  • add SSRF regression tests for 169.254.169.254, RFC1918 ranges, IPv6 loopback, and redirects to private IPs,
  • add a wp_handle_sideload failure test.

jasonbahl added 2 commits May 19, 2026 11:10
…otocols

The file protocol was kept in the default $allowed_protocols list when
wp_http_validate_url() was added in v1.14.6 (#2840), but that validator
rejects file:// URLs, making the entry unreachable in practice. A public
code search shows no consumers calling the
graphql_media_item_create_allowed_protocols filter, so removing 'file'
from the default has no known compatibility impact.

Sites that legitimately rely on local-file sideload (e.g., migration
scripts) can re-enable it via the filter, which still routes through the
wp_upload_bits branch in MediaItemCreate.

Adds a regression test that asserts a file:// filePath is rejected and
that no attachment URL is returned.
Following the removal of 'file' from the default $allowed_protocols list
in the previous commit, the file_get_contents() call and the wp_upload_bits
branch that consumed it are unreachable: wp_http_validate_url() rejects
file:// URLs (no host, scheme stripped to http/https), so the request
errors out before the protocol allowlist check is even consulted.

This also corrects a misleading claim in the prior commit message: the
graphql_media_item_create_allowed_protocols filter alone never functioned
as an escape hatch for local-file sideload, because wp_http_validate_url()
gates the request first. The local-sideload feature has been effectively
unreachable since v1.14.6.

Removing the branch eliminates the file_get_contents() sink named in the
external security report and leaves a single, clear download_url() path
for createMediaItem.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
wpgraphql-com Skipped Skipped May 19, 2026 5:22pm

@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.5%. Comparing base (ee20be9) to head (c1a76d9).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##              main   #3834   +/-   ##
=======================================
  Coverage     83.5%   83.5%           
+ Complexity    5201    5198    -3     
=======================================
  Files          286     286           
  Lines        22567   22563    -4     
=======================================
- Hits         18834   18832    -2     
+ Misses        3733    3731    -2     
Flag Coverage Δ
wp-graphql-acf-wpunit-twentytwentyfive-single 77.1% <ø> (ø)
wp-graphql-wpunit-twentytwentyfive-multisite 84.5% <100.0%> (+<0.1%) ⬆️
wp-graphql-wpunit-twentytwentyfive-single 84.5% <100.0%> (+<0.1%) ⬆️
wp-graphql-wpunit-twentytwentyone-multisite 84.5% <100.0%> (+<0.1%) ⬆️
wp-graphql-wpunit-twentytwentyone-single 84.5% <100.0%> (+<0.1%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...lugins/wp-graphql/src/Mutation/MediaItemCreate.php 89.4% <100.0%> (+0.9%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasonbahl jasonbahl merged commit eb91d26 into main May 19, 2026
65 of 66 checks passed
@jasonbahl jasonbahl deleted the fix/media-item-create-lfi-hardening branch May 19, 2026 17:51
@jasonbahl
Copy link
Copy Markdown
Collaborator Author

Heads up — while building the follow-up test hardening on chore/harden-create-media-item-tests, I confirmed an additional dimension to this fix worth noting in the PR body.

The file_get_contents( $input['filePath'] ) call this PR removes was reachable for any URL that passes wp_check_filetype + wp_http_validate_url, not only file://. PHP's file_get_contents uses its own HTTP stream wrapper, which bypasses WordPress's HTTP API entirely (no pre_http_request filter, no wp_safe_remote_get IP filtering). So an authenticated upload_files user could coerce the server into HTTP GETs against any URL the PHP process can reach — including cloud instance metadata.

Concrete demo on origin/main (before this PR), with an SSRF regression test that runs the mutation against http://169.254.169.254/latest/meta-data/instance-id.jpg:

debugMessage: file_get_contents(http://169.254.169.254/latest/meta-data/instance-id.jpg):
              Failed to open stream: HTTP request failed!

The test took ~60s (PHP's default stream-wrapper timeout) because file_get_contents was actually attempting the connection. After rebasing onto this PR, the same test runs in 0.13s and surfaces the gap differently: download_url/wp_safe_remote_get is now the sink, and wp_http_validate_url() doesn't reject 169.254/16 (its reject list covers 127/8, 10/8, 0/8, 172.16-31/12, 192.168/16 — but not link-local).

What this means for #3834:

  • The fix here closes the file_get_contents SSRF vector entirely (impact reduction is broader than just LFI).
  • A residual SSRF surface remains via download_url's HTTP path against 169.254.169.254. That's a separate hardening — adding a plugin-level guard for 169.254/16 — which I'll bring in the follow-up tests PR alongside the regression test that surfaced it.

Suggested addition to the PR description (paraphrased): "This also closes an SSRF vector reachable via the same file_get_contents() call: PHP's HTTP stream wrapper bypasses wp_safe_remote_get, so any URL that passes wp_check_filetype and wp_http_validate_url (including http://169.254.169.254/) could be fetched by the server. The cloud-metadata vector via download_url itself remains and will be addressed in a follow-up."

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.

1 participant