Paste-to-fill without trusting the browser: server-built field schemas and spam screening

How our WordPress plugin rebuilds every form's field schema on the server for CF7, WPForms and Gravity Forms, constrains and re-validates the AI's answer, and screens submissions without sending phone numbers or full emails.

DSDilawar Singh 10 min read 3 views
Paste-to-fill without trusting the browser: server-built field schemas and spam screening

Paste-to-fill sounds like a frontend feature. A visitor pastes an email signature into the message box, and the name, email, phone, company and city fields fill themselves. In our WordPress plugin, DEV3 AI Form Autofill & Spam Shield, most of the engineering went into the server instead. The paste-to-fill endpoint is public, it has no login and no nonce, and every request costs the site owner an AI call. The rule we settled on early was simple: the browser can send text, but it never gets to describe the form. This article covers how that rule is enforced across Contact Form 7, WPForms and Gravity Forms, how the same field schema drives spam screening at submit time, and what the tests check.

Why the browser cannot be the source of truth

The obvious design is to have the frontend script read the form's fields from the DOM, send them with the pasted text, and let the AI map one onto the other. It is easy to build and it works in a demo. It also means the server forwards to the AI whatever field list a client sends. A bot could invent fields ("admin password", "credit card number"), ask the AI to fill them from arbitrary text, and use the site as a free AI proxy at the owner's expense. Even honest clients get it wrong: hidden fields, honeypots and conditional fields all show up in the DOM.

So the public /parse route accepts exactly three parameters: a form key such as cf7:12, a token and the pasted text. Everything the AI needs to know about the form is rebuilt on the server from the stored form definition. The route's permission and context checks run before any AI call (condensed from Rest/Public_Controller.php):

private function context( \WP_REST_Request $request, $feature ) {
	$key   = (string) $request['form'];
	$check = Form_Token::verify( (string) $request['token'], $key );
	if ( is_wp_error( $check ) ) {
		return $check;
	}
	$resolved = Registry::resolve( $key );
	if ( ! $resolved ) {
		return new \WP_Error( 'aifas_unknown_form', /* ... */ array( 'status' => 404 ) );
	}
	list( $adapter, $form_id ) = $resolved;
	$config                    = $adapter->config( $form_id );
	if ( empty( $config[ $feature ] ) || '' === Settings_Store::api_key() ) {
		return new \WP_Error( 'aifas_disabled', /* ... */ array( 'status' => 403 ) );
	}
	$cap = (int) Settings_Store::get( 'daily_ai_cap', 2000 );
	if ( $cap > 0 && ! Rate_Limiter::hit( 'public_day', 'site', $cap, DAY_IN_SECONDS ) ) {
		return new \WP_Error( 'aifas_daily_cap', /* ... */ array( 'status' => 429 ) );
	}
	return array( $adapter, $form_id, $config );
}

Before that function runs, the route's permission_callback has already applied two checks from a shared core used across DEV3's plugins. It rejects any request whose Origin header names another host, and it applies a per-IP rate limit (10 parse requests per minute by default). Only after all of that does the handler call $adapter->get_fields( $form_id ).

A signed token that survives page caching

A WordPress nonce is the usual answer to "is this request from my page?", but nonces break on cached pages: a full-page cache serves the same nonce to every visitor for hours. We needed something stateless that still binds a request to one form. Form_Token is an HMAC over the form key and an expiry time, signed with the site's salt:

public static function create( $form_key, $now = null ) {
	$expires = ( null === $now ? time() : (int) $now ) + self::ttl();
	return $expires . '.' . self::sign( $form_key, $expires );
}

private static function sign( $form_key, $expires ) {
	return substr( hash_hmac( 'sha256', 'aifas|' . $form_key . '|' . $expires, wp_salt( 'nonce' ) ), 0, 32 );
}

verify() compares signatures with hash_equals and rejects expired tokens. The default lifetime is 12 hours (filter aifas_token_ttl), longer than typical cache lifetimes. If a cached page does serve an expired token, the frontend calls a rate-limited /token route once to get a fresh one and retries. The token does not prove the visitor is human. Its job is to stop a bot from pointing the AI at arbitrary form ids, and to bound replay to the TTL. The PHPUnit suite checks a missing, malformed, foreign and expired token separately, and asserts that none of them reached the AI.

