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 );
+ }
+}