Skip to content

Commit b7623ff

Browse files
enejbclaude
andcommitted
Forms: Carry is_test via signed JWT source; a11y + unread-count polish
Follow-ups from review feedback on #48057: - Replace the hidden-field + nonce injection in form preview rendering with a flag baked into the signed JWT. Feedback_Source::get_current() now reads Form_Preview::is_preview_mode() at render time and records is_test=true on the source, which travels tamper-proof inside the JWT to submission. JWTs issued before this change omit the flag and continue to behave as regular submissions. - Remove the now-unused inject_preview_submission_fields() helper, verify_preview_submission() helper, and the three PREVIEW_SUBMIT_* constants from Form_Preview. - Drop the test-feedback special case in Feedback::save(); test responses are now created as STATUS_UNREAD like any other response so they contribute to the unread count. - Add aria-label="Test response" to the inline Test badge in the dashboard From column so screen readers get a complete label. - Add two PHPUnit round-trip tests covering the JWT backward-compat contract: a JWT issued in preview mode marks the decoded form as a test submission; a JWT issued outside preview mode (or from a cached HTML fragment predating this feature) decodes to a regular submission. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 18f8a7d commit b7623ff

7 files changed

Lines changed: 123 additions & 238 deletions

File tree

projects/packages/forms/routes/responses/stage.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,12 @@ function StageInner() {
466466
<Stack direction="column" gap="2xs">
467467
<Stack direction="row" align="center" gap="xs">
468468
{ item.is_test && (
469-
<Badge intent="informational">{ __( 'Test', 'jetpack-forms' ) }</Badge>
469+
<Badge
470+
intent="informational"
471+
aria-label={ __( 'Test response', 'jetpack-forms' ) }
472+
>
473+
{ __( 'Test', 'jetpack-forms' ) }
474+
</Badge>
470475
) }
471476
<Text ellipsizeMode="tail" limit={ 50 } truncate>
472477
{ displayName }

projects/packages/forms/src/contact-form/class-contact-form-plugin.php

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,24 +1620,6 @@ public function process_form_submission() {
16201620
return Form_Submission_Error::system_error( 'invalid_form_id_or_hash', __( 'Invalid form ID or hash.', 'jetpack-forms' ) );
16211621
}
16221622

1623-
// Detect and authorize submissions coming from form preview. When a preview
1624-
// submission is detected we let it flow through the normal pipeline but
1625-
// flag the resulting feedback as a test response.
1626-
$is_preview_submission = false;
1627-
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce is verified inside verify_preview_submission().
1628-
if ( ! empty( $_POST[ Form_Preview::PREVIEW_SUBMIT_FIELD ] ) ) {
1629-
$preview_form_id = is_numeric( $id ) ? (int) $id : 0;
1630-
if ( $preview_form_id > 0 && Form_Preview::verify_preview_submission( $preview_form_id ) ) {
1631-
$is_preview_submission = true;
1632-
} else {
1633-
// Preview flag was present but nonce/caps did not check out. Reject.
1634-
return Form_Submission_Error::system_error(
1635-
'invalid_preview_submission',
1636-
__( 'Invalid form preview submission.', 'jetpack-forms' )
1637-
);
1638-
}
1639-
}
1640-
16411623
if ( is_user_logged_in() ) {
16421624
check_admin_referer( "contact-form_{$id}" );
16431625
}
@@ -1734,9 +1716,14 @@ public function process_form_submission() {
17341716
if ( Jetpack_Forms::is_webhooks_enabled() && ! empty( $form->attributes['webhooks'] ) ) {
17351717
Form_Webhooks::init();
17361718
}
1737-
// Flag the submission as originating from a form preview so the form
1738-
// treats the resulting feedback as a test response.
1739-
$form->set_is_preview_submission( $is_preview_submission );
1719+
1720+
// The decoded JWT carries a serialized Feedback_Source; when the
1721+
// form was rendered in preview mode that source has is_test=true.
1722+
// Flag the submission accordingly so the response is stored as a
1723+
// test response. JWTs issued before this feature shipped simply
1724+
// omit the flag and behave as regular submissions.
1725+
$form->set_is_preview_submission( $form->get_source()->is_test() );
1726+
17401727
// Process the form
17411728
return $form->process_submission();
17421729
}
@@ -1925,10 +1912,6 @@ public function process_form_submission() {
19251912
Form_Webhooks::init();
19261913
}
19271914

1928-
// Flag the submission as originating from a form preview so the form
1929-
// treats the resulting feedback as a test response.
1930-
$form->set_is_preview_submission( $is_preview_submission );
1931-
19321915
// Process the form
19331916
return $form->process_submission();
19341917
}

projects/packages/forms/src/contact-form/class-feedback-source.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,20 +177,27 @@ private static function get_source_title() {
177177
public static function get_current( $attributes ) {
178178
global $wp, $page;
179179
$current_url = home_url( add_query_arg( array(), $wp->request ) );
180+
181+
// When a form is rendered inside the server-side preview
182+
// (Form_Preview::maybe_render_preview), flag the source as a test
183+
// submission. The flag travels with the signed JWT and is read back
184+
// at submission time to branch the response into the test pipeline.
185+
$is_test = Form_Preview::is_preview_mode();
186+
180187
if ( isset( $attributes['widget'] ) && ! empty( $attributes['widget'] ) ) {
181-
return new self( $attributes['widget'], self::get_source_title(), 1, 'widget', $current_url );
188+
return new self( $attributes['widget'], self::get_source_title(), 1, 'widget', $current_url, $is_test );
182189
}
183190

184191
if ( isset( $attributes['block_template'] ) && ! empty( $attributes['block_template'] ) ) {
185192
global $_wp_current_template_id;
186-
return new self( $_wp_current_template_id, self::get_source_title(), $page, 'block_template', $current_url );
193+
return new self( $_wp_current_template_id, self::get_source_title(), $page, 'block_template', $current_url, $is_test );
187194
}
188195

189196
if ( isset( $attributes['block_template_part'] ) && ! empty( $attributes['block_template_part'] ) ) {
190-
return new self( $attributes['block_template_part'], self::get_source_title(), $page, 'block_template_part', $current_url );
197+
return new self( $attributes['block_template_part'], self::get_source_title(), $page, 'block_template_part', $current_url, $is_test );
191198
}
192199

193-
return new Feedback_Source( \get_the_ID(), \get_the_title(), $page, 'single', $current_url );
200+
return new Feedback_Source( \get_the_ID(), \get_the_title(), $page, 'single', $current_url, $is_test );
194201
}
195202

196203
/**

projects/packages/forms/src/contact-form/class-feedback.php

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,11 +1482,6 @@ public function mark_as_test() {
14821482
* @return int
14831483
*/
14841484
public function save() {
1485-
// Test feedback (from form preview) is created as already-read so it does
1486-
// not bump the unread counter in the dashboard — the form owner explicitly
1487-
// triggered it and doesn't need to be notified of "new" responses.
1488-
$comment_status = $this->is_test() ? self::STATUS_READ : self::STATUS_UNREAD;
1489-
14901485
$post_id = wp_insert_post(
14911486
array(
14921487
'post_type' => self::POST_TYPE,
@@ -1497,7 +1492,7 @@ public function save() {
14971492
'post_content' => $this->serialize(), // In V3 we started to addslashes.
14981493
'post_mime_type' => 'v3', // a way to help us identify what version of the data this is.
14991494
'post_parent' => $this->form_id ?? $this->source->get_id(),
1500-
'comment_status' => $comment_status,
1495+
'comment_status' => self::STATUS_UNREAD, // New feedback is unread by default.
15011496
)
15021497
);
15031498

projects/packages/forms/src/contact-form/class-form-preview.php

Lines changed: 9 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,6 @@ class Form_Preview {
2323
*/
2424
const PREVIEW_NONCE_ACTION = 'jetpack_form_preview_';
2525

26-
/**
27-
* The nonce action prefix used to authenticate POST submissions
28-
* originating from a form preview render.
29-
*
30-
* @var string
31-
*/
32-
const PREVIEW_SUBMIT_NONCE_ACTION = 'jetpack_form_preview_submit_';
33-
34-
/**
35-
* POST field name used to flag a submission as originating from form preview.
36-
*
37-
* @var string
38-
*/
39-
const PREVIEW_SUBMIT_FIELD = 'is_jetpack_form_preview';
40-
41-
/**
42-
* POST field name carrying the preview submission nonce.
43-
*
44-
* @var string
45-
*/
46-
const PREVIEW_SUBMIT_NONCE_FIELD = 'jetpack_form_preview_nonce';
47-
4826
/**
4927
* The query variable for form preview.
5028
*
@@ -317,6 +295,12 @@ function ( $title, $id = null ) use ( $form, $form_id ) {
317295
/**
318296
* Render the form preview content.
319297
*
298+
* The rendered markup flows through the normal block pipeline, which
299+
* embeds a signed JWT carrying the form's serialized source. Because
300+
* Feedback_Source::get_current() reads Form_Preview::is_preview_mode()
301+
* at render time, the JWT issued here travels to submission with
302+
* `is_test: true` baked into its source — no hidden nonce fields needed.
303+
*
320304
* @param WP_Post|null $form The form post.
321305
* @return string The rendered content.
322306
*/
@@ -333,87 +317,14 @@ private static function render_form_preview_content( ?WP_Post $form ) {
333317
$output .= '</div>';
334318

335319
// Parse and render the form blocks.
336-
$blocks = parse_blocks( $form->post_content );
337-
$rendered_forms = '';
320+
$blocks = parse_blocks( $form->post_content );
338321
foreach ( $blocks as $block ) {
339-
$rendered_forms .= render_block( $block );
322+
$output .= render_block( $block );
340323
}
341324

342-
// Inject the preview submission auth fields into every <form> in the rendered markup.
343-
$output .= self::inject_preview_submission_fields( $rendered_forms, (int) $form->ID );
344-
345325
return $output;
346326
}
347327

348-
/**
349-
* Inject hidden fields that authenticate the POST back to PHP as a preview submission.
350-
*
351-
* Adds a flag and a form-id-scoped nonce immediately before each `</form>` tag
352-
* in the given HTML. A leaked nonce only authorizes test submissions for the
353-
* specific form it was generated for.
354-
*
355-
* @param string $html The rendered form HTML.
356-
* @param int $form_id The form post ID the nonce should be scoped to.
357-
* @return string
358-
*/
359-
private static function inject_preview_submission_fields( $html, $form_id ) {
360-
if ( empty( $html ) || $form_id <= 0 ) {
361-
return $html;
362-
}
363-
364-
$nonce = wp_create_nonce( self::PREVIEW_SUBMIT_NONCE_ACTION . $form_id );
365-
366-
$hidden_fields = sprintf(
367-
'<input type="hidden" name="%1$s" value="1" /><input type="hidden" name="%2$s" value="%3$s" />',
368-
esc_attr( self::PREVIEW_SUBMIT_FIELD ),
369-
esc_attr( self::PREVIEW_SUBMIT_NONCE_FIELD ),
370-
esc_attr( $nonce )
371-
);
372-
373-
// Insert the hidden fields just before each closing </form> tag.
374-
return preg_replace( '#</form>#i', $hidden_fields . '</form>', $html );
375-
}
376-
377-
/**
378-
* Verify that the current POST request is an authenticated form preview submission.
379-
*
380-
* Requires the preview flag + nonce in POST, a logged-in user, and the user
381-
* to have `edit_post` capability on the form id whose nonce is presented.
382-
*
383-
* @param int $form_id The form post ID.
384-
* @return bool True when the submission is authorized to be marked as a test submission.
385-
*/
386-
public static function verify_preview_submission( $form_id ) {
387-
// phpcs:disable WordPress.Security.NonceVerification.Missing -- this method IS the nonce verification.
388-
if ( empty( $_POST[ self::PREVIEW_SUBMIT_FIELD ] ) ) {
389-
return false;
390-
}
391-
392-
$form_id = absint( $form_id );
393-
if ( $form_id <= 0 ) {
394-
return false;
395-
}
396-
397-
if ( ! is_user_logged_in() ) {
398-
return false;
399-
}
400-
401-
if ( ! current_user_can( 'edit_post', $form_id ) ) {
402-
return false;
403-
}
404-
405-
$nonce = isset( $_POST[ self::PREVIEW_SUBMIT_NONCE_FIELD ] )
406-
? sanitize_text_field( wp_unslash( $_POST[ self::PREVIEW_SUBMIT_NONCE_FIELD ] ) )
407-
: '';
408-
409-
if ( ! wp_verify_nonce( $nonce, self::PREVIEW_SUBMIT_NONCE_ACTION . $form_id ) ) {
410-
return false;
411-
}
412-
413-
return true;
414-
// phpcs:enable WordPress.Security.NonceVerification.Missing
415-
}
416-
417328
/**
418329
* Enqueue preview styles.
419330
*/
@@ -433,8 +344,6 @@ public static function enqueue_preview_styles() {
433344
* Add preview mode script variable.
434345
*/
435346
public static function add_preview_mode_script() {
436-
wp_print_inline_script_tag(
437-
'window.jetpackFormsPreviewMode = true; window.jetpackFormsPreviewAllowsSubmission = true;'
438-
);
347+
wp_print_inline_script_tag( 'window.jetpackFormsPreviewMode = true;' );
439348
}
440349
}

projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2700,6 +2700,94 @@ public function test_encode_form_to_jwt() {
27002700
Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' );
27012701
}
27022702

2703+
/**
2704+
* A JWT issued while Form_Preview::is_preview_mode() is active carries
2705+
* is_test=true inside its serialized source. After decode, the form's
2706+
* source should report is_test() === true, which is how
2707+
* process_form_submission() recognizes preview submissions.
2708+
*/
2709+
public function test_jwt_source_is_flagged_as_test_when_rendered_in_preview_mode() {
2710+
Constants::set_constant( 'JETPACK_BLOG_TOKEN', 'test.token' );
2711+
2712+
// Flip the Form_Preview static flag for the duration of this test so
2713+
// Feedback_Source::get_current() — invoked by get_jwt() — records the
2714+
// preview context in the serialized source.
2715+
$reflection = new \ReflectionClass( Form_Preview::class );
2716+
$preview_flag = $reflection->getProperty( 'is_preview_mode' );
2717+
$preview_flag->setAccessible( true );
2718+
$previous_value = $preview_flag->getValue();
2719+
$preview_flag->setValue( null, true );
2720+
2721+
try {
2722+
$form = new Contact_Form(
2723+
array(
2724+
'to' => 'preview@example.com',
2725+
'subject' => 'preview subject',
2726+
),
2727+
"[contact-field label='Name' type='name' required='1'/]"
2728+
);
2729+
2730+
$jwt = $form->get_jwt();
2731+
2732+
$decoded = Contact_Form::get_instance_from_jwt( $jwt );
2733+
2734+
$this->assertNotNull( $decoded, 'JWT should decode successfully.' );
2735+
$this->assertTrue(
2736+
$decoded->get_source()->is_test(),
2737+
'A JWT issued while preview mode was active should carry is_test=true in its source.'
2738+
);
2739+
} finally {
2740+
$preview_flag->setValue( null, $previous_value );
2741+
Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' );
2742+
}
2743+
}
2744+
2745+
/**
2746+
* Backward compatibility: a JWT issued before this feature shipped (or
2747+
* issued outside preview mode) has no is_test key in its source. After
2748+
* decode, the form's source should report is_test() === false, so the
2749+
* submission flows through the normal response pipeline. This matters
2750+
* because JWTs can live in cached HTML fragments across page loads.
2751+
*/
2752+
public function test_jwt_without_is_test_in_source_is_not_a_preview_submission() {
2753+
Constants::set_constant( 'JETPACK_BLOG_TOKEN', 'test.token' );
2754+
2755+
$form = new Contact_Form(
2756+
array(
2757+
'to' => 'normal@example.com',
2758+
'subject' => 'normal subject',
2759+
),
2760+
"[contact-field label='Name' type='name' required='1'/]"
2761+
);
2762+
2763+
$jwt = $form->get_jwt();
2764+
2765+
// Sanity-check the serialized JWT does not carry is_test. We read the
2766+
// unencrypted outer claims directly — implementation detail, but it
2767+
// documents the backward-compat contract.
2768+
$raw_parts = explode( '.', $jwt );
2769+
$raw_payload = $raw_parts[1] ?? '';
2770+
$decoded_json = base64_decode( strtr( $raw_payload, '-_', '+/' ), true );
2771+
$decoded_payload = $decoded_json === false ? null : json_decode( $decoded_json, true );
2772+
$this->assertIsArray( $decoded_payload );
2773+
$this->assertArrayHasKey( 'source', $decoded_payload );
2774+
$this->assertArrayNotHasKey(
2775+
'is_test',
2776+
$decoded_payload['source'],
2777+
'Outside preview mode the source must not include an is_test key so old cached JWTs stay compatible.'
2778+
);
2779+
2780+
$decoded = Contact_Form::get_instance_from_jwt( $jwt );
2781+
2782+
$this->assertNotNull( $decoded );
2783+
$this->assertFalse(
2784+
$decoded->get_source()->is_test(),
2785+
'A JWT without is_test in its source must decode to a regular (non-test) submission.'
2786+
);
2787+
2788+
Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' );
2789+
}
2790+
27032791
public function test_get_instance_from_jwt_uses_default_secret_when_no_token_secret() {
27042792
// Ensure JETPACK_BLOG_TOKEN is not defined, so default secret is used
27052793
Constants::clear_single_constant( 'JETPACK_BLOG_TOKEN' );

0 commit comments

Comments
 (0)