Orravo Spam Shield v1.0.0
Invisible spam protection for WordPress forms: honeypot fields, timing analysis, rate limiting, geo blocking, disposable email detection, and IP, email, and keyword blocklists. Works with Contact Form 7, WPForms, Gravity Forms, OForms, and Fluent Forms; no CAPTCHA, no SaaS dependency.
What Spam Shield does
CAPTCHA hurts conversion. Akismet sends every submission to a third party. Spam Shield does neither. It runs six invisible checks on every form submission, scores the result, and accepts, holds, or rejects based on your threshold, with zero friction for real humans.
oss_rate_log. Burst control with cool-down.oss_log with the per-layer score breakdown. Re-classify false positives from the inbox.Getting installed
Requirements
- WordPress 6.0 or newer
- PHP 8.0 or newer with the
mbstringextension - One of: Contact Form 7, WPForms, Gravity Forms, OForms, Fluent Forms (or no form plugin at all if you only need the REST API)
- Optional:
iconvfor the disposable-domain Punycode normaliser
From WordPress admin
orravo-spam-shield.zip, click Install Now, then Activate.oss_log (every submission decision), oss_rate_log (sliding-window rate counters), and oss_blocklist (IPs, emails, keywords). It also schedules a daily cron event (oss_refresh_disposable_domains) to keep the throwaway-email list up to date.Up and running in minutes
Defaults are sane. The plugin starts protecting forms the moment you activate it. The quick-start path is about confirming what is on and tuning the score threshold.
0.6 and the Block threshold is 0.85. Submissions above Block are rejected, between Spam and Block are flagged for review, below Spam are accepted silently.0.6 should be obvious spam. If you see false positives, mark them Not spam; the row's score components feed into the per-layer tuning recommendations.Defense layers
Each layer is independent. Each contributes a fractional score (0 to 1). The submission's total score is a weighted sum; weights are configurable.
The six layers
| Layer | Default weight | Trigger |
|---|---|---|
| Honeypot | 1.00 | Hidden oss_hp field is non-empty. Score 1.0, single-handedly trips Block. |
| Timing | 0.30 | HMAC-signed rendered-at timestamp shows the form was submitted in under 1.5s or after 90 minutes. |
| Rate | 0.20 | Same IP has submitted more than burst_limit times in the sliding window. Window is 5 minutes by default. |
| Geo | 0.40 | Submitter IP resolves to a country on the deny list (default off; you opt in to a country deny list). |
| Disposable email | 0.50 | Email's domain matches the disposable-domain list. |
| Blocklists | 1.00 each | IP, email, or keyword match. Any single blocklist match is treated as a hard block (score 1.0). |
Honeypot
Spam Shield injects a single hidden field, oss_hp, into every supported form. The field is wrapped in a position:absolute;left:-9999px div and labelled Leave this field empty (configurable). Bots fill it because they fill every input. Real users never see it.
If JavaScript is enabled, the field is additionally cleared on submit so even an autofill manager that pre-fills it will be neutralised. The server-side check still wins: a non-empty value is always a 1.0 honeypot score.
Timing
PHP// Hidden field added to each form on render
<input type="hidden" name="oss_ts"
value="1748716800.eyJ0Ijox..." />
// Server-side check on submit:
// 1. Decode payload, verify HMAC signature
// 2. Compute now() - rendered_at
// 3. If delta < min_seconds (default 1.5) -> bot-like
// If delta > max_seconds (default 5400) -> stale form
// 4. Otherwise: 0.0 contribution
Rate limiting
Implemented as a sliding-window counter in oss_rate_log. Two buckets are maintained per IP: one for the last 5 minutes (burst), one for the last hour (sustained). Burst limit defaults to 5; sustained limit defaults to 30. Once a limit is exceeded, the score contribution goes to 1.0 and the offending IP receives a 60-second cool-down (configurable).
PHP// Read current bucket counts
[ $burst, $sustained ] = OSS_Rate::counts_for_ip( '203.0.113.5' );
// Programmatic reset
OSS_Rate::reset( '203.0.113.5' );
Geo blocking
Spam Shield ships with the MaxMind GeoLite2 Country database (CC BY-SA 4.0). IP-to-country resolution happens in PHP via the bundled geoip2/geoip2 reader. No external API call. The database is refreshed monthly via the oss_refresh_geoip cron event (defaults off; opt in if you want auto-updates).
Two modes: deny list (default; block specific countries) or allow list (only listed countries can submit; everything else blocked). Allow list mode is for forms that should only accept submissions from a single country or region.
Disposable email
The disposable-domain list lives in oss_disposable_domains (~12,000 entries). It is refreshed daily from a community-maintained JSON feed; you can pin to a specific revision or disable auto-refresh entirely. The email's domain is normalised (Punycode-decoded, lower-cased, MX-less subdomains trimmed) before lookup.
PHP// Programmatic check
if ( OSS_Disposable::is_disposable( 'foo@10minutemail.com' ) ) {
// ...
}
// Add or remove a domain
OSS_Disposable::add( 'tempbox.net' );
OSS_Disposable::remove( 'mailinator.com' );
Scoring & decisions
Every submission is reduced to a single score in [0, 1]. Two thresholds split the score into three actions.
Decision tree
- 1Run every enabled layer. Each returns a
partial_scorein[0, 1]and a reason string. - 2Compute
total = sum(partial * weight) / sum(weight_of_triggered_layers). Untriggered layers do not dilute. - 3If
total >= block_threshold(default 0.85): Block. The form returns a generic validation error; the entry is not stored. - 4If
total >= spam_threshold(default 0.6): Spam. The submission is stored but flagged; notifications can be suppressed. - 5Otherwise: Clean. The submission flows through to the form plugin normally.
- 6The decision and per-layer scores are written to
oss_log.
Decision actions
| Outcome | What happens |
|---|---|
| Clean | Submission proceeds. No visible difference for the user. Logged with decision='clean'. |
| Spam | Submission is stored if the form plugin supports a Spam folder (CF7 Flamingo, WPForms, Gravity Forms). Notification emails are suppressed by default. Logged with decision='spam'. |
| Block | Submission is rejected before the form plugin sees it. The user sees a generic validation error. Logged with decision='block'. |
Layer weights
Weights are configurable under Spam Shield → Scoring → Weights. Setting a layer's weight to 0 effectively disables its contribution while keeping the layer running (useful for evaluating without blocking).
PHP// Filter weights at runtime
add_filter( 'oss_layer_weights', function( array $w ): array {
$w['rate'] = 0.50; // be aggressive about repeated submissions
$w['geo'] = 0.10; // be soft about geo (we run global outreach)
return $w;
} );
// Filter the final decision (last word)
add_filter( 'oss_decision', function( string $decision, array $context ): string {
// Always trust an authenticated user with edit_posts
if ( is_user_logged_in() && current_user_can( 'edit_posts' ) ) {
return 'clean';
}
return $decision;
}, 10, 2 );
Form integrations
Five integrations ship in the box. Each one is auto-detected; you do not toggle them on per form. Disable a specific integration under Spam Shield → Integrations if you do not want Shield to touch that form plugin.
Built-in
wpcf7_form_elements. Validation runs on wpcf7_validate before CF7's own.wpforms_process_before; spam entries land in WPForms' built-in Spam folder.gform_get_form_filter. Scored on gform_validation; spam status set via gform_entry_post_save.fluentform_before_insert_submission.Custom forms
PHP// Pipe a custom form through Spam Shield
$decision = OSS_Score::evaluate([
'form_id' => 'my-custom-form',
'fields' => $_POST, // raw input
'email' => $_POST['email'] ?? '',
'ip' => OSS_Helpers::client_ip(),
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'request_uri'=> $_SERVER['REQUEST_URI'] ?? '',
]);
if ( $decision['decision'] === 'block' ) {
wp_die( 'Submission blocked.', '', [ 'response' => 403 ] );
}
if ( $decision['decision'] === 'spam' ) {
// Quietly silence notifications; still store the entry
$is_spam = true;
}
// Log it (Spam Shield logs automatically when called via integrations;
// log manually for custom forms)
OSS_Log::insert( $decision );
Blocklists (IP, email, keyword)
Three blocklist types. Any single match is a hard block (score 1.0). Lists are stored in oss_blocklist, one row per entry, indexed by type.
Supported entry types
| Type | Format | Example |
|---|---|---|
ip | Single IPv4 / IPv6 or CIDR range | 203.0.113.5, 198.51.100.0/24, 2001:db8::/32 |
email | Exact address or wildcard at the start | spam@example.com, *@bad-domain.com |
keyword | Substring (case-insensitive) or regex (when wrapped in /.../) | buy viagra, /^[A-Z]{10,}$/ |
Auto-population
After three Block decisions for the same IP within an hour, Spam Shield can auto-add that IP to the IP blocklist with a 24-hour TTL. Disable auto-population under Blocklists → Behaviour if you want full manual control. Auto-added rows are labelled with source='auto' and a reason string so they are distinguishable from manual entries.
PHP// Programmatic
OSS_Blocklist::add( 'ip', '198.51.100.0/24', [ 'source' => 'manual', 'note' => 'sustained scraping' ] );
OSS_Blocklist::add( 'email', '*@throwaway.example' );
OSS_Blocklist::add( 'keyword', '/(viagra|cialis|tramadol)/i' );
// Add with TTL
OSS_Blocklist::add( 'ip', '203.0.113.5', [ 'expires_at' => time() + DAY_IN_SECONDS ] );
// Check
$blocked = OSS_Blocklist::matches([
'ip' => '203.0.113.5',
'email' => 'foo@example.com',
'body' => 'message body to scan for keyword matches',
]); // returns [ 'matched' => bool, 'type' => ..., 'rule' => ... ]
REST API & filters
All endpoints live under /wp-json/orravo-spam/v1/. Every endpoint requires manage_options; there are no public endpoints.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /log | Paginated activity log; filter by decision, form_id, date range. |
| POST | /log/{id}/reclassify | Mark a row as clean or spam; updates the row and trains the heuristic. |
| DELETE | /log/{id} | Delete a log row. |
| GET | /blocklist?type=ip | Read blocklist; filter by type. |
| POST | /blocklist | Add a blocklist entry. |
| DELETE | /blocklist/{id} | Remove an entry. |
| POST | /test | Run a payload through the scoring engine without logging; returns the full breakdown. |
| POST | /disposable/refresh | Force a refresh of the disposable-domain list. |
Test a payload
cURLcurl -X POST https://example.com/wp-json/orravo-spam/v1/test \
-H 'Content-Type: application/json' \
-H 'X-WP-Nonce: <rest-nonce>' \
-d '{
"email": "foo@10minutemail.com",
"ip": "203.0.113.5",
"body": "Buy viagra cheap online click here",
"user_agent": "Mozilla/5.0",
"rendered_at":1748716800
}'
# Response
{
"decision": "block",
"score": 0.92,
"layers": {
"honeypot": { "score": 0.0, "reason": "ok" },
"timing": { "score": 0.3, "reason": "submitted in 0.4s" },
"rate": { "score": 0.0, "reason": "ok" },
"geo": { "score": 0.0, "reason": "ok" },
"disposable": { "score": 1.0, "reason": "10minutemail.com" },
"blocklist": { "score": 1.0, "reason": "keyword: viagra" }
}
}
Filter reference
| Filter | Purpose |
|---|---|
oss_layer_weights | Adjust per-layer weights at runtime (return modified array). |
oss_spam_threshold | Override Spam threshold for this request (return float in [0, 1]). |
oss_block_threshold | Override Block threshold for this request. |
oss_decision | Last-word override on the final decision string. |
oss_should_score | Return false to skip scoring entirely for a given form / context. |
oss_client_ip | Override the resolved client IP (useful behind a reverse proxy not handled by core). |
oss_disposable_domains | Modify the disposable-domain list at runtime. |
oss_geo_country | Override resolved country for an IP (useful in tests). |
Frequently asked questions
OSS_Helpers::client_ip(), which checks CF-Connecting-IP, X-Real-IP, and X-Forwarded-For in that order, validates against your configured proxy allowlist, and falls back to REMOTE_ADDR. Set the allowlist under Settings → Trusted Proxies so spoofed headers from untrusted hops are ignored.oss_refresh_disposable_domains) fetches a community-maintained JSON feed and merges new entries. You can pin to a specific revision via Settings → Disposable → Pin version, or disable auto-refresh entirely. Manual adds always win against the auto list.geoip2/geoip2 reader. To keep the database fresh, opt into the monthly oss_refresh_geoip cron event under Settings → Geo; it downloads the latest MaxMind release. You can also disable Geo entirely to slim the install.oss_should_score filter lets you skip scoring for authenticated users with a specific role or capability. The Activity log indexes by form_id, so you can pivot on which forms produce the most spam and tune layers per form via the oss_layer_weights filter.uninstall.php, which drops all three tables.What's changed
- NewSix defense layers: honeypot, timing, rate, geo, disposable email, blocklists
- NewWeighted scoring with Spam and Block thresholds
- NewHMAC-signed timing field; configurable min and max seconds
- NewSliding-window rate limiter with per-IP burst and sustained buckets
- NewBundled MaxMind GeoLite2 Country DB; optional monthly auto-refresh
- NewDisposable-email list with daily auto-refresh and Punycode normalisation
- NewThree blocklist types: IP (with CIDR), email (with wildcards), keyword (with regex)
- NewAuto-population of IP blocklist with configurable TTL
- NewFive form integrations: CF7, WPForms, Gravity Forms, OForms, Fluent Forms
- NewActivity log with per-layer score breakdown and reclassification UI
- NewReverse-proxy-aware client-IP resolver with trusted-proxy allowlist
- NewPrivacy toggle: IP hashing, body capture opt-out
- NewREST API at
/wp-json/orravo-spam/v1/with a public-payload tester - NewFilter hooks for weights, thresholds, decisions, and disposable domains
- NewThree custom DB tables with full
uninstall.phpcleanup
Got a question about Spam Shield?
Reach out directly. Kenneth replies within 24 hours.

