=== M Security ===
Contributors: majevski
Author: Vitold Majevski
Author URI: https://majevski.com
Plugin URI: https://majevski.com/m-security
Tags: security, brute force, login protection, spam, geoip
Requires at least: 6.5
Tested up to: 7.0
Requires PHP: 8.1
Stable tag: 1.8.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Lightweight brute-force login protection, country-based login access, and spam defense. No ads, no upsells, and the optional shared threat feed is off by default.

== Description ==

M Security protects any WordPress site from brute-force login attacks and spam — with sane defaults that work the moment you activate it, even while the site is in maintenance mode. Everything runs locally by default: no cloud accounts, no phone-home, no tracking. An optional shared threat feed can be switched on, and stays off until you do.

**Brute-force protection**

* Counts failed logins per IP *and* per username across wp-login.php, XML-RPC, and application passwords
* Escalating lockouts: 4 retries → 20-minute lockout, doubling on repeat offenders (capped at 24 hours)
* Instant permanent IP ban for anyone who tries to log in with a decoy username you define (e.g. admin, administrator, root)
* Optional instant permanent ban for XML-RPC probes and ?author=N username scans
* Optional automatic sharing of every ban with the central blacklist, so other member sites block the address in advance
* Locked-out bots receive a completely blank 403 page — no login form, nothing to attack
* Generic error messages that never reveal whether the username or password was wrong
* Password-reset throttling
* Optional email notification after repeated lockouts

**Country rules (GeoIP)**

* Allow only specific countries to access the login page — everyone else gets a blank page
* Block registration, comments, and form submissions from disallowed countries
* Three database sources: upload your own MaxMind .mmdb file, auto-download GeoLite2 with your free MaxMind key (refreshed twice weekly), or the account-free DB-IP Country Lite (refreshed monthly)
* Fails open by default: if the country cannot be determined, visitors are never wrongly locked out — rate limiting still applies

**Spam defense**

* Invisible honeypot, minimum-submit-time, and JavaScript checks on comments — no CAPTCHAs, no third-party services
* Registration protection: honeypot, disposable-email blocklist (8,000+ domains), and MX record verification against fake addresses
* Automatic spam scoring for Contact Form 7, WPForms, and Gravity Forms submissions
* Invisible honeypot fields added to front-end POST forms to trap automated bots

**Hardening**

* Force SSL: redirect every plain-HTTP request to HTTPS — verified against the live site before it is enabled, so a missing certificate can never lock you out
* 404 blocking: exploit scanners trigger far more not-found errors than real visitors; set a threshold and lockout duration and repeat offenders are locked out of the whole site
* Disable XML-RPC (including multicall brute-force amplification)
* Block username enumeration: ?author= scans, REST API user listing, users sitemap, oEmbed author data
* Hide the WordPress version — generator tag, feeds, and enqueued asset URLs
* Disable directory browsing and prevent PHP execution in the uploads folder (marked .htaccess rules, self-tested with automatic rollback)
* Disable the built-in plugin and theme file editors
* Optionally disable application passwords

**Shared threat feed (optional, off by default)**

* Connects to M Blacklist, a community blocklist of IP addresses, ranges, and hashed email addresses reported by other M Security sites
* A background job downloads the feed into a local table; every check reads that local copy, so a normal page view never waits for the network and an outage can never block your visitors — the module always fails open
* Optionally contribute your own blocks: permanent bans, lockouts, and spam verdicts are queued and sent in the background. Only an IP address (or a SHA-256 hashed email) plus a category is sent — never usernames, message contents, or plaintext email addresses
* Registration emails can be checked with k-anonymity: only the first five characters of the address hash ever leave your site, answers are cached for 24 hours, and the lookup times out after 3 seconds
* Reading the feed is anonymous and needs no account; only contributing reports requires an API key

**Logs & privacy**

* Every login attempt, lockout, block, and spam catch is logged with IP, country, gateway, and result
* One-click allowlist/denylist from any log row; CSV export
* GDPR-friendly IP storage modes: full, truncated, or hashed; automatic retention cleanup

== Installation ==

1. Upload the plugin to `/wp-content/plugins/m-security/` or install it from the Plugins screen.
2. Activate it. Brute-force protection, spam guards, and hardening are active immediately with safe defaults. Your own IP is allowlisted automatically.
3. Optional: open **M Security → Country rules**, add your allowed countries (e.g. `LT, DE, US`) and provide a GeoIP database — upload your own `.mmdb` file or let the plugin download one.

== Frequently Asked Questions ==

= Does it work while my site is in maintenance mode? =

Yes. Maintenance-mode plugins leave wp-login.php reachable, and M Security blocks attackers there before other plugins run.

= What is the "instant-ban usernames" feature? =

Under **Login protection → Instant-ban usernames**, list decoy names such as admin, administrator, or root (one per line). Nobody legitimate should ever log in with those, so any attempt to authenticate with one — over wp-login, XML-RPC, or an application password — permanently bans the attacker's IP and serves them a blank page from then on, across login, comments, registration, and forms. Never list a real account name. Your own IP is exempt while it is on the allowlist (added automatically on activation), and private/local addresses are never banned. Bans are stored efficiently (indexed, capped, and off the autoloaded settings), and you can review or clear them under **Access lists → Permanently banned IPs**. Note: on sites where many visitors share one public IP (corporate networks, some mobile carriers/CGNAT), one abuser can get that shared IP banned — keep that in mind before adding common decoys.

= The log shows thousands of blocked requests, but no IPs are banned. Why? =

Those are two different things. A **blocked** request is one that was refused — a probe hit xmlrpc.php, a scanner asked for ?author=1, a locked-out IP tried the login form. Each of those writes a log row and is thrown away. A **ban** persists an address so it is refused from then on.

By default only one thing creates a permanent ban: an attempt to log in with a name from your **Login protection → Instant-ban usernames** list. If that list is empty, nothing is ever banned, no matter how many requests get blocked.

To turn the traffic you are already blocking into bans, enable the instant-ban triggers:

* **Login protection** — fill in *Instant-ban usernames* (admin, administrator, root, support …) and keep *Ban IPs that use those usernames* on.
* **Hardening** — *Ban IPs that probe XML-RPC* and *Ban IPs that scan for usernames*.

Automatic bans do **not** appear in the *Denylist* textarea, which is for manual entries only. They are stored separately and listed under **Access lists → Permanently banned IPs**, with a count in the sidebar.

= I connected to the shared blacklist and the sync succeeded, but nothing of mine appears there. Why? =

Reading the feed is anonymous, so a pull succeeds even when pushing cannot work. Check three things, in this order:

1. **Is anything queued?** The Cloud panel shows a queued count. Reports are queued when a verdict happens, so bans made *before* you enabled sharing are not in it, and manual denylist entries never are. Press **Share existing bans & denylist** to queue everything this site already blocks.
2. **Is your domain verified on the service?** An unverified domain gets `403 site_unverified` for every report while the feed keeps downloading normally. Press **Test connection** — it now reports this explicitly. Verify the domain in the service admin panel (DNS TXT record or `.well-known` file).
3. **Does the key have the write scope?** A read-only key cannot contribute. The connection test reports this too.

The Cloud panel also shows the last sync error with a plain-language explanation.

= I locked myself out. What now? =

Add `define( 'MSC_DISABLE', true );` to wp-config.php via FTP/hosting file manager. The plugin stands down completely until you remove the line. Your own IP is also seeded into the allowlist on activation to prevent this in the first place.

= How do I get a MaxMind license key? =

Create a free account at maxmind.com, then go to *Manage license keys* and generate one. Enter the Account ID and key under **Country rules** and choose the MaxMind source; the database refreshes twice a week automatically. Alternatively, choose DB-IP Country Lite, which needs no account (attribution: [IP Geolocation by DB-IP](https://db-ip.com)).

= My site is behind Cloudflare or a proxy. Anything to configure? =

Yes — under **Country rules**, set the client IP header (e.g. `CF-Connecting-IP`) and list your proxy ranges as trusted. The header is only honored for requests that actually arrive from those ranges, so it cannot be spoofed.

= What is the shared threat feed, and does it slow anything down? =

Under **M Security → Cloud** you can connect the site to M Blacklist (https://black.majevski.com), a blocklist built from what other M Security installations actually block. It is disabled until you enable it. When enabled, a background job (default: every 6 hours) downloads new entries into a local database table; login, registration, and comment checks read only that local table, so no visitor ever waits for a remote server. If the service is down, misconfigured, or slow, the module fails open: checks simply return "not listed" and your local rules keep working exactly as before.

= What does my site send to the shared feed? =

Nothing at all unless you tick "Contribute my blocks" *and* enter an API key. With both in place, the events you already block — permanent bans, lockouts, honeypot and spam verdicts — are queued and sent in the background as an IP address (or a SHA-256 hash of an email address) plus a category such as `bruteforce` or `spam`. Usernames, comment text, form contents, and plaintext email addresses are never queued, stored, or transmitted. Your own server address and your own domain are never reported. Reading the feed needs no key and identifies nothing about your site.

= Is it GDPR compliant? =

IP addresses can be stored in full, truncated, or hashed form; log entries are purged automatically after the configured retention period; a personal-data eraser is registered. With the cloud module off — the default — nothing ever leaves your server; with it on, only the hashed or IP-level indicators described above are shared.

= Does it slow my site down? =

No. On normal front-end requests the plugin does almost nothing. Checks run only on login, registration, comment, and form submissions. Counters use their own indexed tables — no options-table bloat.

= Where do I get support? =

Write to pagalba@majevski.com or visit https://majevski.com — support is available in English and Lithuanian.

== Screenshots ==

1. Login protection settings with the status sidebar
2. Country rules and GeoIP database management
3. The security log with one-click allow/deny

== Changelog ==

= 1.8.2 =
* Fixed: the local mirror of the shared threat feed was slowly losing entries, and never catching up with the server. Sites kept a feed row for 30 days from the SERVER's last-updated time; the incremental sync only carries entries the server has touched since the previous sync, so a listed address the server had no reason to update again was pruned locally after a month and nothing ever brought it back. At the time of the fix, a fresh site synced 5,236 confirmed entries and immediately dropped 1,458 of them. Rows are now kept by when THIS site last received them, and once a week the whole feed is walked again: every entry still listed is refreshed, and only entries the feed no longer carries are dropped. In between, syncs stay incremental.
* Changed: the Cloud tab shows when the last full re-mirror completed.
* Note: the update triggers one full re-mirror on the next sync (six requests for the current feed size), which restores every entry the old pruning removed.

= 1.8.1 =
* Fixed: an update that could not be downloaded now says why. WordPress gives a package download five minutes to finish, which is longer than any host allows a single admin request to run, so when the connection to majevski.com stalled the request was cut off before WordPress could report anything and the Plugins screen showed only the browser's own "Connection lost or the server is busy". Downloads of this plugin's releases are now capped at sixty seconds — far more than a release archive needs, and short enough that the real transport error is reported on the screen instead of the request dying silently.
* New: a Site Health check under Tools → Site Health → Status, "M Security can reach its update server". It makes the same two calls the installer makes — the release manifest, then the package itself — and prints the exact error when either fails, so a blocked outbound connection is named as such instead of appearing as a failed update.

= 1.8.0 =
* New: Force SSL — every plain-HTTP request is redirected to HTTPS (301, or 308 for form posts) and login cookies are marked secure. Before the toggle is honored the plugin makes a live HTTPS request to the site and refuses to enable the redirect when it fails, so enabling it without a certificate cannot lock you out. Proxy-aware: X-Forwarded-Proto is respected, so sites behind Cloudflare or a TLS-terminating load balancer do not redirect-loop.
* New: 404 blocking — exploit scanners betray themselves by requesting long lists of paths that do not exist (/.env, backup archives, plugin files). More not-found errors within five minutes than the threshold allows (default 20) locks the address out of the entire site for a configurable duration (default 20 minutes). Missing images, styles, fonts, and media are never counted — a page full of broken image links cannot get a visitor locked out — and neither are logged-in users or allowlisted addresses.
* New: Disable directory browsing, and Prevent code execution in the public uploads folder — both written as marked .htaccess blocks. Every write is verified with a live test request and rolled back automatically when the server rejects the rule, so a restrictive AllowOverride cannot take the site down. On nginx/IIS the plugin says the rules cannot apply instead of pretending they do.
* New: Disable the built-in file editors — removes the plugin and theme code editors, the same effect as DISALLOW_FILE_EDIT without editing wp-config.php.
* Improved: Hide WordPress version now also strips the core version from enqueued script and style URLs. Plugin and theme asset versions are kept for cache busting.
* Improved: the Hardening tab is organised into sections — Connection, 404 blocking, User enumeration, Attack surface, Information disclosure, Filesystem — with a live status line for each .htaccess rule.
* Stats: scanner lockouts appear in the log under the new "404 scan" gateway and count toward the "Attacks blocked" figure (previously "Sign-ins blocked") on the dashboard, the Statistics tab, and the weekly report.

= 1.7.1 =
* Fixed: new releases now appear on the Plugins screen within an hour instead of up to a day. WordPress only re-checks for updates every 12 hours by default and the release manifest was cached for another 12 — visiting the Plugins screen now forces a fresh check (at most hourly), and the manifest cache lasts one hour.
* New: a "Check for updates" link in this plugin's row on the Plugins screen — click it and, if a newer release exists, the native "update now" link is active immediately.

= 1.7.0 =
* New: a "Spam & blacklist" action on the Comments screen — as a row action under each comment and as a bulk action. It does everything the native Spam action does, and additionally bans the author's IP on this site permanently and reports the IP and a SHA-256 hash of the e-mail address to the shared blacklist at black.majevski.com. The plaintext e-mail never leaves the site.
* New: comments from authors on the shared blacklist are refused outright — the IP check is a local cache lookup, the e-mail check uses the same k-anonymity lookup as registration (3-second timeout, fails open). Controlled by a new "Block comments from listed authors" toggle on the Cloud tab, on by default, active only while the shared feed is enabled.
* Safety: the blacklist action never reports private or allowlisted addresses, skips e-mail addresses that belong to registered users of your site (the notice says so), and requires the moderate-comments capability with a per-comment nonce.
* Note: like every shared-feed report, a blacklisted author starts being refused on other member sites once a second site corroborates the report (or immediately on your own site, via the permanent IP ban).

= 1.6.0 =
* New: automatic update checks against majevski.com. When a newer release is published at https://majevski.com/plugins/m-security/, WordPress shows its standard update prompt on the Plugins screen (and in the admin-bar update count), with one-click install from the official download endpoint. A "View details" popup shows the changelog before you update.
* New: the plugin's own settings sidebar shows a note when a newer version is known, linking to the Plugins screen.
* Security: the update channel is pinned — an update package is only ever accepted from https://majevski.com over HTTPS, whatever a manifest claims; equal or older versions are never offered; checks are cached for 12 hours, fail open, and never run on front-end requests.

= 1.5.0 =
* New: anonymised usage telemetry to the M Blacklist platform, controlled from the Cloud tab with three states — Off / Basic (versions and which protections are on) / Full (Basic plus the same lifetime counters the plugin shows you). On by default; switching it off removes the scheduled send entirely, so nothing can ever fire. A "what will be sent" preview shows the exact JSON, and a test button shows the server's raw reply.
* New: the snapshot is tied to a random install identifier generated once on activation — never derived from your domain or anything identifying. With an API key configured, snapshots attach to your verified site and no domain is sent; without one, the domain is included only while the separate "share my site's domain" checkbox is on.
* New: a "Send feedback" page under the M Security menu — bug reports, feedback, and feature requests go straight to the developer, with an optional, previewable diagnostics attachment (settings and counters only). Works even with telemetry off.
* New: lifetime counters (blocked sign-ins, registrations, comments, feed checks, reports shared) stored outside the pruned log, so long-term totals survive log retention. They are removed on uninstall.
* Privacy: no user names, e-mails, IP addresses, URLs, or content ever leave the site; the server discards anything outside the documented vocabulary, retains data at most 180 days after an install goes silent, and every send is fail-open — an unreachable service can never affect the site.

= 1.4.0 =
* New: a "Statistics" tab that opens by default, showing what the plugin has actually stopped over the last 7 or 30 days — blocked sign-ins, blocked spam, failed passwords, distinct addresses, and the busiest day — as a chart and as figures.
* New: an "M Security — blocked attacks" widget on the WordPress dashboard with the same 7/30-day chart, so the picture is there without opening the plugin.
* New: the Statistics tab reports how much work the shared threat feed is doing — how many attempts it blocked, what share of all blocks that is, and a breakdown by rule. These are addresses that had never touched your site before.
* New: an optional weekly e-mail summary, off by default. It goes to the site administrator or any address you choose, is sent on Mondays at 08:00 site time, and compares the week against the one before it. There is a "send a test report now" button.
* Improved: application-password refusals caused by the country rules or the shared feed are now logged, so the statistics no longer under-report what the REST channel blocks.
* Fixed during development, worth stating because the figures would have been wrong: hard blocks on the comment and registration gateways (a geo-blocked comment, a banned address posting) were counted by neither headline figure; day buckets could drop a whole day for a week after a daylight-saving change; the "distinct addresses" figure read zero unless IP storage was set to "full"; and the weekly e-mail compared a part-finished week against a complete one, reporting a steady fall on flat traffic.
* Note: charts are drawn as inline SVG generated by the plugin — no charting library, no external requests, and no JavaScript is required to read them.

= 1.3.2 =
* Fixed: a fatal "Allowed memory size exhausted" error that could take a site down completely — every page blank for visitors, and wp-login.php unreachable so you could not sign in to deactivate the plugin. The application-password check asked WordPress whether the visitor was logged in, but WordPress runs that check *while it is still working out who the visitor is*, and it does not guard against being asked again. Each answer restarted the question until PHP ran out of stack. Sites where "Disable application passwords" was switched on were not affected, because that setting made the check exit before reaching the faulty line.
* Fixed: the same check was running a denylist scan, a GeoIP lookup and two lockout queries on every anonymous page view, because WordPress consults it on every request rather than only during a login. It now does nothing unless the request actually carries application-password credentials.
* Hardening: the check can no longer re-enter itself under any circumstances, so this class of failure cannot recur even if a future rule or another plugin triggers it.

= 1.3.1 =
* New: "Share existing bans & denylist" button on the Cloud tab. Reports are normally queued the moment a verdict is reached, so addresses banned before you enabled sharing — and manual denylist entries, which no rule ever "reaches" — were never sent. This backfills them.
* New: adding an IP to the denylist now also queues it for the shared blacklist.
* Fixed: the connection test read the service's account fields from the wrong nesting level, so it reported plain success even when the domain was unverified or the key read-only — both of which make the service refuse every report while the feed still downloads normally. It now says so explicitly.
* Improved: sync errors are explained in plain language (unverified domain, read-only key, rejected key, rate limited) instead of only showing a raw code.

= 1.3.0 =
* New: optional instant permanent ban for IPs that probe xmlrpc.php while XML-RPC is disabled.
* New: optional instant permanent ban for IPs that scan ?author=N to harvest usernames.
* New: "Report permanent bans to the shared blacklist" — every automatic ban (decoy username, XML-RPC probe, username scan) is pushed to black.majevski.com so other member sites block the address before it reaches them.
* New: the decoy-username ban is now an explicit toggle rather than implicit behaviour.
* Improved: the sidebar shows a permanently-banned count, and explains the difference between a blocked request and a banned address when nothing is banned yet.
* Improved: the Denylist field now states that it holds manual entries only — automatic bans are listed under "Permanently banned IPs".

= 1.2.0 =
* New: optional M Blacklist shared threat feed (Cloud tab) — download a community blocklist of IPs, ranges, and hashed emails, and optionally contribute your own blocks. Disabled by default.
* New: registration email screening against the shared feed using k-anonymity (only a 5-character hash prefix leaves the site), with a 24-hour cache, a 3-second timeout, and fail-open behaviour.
* New: local cache table for feed entries, so every check on the request path is a single indexed local query — no network access during a page view.
* Database: schema version 1.1 adds the `msc_cloud` table (created automatically; removed on uninstall).

= 1.1.0 =
* New: instant permanent IP ban when a login is attempted with a decoy username you define (admin, root, etc.), covering wp-login, XML-RPC, and application passwords.
* New: "Permanently banned IPs" panel (Access lists tab) to review and clear auto-bans; bans are stored indexed, capped, and off the autoloaded settings.
* New: optional "Lock by username" toggle to prevent account-lockout abuse from rotating IPs.
* Security: the denylist and country allowlist are now enforced on the REST/application-password and XML-RPC login paths, not only wp-login.php.
* Hardening: opportunistic log-table trimming; more robust lockout counting across MySQL configurations.

= 1.0.0 =
* Initial release.

== Upgrade Notice ==

= 1.7.0 =
Adds "Spam & blacklist" to the Comments screen and refuses comments from authors on the shared blacklist (toggle on the Cloud tab, effective while the shared feed is enabled).

= 1.6.0 =
Adds self-hosted update checks: from this version on, new releases appear in the normal WordPress update flow. Install this version manually once — older versions do not know about the update channel yet.

= 1.5.0 =
Adds anonymised usage telemetry (on by default — set it to Off on the Cloud tab if you prefer; the preview shows the exact JSON that would be sent) and a Send feedback page. No personal data is ever transmitted.

= 1.4.0 =
Adds a statistics screen, a dashboard widget, and an optional weekly e-mail summary. No settings change behaviour on upgrade; the weekly e-mail is off until you switch it on.

= 1.3.2 =
Critical fix. Upgrade immediately if you run 1.3.0 or 1.3.1 with application passwords left enabled: those versions can exhaust PHP's memory on every request and lock you out of your own site. If you are locked out right now, add define( 'MSC_DISABLE', true ); to wp-config.php to stand the plugin down, upgrade, then remove that line.

= 1.3.1 =
Adds a button to share bans and denylist entries that predate sharing, and fixes a connection test that wrongly reported success for unverified domains.

= 1.3.0 =
Adds optional instant bans for XML-RPC probes and username scans, and can push every automatic ban to the shared blacklist. Both ban triggers default to off — enable them under Hardening.

= 1.2.0 =
Adds the optional M Blacklist shared threat feed. It is off by default and nothing is sent or fetched until you enable it. One new database table is created automatically.

= 1.1.0 =
Adds decoy-username instant IP banning and closes login-gateway coverage gaps. No database migration required.

= 1.0.0 =
Initial release.

== Credits ==

* Bundles the MaxMind DB Reader for PHP (Apache License 2.0).
* Disposable-domain list from the disposable-email-domains project (CC0).
* Optional GeoIP data: MaxMind GeoLite2 (requires your own account) or DB-IP Country Lite ([IP Geolocation by DB-IP](https://db-ip.com)).
