Skip to content

Forms: harden webhook URL validation — loopback, redirects, and an escape hatch - #52103

Open
kraftbj wants to merge 3 commits into
trunkfrom
forms-webhook-loopback-ssrf-FORMS-786
Open

Forms: harden webhook URL validation — loopback, redirects, and an escape hatch#52103
kraftbj wants to merge 3 commits into
trunkfrom
forms-webhook-loopback-ssrf-FORMS-786

Conversation

@kraftbj

@kraftbj kraftbj commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes FORMS-786

Proposed changes

Started as one failing test and grew into the SSRF path around it. Form_Webhooks::is_blocked_ip() blocked IPv6 loopback (::1), link-local, and ULA, but its IPv4 branch only covered 169.254.0.0/16 and the Azure wire server. Nothing blocked 127.0.0.0/8.

So https://localhost/webhook cleared validation on the gethostbyname() result and was only ever stopped by the AAAA lookup further down. glibc answers ::1 for localhost; macOS returns nothing for it over DNS. That is why test_send_webhooks_blocks_localhost_hostname is green in CI and red on a Mac — same commit, same tree, different resolver.

Blocking

  • Block 127.0.0.0/8 and 0.0.0.0/8, plus :: alongside ::1. :: rather than ::1 is the real IPv6 counterpart of 0.0.0.0, and connect() to it reaches loopback.
  • Decode the IPv6 spellings that embed an IPv4 address before checking it. 64:ff9b::7f00:1 (NAT64) and 2002:7f00:1::1 (6to4) both reach 127.0.0.1 and both were allowed — loopback bypasses in a change whose whole point is blocking loopback. The existing ::ffff: handling was also dead for loopback, since it recursed into is_blocked_ip( '127.0.0.1' ) and got false.
  • Switch the link-local check from magic long bounds to octet math. On 32-bit PHP ip2long() returns a negative int above 127.255.255.255, so >= 2851995648 never fired there and 169.254.0.0/16 was silently unblocked. The Azure literal moved above the ip2long() guard so it stays reachable.

Don't follow redirects

Core re-validates each hop via requests.before_redirect, but wp_http_validate_url() does not block 168.63.129.16 (the Azure wire server) anywhere and still permits plain http, so a 302 escaped the stricter checks we had just run on the configured URL. Core's own browser_redirect_compatibility also turns a redirected POST into a bodyless GET, so following one was not delivering the webhook anyway.

Give sites an escape hatch

Because Forms validates ahead of wp_safe_remote_request(), core's http_request_host_is_external filter is never reached — a site that deliberately points a webhook at its own network had no recourse. New filter:

add_filter( 'jetpack_forms_webhook_blocked_ip', function ( $blocked, $ip, $url ) {
	return '10.0.0.5' === $ip ? false : $blocked;
}, 10, 3 );

Keep it diagnosable

A webhook rejected at validation was skipped in get_enabled_webhooks() and left nothing behind, while a request-time failure wrote _jetpack_forms_webhook_error and bumped the request stat. Validation failures now record the same way.

Tests

Four existing tests asserted almost nothing — test_send_webhooks_blocks_zero_padded_ip had no pre_http_request stub at all, so it made a real network call and passed on the connection failure. Tightened those, and added coverage for 0.0.0.0/8, ::, the NAT64/6to4 spellings, the new filter, the error trail, and the redirect behavior.

This is a behavior change, narrowly