One field schema for three builders

The three builders store forms in completely different ways. Contact Form 7 keeps a template of form-tags like [email* your-email] inside free HTML. WPForms stores a JSON document in post_content. Gravity Forms has field objects with sub-inputs such as 3.3 for a first name. Each adapter's only job is to turn its builder's definition into the same normalized shape:

/**
 * A field is { id, name, label, type, required, options[], role }:
 * - id: builder field id ("your-email", "3", "4.3"), unique per form;
 * - name: the input's HTML name attribute, so the browser can find it;
 * - type: text|email|phone|url|number|textarea|select|radio|checkbox;
 * - options: [ { value, label } ] for choice fields (value is what the browser submits);
 * - role: semantic hint (email, phone, first_name, company, city, category, message…).
 */

The CF7 adapter, for example, calls scan_form_tags(), skips non-input tags (submit, file, CAPTCHA, acceptance and others), and recovers labels from the surrounding <label> or the line before the tag. Multi-select checkboxes get a [] suffix on their name, because that is what the browser submits.

role is a semantic hint guessed from type, label and id (first_name, zip, company, category…) so the AI can distinguish "Your name" from "Company name". Field_Schema::source_field() then picks the field the visitor pastes into: the one configured in the builder panel, or else the first textarea. fill_targets() returns every other field. The source is never a target, so the AI cannot overwrite what the visitor just pasted.

Constraining the answer, then distrusting it anyway

The extractor sends the AI the pasted text, the form title and one line per target field (id, label, type, role and option labels). The pasted text is wrapped and marked as untrusted data. The response schema is built from the same targets, so the model can only return ids that exist:

'id'    => array(
	'type' => 'STRING',
	'enum' => array_values( array_map( 'strval', wp_list_pluck( $targets, 'id' ) ) ),
),
'value' => array(
	'type'      => 'STRING',
	'maxLength' => 400,
),

A schema narrows what the model can say, but the server still validates every value. Extractor::map() drops unknown ids and duplicates, then coerce() validates each value against its field type (condensed):

switch ( $field['type'] ) {
	case 'email':
		$email = sanitize_email( strtolower( $raw ) );
		return is_email( $email ) ? $email : null;
	case 'phone':
		$phone = preg_replace( '/[^\d+()\-.\s]/', '', $raw );
		return strlen( preg_replace( '/\D/', '', $phone ) ) >= 6 ? trim( $phone ) : null;
	case 'select':
	case 'radio':
		return Field_Schema::match_option( $field, $raw );
	// checkbox: each part must match an option; textarea/text: sanitized and length-capped
}

Values such as "N/A", "none" or "not provided" are discarded rather than written into the form. For a choice field, the answer has to match one of the field's own options by value or label (ignoring case, extra spaces and surrounding punctuation), and the server returns the canonical option value. Anything else is dropped.

The test that best captures the design is test_server_rebuilds_schema_and_maps_only_existing_fields_and_options. It fakes an AI response that tries everything at once: an invalid email, a duplicate category, an option that does not exist, an admin_password field, an attempt to overwrite the source message, an "N/A" city and HTML in the company name. The request body also carries a client-side fields array that declares the fake admin_password field. The response contains exactly five clean values. The test asserts that admin_password never appears in the prompt, that the source field is not in the enum, and that the checkbox field's name in the response (your-interests[]) comes from the server schema.

The frontend only fills what is empty

The browser script receives { id, name, type, value } items and writes them through applyFill(). Its rules are deliberately conservative:

  • It only writes into empty controls. A field the visitor already typed into is left alone.
  • It snapshots each control first, so Undo restores the exact previous state. Undo skips fields the visitor has edited since the fill.
  • Filled fields are highlighted and an inline notice says how many were filled ("Filled 6 fields from your text. Please check them.").
  • It never submits the form.
  • Phone values pass through the same local E.164 formatter as typed numbers.

That formatter is part of the corrections layer, which uses no AI at all. Email domains are checked against a list of popular providers with an edit-distance match plus explicit typo tables (gmial.com, hotmial.com, .con). Phone numbers are normalized to E.164 for a configurable default country (70+ country rules). Names are trimmed and their casing fixed. They run in the browser as the visitor types, so they cost nothing and work even without an API key.

In the live check with a real API key, a lowercase, run-on paragraph ("hey there!! this is maria gonzalez, ops lead @ Brightleaf Studios in Austin TX…") filled 6 fields: name, email, phone (normalized to +15125550147), company, city and the "Enquiry type" select, which was set to "Sales" from context.

The same schema guards the submit

Spam screening runs inside each builder's submission pipeline, and it reuses the server-built schema. Base_Adapter::gate() walks get_fields() and keeps only submitted values for known fields, with their labels, types and roles. The adapter's own hidden config input is excluded. Classifier::compose() then decides what the AI sees:

if ( 'phone' === $item['type'] || 'phone' === $item['role'] ) {
	continue;
}
if ( 'email' === $item['type'] || 'email' === $item['role'] ) {
	$at = strrpos( $value, '@' );
	if ( false === $at ) {
		continue;
	}
	$lines[] = 'Email domain: ' . strtolower( substr( $value, $at + 1 ) );
	continue;
}
$lines[] = $item['label'] . ': ' . $value;

Phone numbers are never sent. An email address becomes its domain, which is still a useful spam signal (seo-boost.example), without the personal part. No IP address, user agent or cookie is sent either. A PHPUnit test, test_what_is_sent_to_the_ai, checks the captured prompt: it contains Email domain: seo-boost.example but not the full address, and none of the phone digits.

The classifier returns a spam probability, an abusive flag, a sentiment and an ISO 639-1 language code, under a response schema with maxLength on every string. normalize() then clamps the numbers. Some answers come back as a percentage, so 95 is read as 0.95 and the result is clamped to 0–1. Unknown sentiments fall back to neutral. In the live check, a "boost your Google ranking to #1, cheap backlinks, crypto accepted" pitch scored 0.95 and a genuine enquiry scored 0.10. A message written in German was returned with the language de.

Spam_Gate turns the verdict into one of four actions, chosen per form, and each adapter maps that action onto its builder's own mechanism:

Action Contact Form 7 WPForms Gravity Forms
Block with a message wpcf7_before_send_mail aborts and sets the response form error in wpforms_process source field fails gform_validation
Mark as spam wpcf7_spam returns true with a spam log entry spam_reason set, notification suppressed gform_entry_is_spam
Flag only mail subject prefixed notification subject prefixed gform_notification subject prefixed

Using the builders' own hooks means their existing spam folders, entry storage and notifications keep working. Two cheaper checks run before the AI. More than 12 submissions per minute from one IP are treated as spam without spending an AI call, and a site-wide daily cap (2,000 by default) protects the API budget. When the cap is hit or the AI is unreachable, the configured failure policy decides. Fail-open (the default) delivers the submission and logs the outage; fail-closed blocks it with a message.

Only triggered submissions and AI outages are logged, as salted SHA-256 hashes plus a 140-character excerpt with emails, phone numbers and links redacted. Rows expire after 90 days by default.

Trade-offs

  • Gravity Forms is tested through stubs. The PHPUnit suite runs against real Contact Form 7 6.2.3 and WPForms Lite 2.0.3. Gravity Forms is a commercial plugin, so its adapter is built against the documented hooks and GFAPI and tested with stubs of them. We say so in the documentation.
  • Latency at submit time. The classification adds an AI round trip before the notification email (20-second timeout by default). We accepted that because screening after delivery would let spam reach the inbox first.
  • A token is not a CAPTCHA. It binds requests to a form and an expiry time; the per-IP limit and the daily cap do the rest. A determined bot on a real page can still make paste requests, but only for that form's real fields and within the limits.

Conclusion

Paste-to-fill and AI spam screening are both safe for one reason: the server owns the schema. Each adapter rebuilds the fields from the stored form, the AI's output is constrained to those fields and then validated again, and the submit-time gate sends the AI only labelled text with personal identifiers removed. The test numbers: 62 PHPUnit tests with 387 assertions, 56 Jest tests, and 16/16 frontend and 17/17 admin Puppeteer checks. The plugin is being prepared for release on CodeCanyon and the DEV3 marketplace. The case study covers the full architecture.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles