diff --git a/includes/class-evf-form-task.php b/includes/class-evf-form-task.php index 3c9cca5a7..e51e5152f 100644 --- a/includes/class-evf-form-task.php +++ b/includes/class-evf-form-task.php @@ -722,13 +722,24 @@ public function do_task( $entry ) { $logger->notice( sprintf( 'Everest Form Process After: %s', evf_print_r( $this->form_fields, true ) ) ); /** - * Apply smart tags to form fields values. + * Apply smart tags to form fields values. + * + * Resolved strictly from the field's own admin-configured default_value + * (the trusted form definition), never from the submitted value, so a + * crafted request body cannot inject arbitrary smart tags such as + * {post_meta key=...}. See https://github.com/themegrill/everest-forms/pull/1667. * * @since 3.2.3 */ foreach ( $this->form_fields as $key => $value ) { - if ( 'hidden' === $value['type'] && ! empty( $value['value'] ) && is_string( $value['value'] ) && strpos( $value['value'], '{' ) !== false ) { - $this->form_fields[ $key ]['value'] = apply_filters( 'everest_forms_process_smart_tags', $value['value'], $this->form_data, $this->form_fields ); + if ( 'hidden' !== $value['type'] ) { + continue; + } + + $default_value = isset( $this->form_data['form_fields'][ $key ]['default_value'] ) ? $this->form_data['form_fields'][ $key ]['default_value'] : ''; + + if ( is_string( $default_value ) && '' !== $default_value && strpos( $default_value, '{' ) !== false ) { + $this->form_fields[ $key ]['value'] = apply_filters( 'everest_forms_process_smart_tags', $default_value, $this->form_data, $this->form_fields ); } } @@ -1550,7 +1561,7 @@ public function entry_save( $fields, $entry, $form_id, $form_data = array() ) { // Create meta data. if ( $entry_id ) { - foreach ( $fields as $field ) { + foreach ( $fields as $field_key => $field ) { $field = apply_filters( 'everest_forms_entry_save_fields', $field, $form_data, $entry_id ); // Add only whitelisted fields to entry meta. if ( in_array( $field['type'], array( 'html', 'title' ), true ) ) { @@ -1578,8 +1589,15 @@ public function entry_save( $fields, $entry, $form_id, $form_data = array() ) { } if ( isset( $field['meta_key'], $field['value'] ) && '' !== $field['value'] ) { - if ( 'hidden' === $field['type'] && ! empty( $field['value'] ) && is_string( $field['value'] ) && strpos( $field['value'], '{' ) !== false ) { - $field['value'] = apply_filters( 'everest_forms_process_smart_tags', $field['value'], $form_data, $fields, $entry_id ); + // Resolved strictly from the field's own admin-configured default_value + // (never the submitted value) -- see the matching fix in do_task(). + if ( 'hidden' === $field['type'] ) { + $field_id = isset( $field['id'] ) ? $field['id'] : $field_key; + $default_value = isset( $form_data['form_fields'][ $field_id ]['default_value'] ) ? $form_data['form_fields'][ $field_id ]['default_value'] : ''; + + if ( is_string( $default_value ) && '' !== $default_value && strpos( $default_value, '{' ) !== false ) { + $field['value'] = apply_filters( 'everest_forms_process_smart_tags', $default_value, $form_data, $fields, $entry_id ); + } } $entry_metadata = array( 'entry_id' => $entry_id, diff --git a/includes/class-evf-smart-tags.php b/includes/class-evf-smart-tags.php index feafb8562..13b32fbf6 100644 --- a/includes/class-evf-smart-tags.php +++ b/includes/class-evf-smart-tags.php @@ -215,139 +215,16 @@ public function regex_expression_lists() { * @return string */ public function process( $content, $form_data, $fields = '', $entry_id = '' ) { - // Field smart tags (settings, etc). - preg_match_all( '/\{field_id="(.+?)"\}/', $content, $ids ); - - // We can only process field smart tags if we have $fields. - if ( ! empty( $ids[1] ) && ! empty( $fields ) ) { - - foreach ( $ids[1] as $key => $field_id ) { - $mixed_field_id = explode( '_', $field_id ); - $uploads = wp_upload_dir(); - - if ( count( $mixed_field_id ) > 1 && 'fullname' !== $field_id && 'email' !== $field_id && 'subject' !== $field_id && 'message' !== $field_id ) { - $value = isset( $fields[ $mixed_field_id[1] ]['value'] ) && ! empty( $fields[ $mixed_field_id[1] ]['value'] ) ? evf_sanitize_textarea_field( $fields[ $mixed_field_id[1] ]['value'] ) : ''; - } else { - $value = isset( $fields[ $field_id ]['value'] ) && ! empty( $fields[ $field_id ]['value'] ) ? evf_sanitize_textarea_field( $fields[ $field_id ]['value'] ) : ''; - } - - $value = apply_filters( 'everest_forms_smart_tags_value', $value, $field_id, $fields, $form_data ); - - if ( count( $mixed_field_id ) > 1 && ! empty( $fields[ $mixed_field_id[1] ] ) ) { - // Properly display signature field in smart tag. - if ( 'signature' === $fields[ $mixed_field_id[1] ]['type'] ) { - if ( ! is_array( $value ) && false !== strpos( $value, $uploads['basedir'] ) ) { - $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $value ); - } - - if ( ! empty( $value ) ) { - $styles = array( - 'width' => '150px', - 'height' => '80px', - 'max-width' => '100px', - 'max-height' => '200px', - ); - - $styles = apply_filters( 'everest_forms_export_signature_style', $styles, $fields[ $mixed_field_id[1] ] ); - - // Convert styles array to inline style string - $style_string = ''; - foreach ( $styles as $key => $val ) { - $style_string .= $key . ':' . $val . ';'; - } - - $value = sprintf( - '', - esc_url( $value ), - esc_attr( $style_string ) - ); - } - } - - // Properly display Radio field in smart tag. - if ( isset( $value['image'] ) && 'radio' === $fields[ $mixed_field_id[1] ]['type'] ) { - if ( ! is_array( $value ) && false !== strpos( $value['image'], $uploads['basedir'] ) ) { - $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $value['image'] ); - } - - if ( ! empty( $value ) ) { - $value = sprintf( - "\n" . '' . "\n" . '%s', - $value['image'], - $value['label'] - ); - } - } - - // Properly display Checkboxes field in smart tag. - if ( isset( $value['images'] ) && ( 'checkbox' === $fields[ $mixed_field_id[1] ]['type'] ) ) { - $checkbox_images = ''; - foreach ( $value['images'] as $image_key => $image_value ) { - if ( ! is_array( $image_value ) && false !== strpos( $image_value, $uploads['basedir'] ) ) { - $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $image_value ); - } - - if ( ! empty( $value ) ) { - $checkbox_images .= sprintf( - "\n" . '' . "\n" . '%s', - $image_value, - $value['label'][ $image_key ] - ); - } - } - $value = $checkbox_images; - } - - // Properly display Files and Image Upload field in smart tag. - if ( 'image-upload' === $fields[ $mixed_field_id[1] ]['type'] || 'file-upload' === $fields[ $mixed_field_id[1] ]['type'] ) { - $files = ''; - - if ( ! empty( $fields[ $mixed_field_id[1] ]['value_raw'] ) ) { - foreach ( $fields[ $mixed_field_id[1] ]['value_raw'] as $files_key => $files_value ) { - if ( ! is_array( $files_value['value'] ) && false !== strpos( $files_value['value'], $uploads['basedir'] ) ) { - $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $files_value['value'] ); - } - - if ( ! empty( $value ) ) { - $files .= sprintf( - apply_filters( - 'everest_forms_smart_tags_file_upload', - '%s' . "\n", - $files_value - ), - esc_url( $files_value['value'] ), - esc_html( $files_value['name'] ) - ); - } - } - $value = $files; - } - } - } - - if ( ! is_array( $value ) ) { - $content = str_replace( '{field_id="' . $field_id . '"}', $value, $content ); - } else { - if ( isset( $value['type'], $value['label'] ) ) { - if ( in_array( $value['type'], array( 'radio', 'payment-multiple' ), true ) ) { - $value = $value['label']; - } elseif ( in_array( $value['type'], array( 'checkbox', 'payment-checkbox' ), true ) ) { - $value = implode( ',', $value['label'] ); - - } - } elseif ( isset( $value['number_of_rating'], $value['value'] ) ) { - $value = (string) $value['value'] . '/' . (string) $value['number_of_rating']; - } elseif ( isset( $value['type'], $value['country_code'] ) && 'country' === $value['type'] ) { - $value = $value['country_code']; - } else { - $value = $value[0]; - } - - $content = str_replace( '{field_id="' . $field_id . '"}', $value, $content ); - } - } - } - + /** + * Other smart tags (post_meta, user_meta, admin_email, etc.) are resolved + * first, against the original admin-authored $content, and {field_id="..."} + * placeholders are substituted last. If field values were merged in first, + * a submitter typing tag syntax (e.g. {post_meta key=...}) into an ordinary + * field referenced by a {field_id} smart tag would get it re-resolved by + * the pass below, letting an unauthenticated submitter read arbitrary + * server-side data back through any notification/confirmation that embeds + * that field. See https://github.com/themegrill/everest-forms/pull/1667. + */ // Other Smart tags. preg_match_all( '/\{(.+?)\}/', $content, $other_tags ); if ( ! empty( $other_tags[1] ) ) { @@ -581,6 +458,139 @@ public function process( $content, $form_data, $fields = '', $entry_id = '' ) { } } + // Field smart tags (settings, etc). + preg_match_all( '/\{field_id="(.+?)"\}/', $content, $ids ); + + // We can only process field smart tags if we have $fields. + if ( ! empty( $ids[1] ) && ! empty( $fields ) ) { + + foreach ( $ids[1] as $key => $field_id ) { + $mixed_field_id = explode( '_', $field_id ); + $uploads = wp_upload_dir(); + + if ( count( $mixed_field_id ) > 1 && 'fullname' !== $field_id && 'email' !== $field_id && 'subject' !== $field_id && 'message' !== $field_id ) { + $value = isset( $fields[ $mixed_field_id[1] ]['value'] ) && ! empty( $fields[ $mixed_field_id[1] ]['value'] ) ? evf_sanitize_textarea_field( $fields[ $mixed_field_id[1] ]['value'] ) : ''; + } else { + $value = isset( $fields[ $field_id ]['value'] ) && ! empty( $fields[ $field_id ]['value'] ) ? evf_sanitize_textarea_field( $fields[ $field_id ]['value'] ) : ''; + } + + $value = apply_filters( 'everest_forms_smart_tags_value', $value, $field_id, $fields, $form_data ); + + if ( count( $mixed_field_id ) > 1 && ! empty( $fields[ $mixed_field_id[1] ] ) ) { + // Properly display signature field in smart tag. + if ( 'signature' === $fields[ $mixed_field_id[1] ]['type'] ) { + if ( ! is_array( $value ) && false !== strpos( $value, $uploads['basedir'] ) ) { + $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $value ); + } + + if ( ! empty( $value ) ) { + $styles = array( + 'width' => '150px', + 'height' => '80px', + 'max-width' => '100px', + 'max-height' => '200px', + ); + + $styles = apply_filters( 'everest_forms_export_signature_style', $styles, $fields[ $mixed_field_id[1] ] ); + + // Convert styles array to inline style string + $style_string = ''; + foreach ( $styles as $key => $val ) { + $style_string .= $key . ':' . $val . ';'; + } + + $value = sprintf( + '', + esc_url( $value ), + esc_attr( $style_string ) + ); + } + } + + // Properly display Radio field in smart tag. + if ( isset( $value['image'] ) && 'radio' === $fields[ $mixed_field_id[1] ]['type'] ) { + if ( ! is_array( $value ) && false !== strpos( $value['image'], $uploads['basedir'] ) ) { + $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $value['image'] ); + } + + if ( ! empty( $value ) ) { + $value = sprintf( + "\n" . '' . "\n" . '%s', + $value['image'], + $value['label'] + ); + } + } + + // Properly display Checkboxes field in smart tag. + if ( isset( $value['images'] ) && ( 'checkbox' === $fields[ $mixed_field_id[1] ]['type'] ) ) { + $checkbox_images = ''; + foreach ( $value['images'] as $image_key => $image_value ) { + if ( ! is_array( $image_value ) && false !== strpos( $image_value, $uploads['basedir'] ) ) { + $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $image_value ); + } + + if ( ! empty( $value ) ) { + $checkbox_images .= sprintf( + "\n" . '' . "\n" . '%s', + $image_value, + $value['label'][ $image_key ] + ); + } + } + $value = $checkbox_images; + } + + // Properly display Files and Image Upload field in smart tag. + if ( 'image-upload' === $fields[ $mixed_field_id[1] ]['type'] || 'file-upload' === $fields[ $mixed_field_id[1] ]['type'] ) { + $files = ''; + + if ( ! empty( $fields[ $mixed_field_id[1] ]['value_raw'] ) ) { + foreach ( $fields[ $mixed_field_id[1] ]['value_raw'] as $files_key => $files_value ) { + if ( ! is_array( $files_value['value'] ) && false !== strpos( $files_value['value'], $uploads['basedir'] ) ) { + $value = trailingslashit( content_url() ) . str_replace( str_replace( 'uploads', '', $uploads['basedir'] ), '', $files_value['value'] ); + } + + if ( ! empty( $value ) ) { + $files .= sprintf( + apply_filters( + 'everest_forms_smart_tags_file_upload', + '%s' . "\n", + $files_value + ), + esc_url( $files_value['value'] ), + esc_html( $files_value['name'] ) + ); + } + } + $value = $files; + } + } + } + + if ( ! is_array( $value ) ) { + $content = str_replace( '{field_id="' . $field_id . '"}', $value, $content ); + } else { + if ( isset( $value['type'], $value['label'] ) ) { + if ( in_array( $value['type'], array( 'radio', 'payment-multiple' ), true ) ) { + $value = $value['label']; + } elseif ( in_array( $value['type'], array( 'checkbox', 'payment-checkbox' ), true ) ) { + $value = implode( ',', $value['label'] ); + + } + } elseif ( isset( $value['number_of_rating'], $value['value'] ) ) { + $value = (string) $value['value'] . '/' . (string) $value['number_of_rating']; + } elseif ( isset( $value['type'], $value['country_code'] ) && 'country' === $value['type'] ) { + $value = $value['country_code']; + } else { + $value = $value[0]; + } + + $content = str_replace( '{field_id="' . $field_id . '"}', $value, $content ); + } + } + } + return $content; } } diff --git a/includes/evf-core-functions.php b/includes/evf-core-functions.php index 89440134f..07c0e42f1 100644 --- a/includes/evf-core-functions.php +++ b/includes/evf-core-functions.php @@ -1295,6 +1295,7 @@ function evf_get_browser() $bname = 'Unknown'; $platform = 'Unknown'; $version = ''; + $ub = 'other'; // First get the platform. if (preg_match('/linux/i', $u_agent)) { @@ -1341,13 +1342,15 @@ function evf_get_browser() // See how many we have. $i = count($matches['browser']); - if (1 !== $i) { + if (0 === $i) { + $version = ''; + } elseif (1 !== $i) { // we will have two since we are not using 'other' argument yet. // see if version is before or after the name. if (strripos($u_agent, 'Version') < strripos($u_agent, $ub)) { $version = $matches['version'][0]; } else { - $version = $matches['version'][1]; + $version = isset($matches['version'][1]) ? $matches['version'][1] : $matches['version'][0]; } } else { $version = $matches['version'][0]; diff --git a/tests/e2e/specs/submission/field-value-smart-tag-not-reresolved.spec.ts b/tests/e2e/specs/submission/field-value-smart-tag-not-reresolved.spec.ts new file mode 100644 index 000000000..4306b237b --- /dev/null +++ b/tests/e2e/specs/submission/field-value-smart-tag-not-reresolved.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from '../../fixtures/published-form'; + +/** + * Wordfence-reported unauthenticated smart-tag injection (Everest Forms + * <= 3.6.1): a visitor's own submitted value could be re-resolved as a smart + * tag. The original fix (#1667) only closed the Hidden-field post-submission + * path. This spec guards a separate bypass found and fixed alongside it + * (PR #1669): `{field_id="X"}` — used in an admin-authored redirect query + * string to embed a submitted field's value, per the builder's own example + * placeholder (`firstname={field_id="..."}`) — was substituted in *before* + * the generic smart-tag scan ran, so a tag typed into any ordinary visible + * field referenced that way still got re-resolved. + * + * `{admin_email}` is the payload here rather than `{post_meta key=...}` + * because it needs no test-data setup (no target post/meta to seed) and is + * still real site data an anonymous visitor has no business extracting + * through form input. + * + * Reuses the same "External URL + Append Query String" mechanism as + * `external-redirect-query-params.spec.ts` — only the query string's value + * differs (a smart tag instead of a plain string). + * + * Does not cover the Hidden-field default_value path (#1667's original fix) + * — that is guarded at the unit level in + * tests/phpunit/includes/class-evf-smart-tags-security-test.php, since it + * needs a Hidden field with a specific default_value and this suite has no + * builder-driven fixture for adding a custom field yet. + */ + +test.setTimeout(150_000); + +const SETTINGS_TAB = (formId: string) => + `/wp-admin/admin.php?page=evf-builder&tab=settings&form_id=${formId}`; +const FIELDS_TAB = (formId: string) => + `/wp-admin/admin.php?page=evf-builder&tab=fields&form_id=${formId}`; + +/** + * @area submission + * @tier fresh + * @guards wordfence-evf-3.6.1-smart-tag-disclosure + * @source verify-fix 2026-09-29 — https://github.com/themegrill/everest-forms/pull/1669 + * @why A visitor-typed smart tag embedded into an admin-authored redirect + * query string via {field_id} must stay literal text. Proves the + * resolution-order fix in EVF_Smart_Tags::process(); does not assert + * anything about the Hidden-field default_value path, which is + * unit-tested separately. + */ +test('a smart tag typed into a field is not resolved when echoed via {field_id} in a redirect query string @fresh @submission', async ({ + page, + browser, + publishedForm, +}) => { + // --- find a text field's id from the builder ----------------------------- + await page.goto(FIELDS_TAB(publishedForm.formId), { waitUntil: 'domcontentloaded' }); + + const targetField = page.locator('.everest-forms-field[data-field-type="text"]').first(); + await expect( + targetField, + 'No text field on the fixture form to target — the template this suite builds ' + + 'from must have changed.', + ).toBeVisible({ timeout: 30_000 }); + const fieldId = await targetField.getAttribute('data-field-id'); + expect(fieldId, 'The text field has no data-field-id.').toBeTruthy(); + + // --- configure an External URL redirect with the payload in the query ---- + const origin = new URL(publishedForm.url).origin; + + await page.goto(SETTINGS_TAB(publishedForm.formId), { waitUntil: 'domcontentloaded' }); + await page + .locator('.everest-forms-panel-sidebar a') + .filter({ hasText: /^Confirmation$/ }) + .first() + .click(); + + const externalUrlRadio = page.locator('#everest-forms-panel-field-settings-redirect_to-3'); + await expect( + externalUrlRadio, + 'No "Redirect to External URL" option in the Confirmation settings.', + ).toBeAttached({ timeout: 20_000 }); + await externalUrlRadio.check({ force: true }); + + const urlField = page.locator('#everest-forms-panel-field-settings-external_url'); + await expect( + urlField, + 'Choosing "Redirect to External URL" did not reveal the URL field.', + ).toBeVisible({ timeout: 15_000 }); + await urlField.fill(`${origin}/`); + + const appendQueryToggle = page.locator( + '#everest-forms-panel-field-settings-enable_redirect_query_string', + ); + await expect( + appendQueryToggle, + 'No "Append Query String" toggle in the Confirmation settings.', + ).toBeAttached({ timeout: 15_000 }); + await appendQueryToggle.check({ force: true }); + + const queryStringField = page.locator('#everest-forms-panel-field-settings-query_string'); + await expect( + queryStringField, + 'Enabling "Append Query String" did not reveal the Query String field.', + ).toBeVisible({ timeout: 15_000 }); + // Matches the builder's own documented syntax for this field + // (placeholder example: `firstname={field_id="..."}`). + await queryStringField.fill(`payload={field_id="${fieldId}"}`); + + await page.getByRole('button', { name: 'Save', exact: true }).first().click(); + await page.waitForLoadState('networkidle').catch(() => {}); + + // --- submit as a visitor, with the payload in the targeted field ---------- + const visitor = await browser.newContext(); + try { + const visitorPage = await visitor.newPage(); + await visitorPage.goto(publishedForm.url, { waitUntil: 'domcontentloaded' }); + + const form = visitorPage.locator(`form#evf-form-${publishedForm.formId}`); + await expect(form, 'The form did not render for a visitor.').toBeVisible({ timeout: 30_000 }); + + // Fill every required field normally first, so validation does not block + // the submission, then overwrite the one we are attacking. + const textInputs = form.locator('input[type="text"]:visible'); + const textCount = await textInputs.count(); + for (let i = 0; i < textCount; i++) { + await textInputs.nth(i).fill(`filler-${i}`); + } + const emailInput = form.locator('input[type="email"]:visible'); + if ((await emailInput.count()) > 0) { + await emailInput.first().fill('qa-visitor@example.com'); + } + const textarea = form.locator('textarea:visible'); + if ((await textarea.count()) > 0) { + await textarea.first().fill('filler message'); + } + + const targetInput = form.locator(`[name="everest_forms[form_fields][${fieldId}]"]`); + await expect( + targetInput, + `No input named for field ${fieldId} on the rendered form.`, + ).toBeVisible({ timeout: 15_000 }); + await targetInput.fill('{admin_email}'); + + await form.locator('.everest-forms-submit-button').click(); + + // The redirect is driven by `window.location`, so wait for the URL to + // become the target rather than for a load event on the form's own page. + await visitorPage.waitForURL(/[?&]payload=/, { timeout: 45_000 }); + + const landed = new URL(visitorPage.url()); + + expect( + landed.searchParams.get('payload'), + `Redirected to ${landed.href}. A visitor-typed smart tag ({admin_email}) in the ` + + 'field was re-resolved into the real admin email instead of being kept as the ' + + 'literal text the visitor typed.', + ).toBe('{admin_email}'); + } finally { + await visitor.close(); + } +}); diff --git a/tests/phpunit/includes/class-evf-smart-tags-security-test.php b/tests/phpunit/includes/class-evf-smart-tags-security-test.php new file mode 100644 index 000000000..7fa79541e --- /dev/null +++ b/tests/phpunit/includes/class-evf-smart-tags-security-test.php @@ -0,0 +1,242 @@ +post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $this->post_id, 'mailchimp_api_key', 'mc-LIVE-SECRET-VALUE' ); + update_post_meta( $this->post_id, '_protected_secret', 'should-never-leak' ); + + $this->form_id = $this->factory->post->create( + array( + 'post_type' => 'everest_form', + 'post_status' => 'publish', + 'post_title' => 'Smart Tag Security Test Form', + ) + ); + + $this->form_data = array( + 'id' => $this->form_id, + 'form_fields' => array( + '1' => array( + 'id' => '1', + 'type' => 'hidden', + 'label' => 'UTM Source', + 'meta-key' => 'utm_source', + 'default_value' => 'organic', + ), + '2' => array( + 'id' => '2', + 'type' => 'text', + 'label' => 'Message', + 'meta-key' => 'message', + ), + ), + 'settings' => array( + 'form_title' => 'Smart Tag Security Test Form', + 'ajax_form_submission' => '0', + ), + ); + + $this->save_form_data(); + } + + /** + * Tear down test. + */ + public function tearDown() { + wp_delete_post( $this->post_id, true ); + wp_delete_post( $this->form_id, true ); + $_POST = array(); + parent::tearDown(); + } + + /** + * Persists $this->form_data as the form's post_content, exactly as the + * builder would save it. + */ + protected function save_form_data() { + wp_update_post( + array( + 'ID' => $this->form_id, + 'post_content' => evf_encode( $this->form_data ), + ) + ); + } + + /** + * Submits the form through the real production entry point -- + * EVF_Form_Task::do_task(), which on success also calls entry_save() -- + * using the process-wide evf()->task singleton so that field-type + * validate()/format() hooks (which write to evf()->task->form_fields, + * not to an arbitrary instance) land on the same object this test reads + * back from. + * + * @param array $submitted_fields Field id => submitted value. + * @return EVF_Form_Task + */ + protected function submit( $submitted_fields ) { + $_POST[ '_wpnonce' . $this->form_id ] = wp_create_nonce( 'everest-forms_process_submit' ); + + $task = evf()->task; + $task->do_task( + array( + 'id' => $this->form_id, + 'form_fields' => $submitted_fields, + ) + ); + + return $task; + } + + /** + * Reads a persisted entry meta value back from the database, the way + * entry_save() actually stored it. + * + * @param int $entry_id Entry id. + * @param string $meta_key Meta key. + * @return string|null + */ + protected function get_entry_meta_value( $entry_id, $meta_key ) { + global $wpdb; + + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->prefix}evf_entrymeta WHERE entry_id = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $entry_id, + $meta_key + ) + ); + + return null === $value ? null : maybe_unserialize( $value ); + } + + /** + * An attacker POSTing a smart tag directly into a Hidden field's value + * must never have it resolved -- only the field's own trusted + * default_value may ever reach the smart tag parser. Exercises the real + * do_task() -> entry_save() path, not a copy of its logic. + */ + public function test_hidden_field_post_payload_is_not_resolved() { + $task = $this->submit( + array( + '1' => '{post_meta key=mailchimp_api_key}', + '2' => 'hello', + ) + ); + + $this->assertEmpty( $task->errors, 'Submission unexpectedly failed validation: ' . wp_json_encode( $task->errors ) ); + $this->assertSame( '{post_meta key=mailchimp_api_key}', $task->form_fields['1']['value'] ); + $this->assertStringNotContainsString( 'mc-LIVE-SECRET-VALUE', $task->form_fields['1']['value'] ); + + // entry_save() persisted the same, unresolved value -- not the secret. + $this->assertGreaterThan( 0, $task->entry_id ); + $stored = $this->get_entry_meta_value( $task->entry_id, 'utm_source' ); + $this->assertSame( '{post_meta key=mailchimp_api_key}', $stored ); + } + + /** + * A hidden field whose default_value legitimately contains a smart tag + * (e.g. referencing another submitted field) must still resolve, end to + * end through do_task() -> entry_save(). + */ + public function test_hidden_field_trusted_default_value_still_resolves() { + $this->form_data['form_fields']['1']['default_value'] = 'ref: {field_id="2"}'; + $this->save_form_data(); + + $task = $this->submit( + array( + '1' => 'ref: {field_id="2"}', // as rendered/submitted verbatim. + '2' => 'user typed this', + ) + ); + + $this->assertEmpty( $task->errors, 'Submission unexpectedly failed validation: ' . wp_json_encode( $task->errors ) ); + $this->assertSame( 'ref: user typed this', $task->form_fields['1']['value'] ); + + $this->assertGreaterThan( 0, $task->entry_id ); + $stored = $this->get_entry_meta_value( $task->entry_id, 'utm_source' ); + $this->assertSame( 'ref: user typed this', $stored ); + } + + /** + * Bypass vector: a user types a smart tag into an ordinary visible + * field, and an admin template embeds that field via {field_id="X"}. + * The embedded value must not be re-scanned for smart tags. + */ + public function test_field_id_embed_of_user_value_is_not_re_resolved() { + $smart_tags = new EVF_Smart_Tags(); + + $fields = array( + '2' => array( + 'id' => '2', + 'type' => 'text', + 'value' => '{post_meta key=mailchimp_api_key}', + ), + ); + + $result = $smart_tags->process( 'You said: {field_id="2"}', $this->form_data, $fields, 123 ); + + $this->assertSame( 'You said: {post_meta key=mailchimp_api_key}', $result ); + $this->assertStringNotContainsString( 'mc-LIVE-SECRET-VALUE', $result ); + } + + /** + * No regression: a legitimate admin-authored {post_meta} tag in a + * trusted template (not sourced from a submitted field) still resolves. + */ + public function test_trusted_post_meta_tag_still_resolves() { + $smart_tags = new EVF_Smart_Tags(); + + $result = $smart_tags->process( 'Key is: {post_meta key=mailchimp_api_key}', $this->form_data, array(), 123 ); + + $this->assertSame( 'Key is: mc-LIVE-SECRET-VALUE', $result ); + } + + /** + * Protected (underscore-prefixed) meta keys stay blocked. + */ + public function test_protected_meta_key_stays_blocked() { + $smart_tags = new EVF_Smart_Tags(); + + $result = $smart_tags->process( 'Key is: {post_meta key=_protected_secret}', $this->form_data, array(), 123 ); + + $this->assertStringNotContainsString( 'should-never-leak', $result ); + } +}