An earlier attempt at this fix (#46526) was closed on the grounds that wp_http_validate_url() already blocks 127/8, 10/8, 172.16/12 and 192.168/16, making the addition redundant. True except for one case that review missed: core keeps its entire IP check inside if ( ! $same_host ). When the webhook host matches the site's own home host, core does no IP filtering at all.

So a site whose home host resolves to loopback, with a webhook pointed at that same host, previously fired and now does not — hence the plugins/jetpack changelog entries, and the new filter for anyone who wants the old behavior back.

On consolidating with packages/ip

Automattic\Jetpack\IP\Utils::ip_is_public() already implements a superset of this logic, and automattic/jetpack-ip is already in the Forms vendor tree transitively (via connection, status and sync). Delegating to it is tempting, and I measured what it would change across 32 representative addresses. It never unblocks anything, and after this PR it would newly block 11 more classes:

RFC1918 10/8, 172.16/12, 192.168/16, and their ::ffff: forms
Other special-use CGNAT 100.64/10 (Alibaba metadata), 192.0.0/24, 192.88.99/24, 198.18/15, multicast, 240/4, broadcast

That is the RFC1918 decision below, plus six more ranges, arriving as a side effect of a refactor. Worth doing, but as its own change where the blast radius is the point rather than a footnote — tracked in FORMS-787. This PR takes the part that is unambiguously in scope, the embedded-IPv4 loopback decoding, and leaves the rest.

Deliberately out of scope

is_blocked_ip() still does not block RFC1918 at validation time, even though it blocks the IPv6 equivalent fc00::/7. Same-host aside, core covers those at request time, and blocking them here would newly break a local-dev site webhooking to its own private-IP host. That wants its own decision.

Related product discussion/links

Does this pull request change what data or activity we track or use?

The jetpack_forms_webhook_request stat now counts validation-time rejections as errors, where before it only counted request-time failures. No new data is collected.

Testing instructions

This one is platform-dependent, so run it on both if you can.

  • Run the Forms PHP suite: jetpack test php packages/forms. Natively, that is composer install in tools/php-test-env and in projects/packages/forms, then composer phpunit from the package.
  • All 50 Form_Webhooks_Test cases should pass. On trunk, test_send_webhooks_blocks_localhost_hostname fails on macOS and passes on Linux.
  • To watch that split for yourself, run the same worktree through a Linux container (vendor dirs from the host install are reused, so run the installs first):
docker run --rm -v "$PWD:$PWD" -w "$PWD/projects/packages/forms" php:8.3-cli \
  bash -lc "php vendor/bin/phpunit -c phpunit.12.xml.dist tests/php/service/Form_Webhooks_Test.php"
  • Functional check: point a form's webhook at https://localhost/webhook, submit an entry, and confirm nothing goes out and the entry records a webhook error. Then add the jetpack_forms_webhook_blocked_ip filter above returning false, submit again, and confirm the request is attempted.

is_blocked_ip() covers ::1 but not 127.0.0.0/8, so a webhook pointed at
localhost was only ever blocked when the host resolver returned an AAAA
record for it. glibc does; macOS does not, which is why
test_send_webhooks_blocks_localhost_hostname passes in CI and fails on a
Mac. Same commit, same tree, different resolver.

Block 127.0.0.0/8 and 0.0.0.0/8 alongside the IPv6 checks already there.
That also revives the IPv4-mapped path, which was dead for loopback:
::ffff:127.0.0.1 recursed into is_blocked_ip('127.0.0.1') and got false.

Tighten the affected tests to assert webhook_skipped/blocked_ip and no
HTTP request, rather than a three-way || that passed on almost anything.
One of them had no pre_http_request stub at all and was making a real
network call.

Fixes FORMS-786
@kraftbj kraftbj added Bug When a feature is broken and / or not performing as intended [Feature] Forms [Status] Needs Review This PR is ready for review. labels Sep 8, 2026
@kraftbj kraftbj self-assigned this Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the forms-webhook-loopback-ssrf-FORMS-786 branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack forms-webhook-loopback-ssrf-FORMS-786

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Jetpack plugin:

The Jetpack plugin has different release cadences depending on the platform:

  • WordPress.com Simple releases happen as soon as you deploy your changes after merging this PR (PCYsg-Jjm-p2).
  • WoA releases happen weekly.
  • Releases to self-hosted sites happen monthly:
    • Scheduled release: October 6, 2026

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

@jp-launch-control

jp-launch-control Bot commented Sep 8, 2026

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 1 file.

File Coverage Δ% Δ Uncovered
projects/packages/forms/src/service/class-form-webhooks.php 163/173 (94.22%) -1.18% 3 ❤️‍🩹

Full summary · PHP report · JS report

Blocking 127.0.0.0/8 is observable after all. wp_http_validate_url()
keeps its whole IP check behind `if ( ! $same_host )`, so a site whose
home host resolves to loopback could webhook to itself and core never
looked. Add the plugins/jetpack changelog entry that behavior change
earns, and stop claiming in the docblock that IPv4 private ranges are
blocked when RFC1918 deliberately is not.

Also block :: alongside ::1. The unspecified address, not ::1, is the
IPv6 counterpart of the 0.0.0.0/8 rule added here, and connect() to ::
reaches loopback.

Switch the link-local check from magic long bounds to octet math. On
32-bit PHP ip2long() goes negative above 127.255.255.255, so the old
`>= 2851995648` comparison never fired there and 169.254.0.0/16 was
silently unblocked. Move the Azure literal above the ip2long() guard so
it stays reachable regardless.

Tests for 0.0.0.0/8 and :: — both were shipped unblocked and untested.
@github-actions github-actions Bot added the [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ label Sep 8, 2026
…p following redirects

Four things the review turned up, all in the webhook SSRF path.

Forms validates ahead of wp_safe_remote_request(), so core's own
http_request_host_is_external escape hatch is never reached and a site
that deliberately points a webhook at its own network has no recourse.
Add jetpack_forms_webhook_blocked_ip as the equivalent.

A webhook rejected at validation was skipped in get_enabled_webhooks()
and left nothing behind, while a request-time failure wrote post meta
and bumped the stat. Record validation failures the same way, so a
misconfigured URL stays diagnosable.

Stop following redirects. Core re-validates each hop, but it never
blocks the Azure wire server and still permits http, so a 302 escaped
the stricter checks we just ran -- and browser_redirect_compatibility
turns a redirected POST into a bodyless GET regardless.

Decode the IPv6 spellings that embed an IPv4 address before checking it.
64:ff9b::7f00:1 (NAT64) and 2002:7f00:1::1 (6to4) both reach 127.0.0.1
and both were allowed, which makes them loopback bypasses in a change
whose whole point is blocking loopback. packages/ip already does this;
see the PR for why the wholesale swap is a separate decision.
@kraftbj kraftbj changed the title Forms: block IPv4 loopback when validating webhook URLs Forms: harden webhook URL validation — loopback, redirects, and an escape hatch Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug When a feature is broken and / or not performing as intended [Feature] Contact Form [Feature] Forms [Package] Forms [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Status] Needs Review This PR is ready for review. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant