Security Testing Methodology
Last updated: 20 August 2026
Current status: every issue found on this page has been identified, fixed, and re-verified. Nothing below is an open problem.
This page exists separately from our blog because a testing claim should be checkable against a specific version at a specific date, not buried inside a narrative post that gets revised over time. When a covered component changes, this page’s relevant section and the version history table below change with it.
What this page does not claim
This is not a claim that the plugin is free of bugs, or that any covered component can never fail. No amount of testing proves that, for any software, from any team. What follows is a record of what was specifically tested, what was found, and what was fixed, so that claim can be evaluated on its own evidence rather than taken on trust.
This page covers specific components of the plugin, not the whole codebase. Other parts of SecondGate are built with the same care, but have not been put through this same level of adversarial testing, and we’re not going to imply otherwise by putting them on this page. As more components go through this process, they get added here, each in its own section, with its own evidence.
Component 1: WebAuthn/passkey parsing (CBOR decoding, authenticator data parsing, COSE key handling)
Covers: SecondGate Pro 3.14.3 and later. Ported into the free plugin as of 1.1.1.
Why this component specifically
WebAuthn attestation and assertion data arrives from the browser as CBOR-encoded binary. Rather than pull in a third-party CBOR/WebAuthn library, this plugin implements a minimal, purpose-built decoder covering only the constructs a passkey ceremony actually produces.
That choice trades a dependency for a maintenance obligation: custom binary parsers are a well-known category of security risk, and that’s exactly why this specific component gets tested harder than most of the plugin, not because it’s assumed safe, but because the standard industry caution about hand-written parsers applies directly to it.
Testing performed
- Official conformance vectors. Every well-formed CBOR construct in the RFC 8949 Appendix A test suite, fetched directly from the official public test-vectors repository, is checked against the decoder. Constructs the decoder does not support by design (floats, tags, indefinite-length items) are confirmed to be rejected outright, not silently accepted or partially handled.
- Real authenticator data. A genuine attestation object and assertion, captured from an actual macOS Touch ID authenticator (public fixtures from the widely-used duo-labs/webauthn library), are run end to end through the production code path: CBOR decoding, authenticator-data parsing, COSE-key-to-PEM conversion. The extracted credential ID, signature counter, and public key are checked against known-correct values. A separate registration was also synthesized from a freshly generated, real ECDSA key pair and driven through the full pipeline to a genuinely successful stored credential, confirming the whole flow works end to end, not just the parsing step in isolation.
- Hand-crafted adversarial input. Deliberately malformed input covering known attack patterns: truncated strings and integers, non-scalar map keys, oversized input, excessive nesting depth, reserved and out-of-range encodings, trailing data appended after a complete structure. Each case is checked at the exact boundary of its corresponding limit, not just with an obviously-invalid example.
- Mutation fuzzing. 200,000 randomly mutated inputs, built from the real fixtures and conformance vectors above, are run through the decoder on every meaningful change. The specific failure mode being hunted for is a PHP engine-level error escaping this plugin’s own exception handling and becoming an uncaught fatal error.
- Regression testing. The full suite above is re-run after every fix, to confirm nothing that previously passed stops passing.
What testing found, and what changed
Result: all four issues found were fixed, re-verified, zero regressions.
First pass found four issues: a CBOR map containing a non-scalar key threw a raw PHP TypeError, not caught by the surrounding exception handling. Truncated byte strings, truncated integers, and a truncated 8-byte integer near the limit of PHP’s representable range each decoded to a silently incorrect value instead of failing. All four were fixed and confirmed against a fresh run of the full suite.
Second pass, after the decoder’s accepted input was narrowed to an explicit whitelist and hard limits were added on nesting depth and total input size, re-ran the same suite specifically to confirm the narrower whitelist didn’t break real authenticator data (it didn’t), and added new adversarial cases targeting the new limits directly. Every catch in the passkey verification flow was also broadened from catching Exception to catching Throwable, as defence in depth rather than as a response to a found bug.
In neither pass did any malformed or adversarial input reach the actual cryptographic verification step. Every case that mattered was caught during parsing, before any signature, credential, or origin check ever ran.
Component 2: Untrusted-input parsers (IP/CIDR matching, request pattern inspection, object injection detection)
Covers: SecondGate Pro 3.14.5. Partially ported into the free plugin as of 1.1.2, see the version history table below for exactly what applies to which product.
Why these components specifically
The same criterion used to select the WebAuthn decoder for the treatment above: does this component parse untrusted, attacker-controlled input directly, and would a subtle bug in it mean a real security bypass or crash, not just a cosmetic issue. Most of the plugin, admin screens, email digests, dashboard widgets, doesn’t meet that bar. Three areas do:
- IP/CIDR range matching, parses IP addresses (fully attacker-controlled) against CIDR ranges to make binary allow/block decisions. Underpins country blocking, datacentre blocking, and manual allow/deny lists. Nearly every other blocking feature in the plugin depends on this being correct.
- The local request pattern inspector (Pro only), scans raw, attacker-controlled request content (query parameters, POST body, cookies) for injection signatures.
- The object injection scanner (Pro only), scans raw request input for serialized-PHP-object headers.
Testing performed
There’s no official conformance-vector body for these the way RFC 8949 exists for CBOR, so testing leaned more heavily on real-world known-good data and hand-built adversarial cases:
- Real-world correctness. CIDR matching checked against real, published IP ranges (including actual Google DNS IPv4 and IPv6 ranges) to confirm legitimate addresses inside and outside a range are still classified correctly.
- Real attack payloads. The object injection scanner checked against actual PHP-generated serialization output (not hand-typed approximations) for both the common and the less-known object-serialization type markers.
- Hand-crafted adversarial input. Malformed CIDR prefixes, IP-family mismatches, cookie names deliberately crafted to test the boundary of exemption logic, encoding-obfuscated injection payloads run through the same multi-round decode pipeline the scanner itself uses.
- Mutation fuzzing. 200,000 fuzzed inputs against each affected component, hunting for the same uncaught-Throwable failure mode as the WebAuthn work.
- Timing checks. The request inspector’s pattern list was additionally timed against adversarial inputs specifically shaped to stress regular-expression matching, to rule out a slow-matching pattern being usable as a denial-of-service vector.
- A real subprocess test of generated code, not just source. One of the affected components generates a standalone loader file that WordPress executes directly, before the main plugin itself has loaded. Rather than test the generator’s source code in isolation, the actual generated file was extracted and run as a real PHP process against real request scenarios, including the exact IP address a live canary-trap detection produced this week, to confirm the fix holds in the literal code that runs on a real site, not just in the template that produces it.
- Regression testing. Full suite re-run after every fix.
What testing found, and what changed
Result: every issue found across all six locations, and both Pro-only components, was fixed, re-verified, zero regressions.
The core issue, and how far it had spread. A malformed CIDR prefix length, the “/24” in “192.168.1.0/24”, being anything other than a clean, well-formed number (a typo, a corrupted line in a downloaded feed) was, in the original code, silently cast to zero rather than rejected. A prefix of “/0” doesn’t mean “nothing”, it means the entire address space. One bad entry in a list could silently mean “block every visitor” or “block nobody at all”, the exact opposite of a safe failure.
That bug wasn’t sitting in one place. Checking for it properly meant finding every independent piece of code in the codebase that parsed a CIDR range at all, and it turned out to have been hand-copied, independently, into six separate locations across the two products: the shared CIDR-parsing utility, the manual allow/deny list matcher, the country-blocking matcher, and the mu-plugins loader that runs before WordPress itself has finished booting (Pro only, this last one has no equivalent in the free plugin). Each was checked and confirmed to carry the same bug independently, since fixing one copy does nothing for the others.
Each was fixed at the source. Two of them, the manual list matcher and the country-blocking matcher, were then refactored to call the shared, already-tested parsing code directly instead of keeping their own independent copies, specifically so this exact bug class cannot quietly reappear in a seventh place later without the shared code catching it first.
Two further, separate issues, found in Pro-only components with no free-plugin equivalent:
- Request pattern inspector: the exemption list for WordPress’s own cookies matched by name prefix rather than actual shape. Since a cookie’s name is exactly as attacker-controllable as any other request field, a cookie merely named to start with a trusted prefix (for example,
wordpress_test_cookie_<script>...) skipped scanning entirely, regardless of what its value actually contained. Fixed by validating the real, fixed shape genuine WordPress cookies have, not just their opening characters. - Object injection scanner: only recognised PHP’s
O:object-serialization marker. It completely missedC:, the marker used by classes implementing PHP’sSerializableinterface, a real, documented, equally dangerous alternative entry point for the same class of attack. Confirmed with genuine PHP-generatedC:payloads both before the fix (undetected) and after (detected).
As with the WebAuthn work, none of these issues were auth-bypass or signature-verification failures. Each was a case of malformed or adversarially-shaped input producing a silently wrong decision, or a real detection gap for a known attack technique, not a case of the underlying authentication or cryptographic logic being wrong.
Component 3: Visitor IP resolution (brute-force lockout, and every module that records or acts on an IP address)
Covers: SecondGate Pro 3.14.11. Ported into the free plugin as of 1.1.3.
Why this component specifically
This plugin has one deliberately careful IP-resolution function: by default it trusts only REMOTE_ADDR, the real TCP connection address a visitor cannot forge, and only consults a proxy header (X-Forwarded-For, CF-Connecting-IP, X-Real-IP) if the site owner has explicitly enabled that and named their specific proxy. That care only protects a feature if the feature actually uses it. Auditing this component meant finding every place in the codebase that made a security-relevant decision or record from an IP address, and checking each one used the shared, careful resolution rather than its own copy.
What was found
Brute-force login lockout resolved the visitor’s IP with its own separate, simpler logic than the rest of the plugin, REMOTE_ADDR only, with no awareness of the “trust proxy headers” setting the IP/country blocker already respects. On a site behind a CDN or reverse proxy with that setting enabled, REMOTE_ADDR is the proxy’s own address for every visitor, not the real client. That meant every visitor through the proxy shared one IP-keyed lockout bucket: one attacker’s failed logins could lock out every legitimate visitor behind the same edge, and per-IP brute-force protection was defeated for real attackers, since every attacker also looked identical to the plugin.
This is the same underlying mistake as the CIDR “/0” bug in Component 2, logic hand-copied instead of shared, so it was closed the same way: a new shared entry point, TFIB_Blocker::resolve_ip(), was added as the one tested implementation, and every module making a security-relevant decision or record from an IP address was moved onto it instead of keeping its own copy.
One deliberate exception: the mu-plugins early interceptor (see Component 2), which runs before WordPress itself has finished booting. TFIB_Blocker genuinely isn’t loaded yet at that stage by design, so it can’t call the shared resolver; routing it through would mean a third independent copy of the proxy-header logic, the exact pattern this fix exists to eliminate. Left on its own REMOTE_ADDR-only resolution as a documented trade-off, not an oversight.
Testing performed, at three genuinely different confidence levels
This component has a wider spread of evidence quality than Components 1 and 2, worth being precise about rather than flattening into one claim:
Directly verified, tested end to end against real source code, same standard as everything else on this page. Brute-force lockout and the core TFIB_Blocker::resolve_ip() function: real classes, not simulated stand-ins, exercised against the real Cloudflare-header resolution path (trust-proxy setting on, a configured header, a spoofed REMOTE_ADDR standing in for a shared proxy, a distinct real client IP behind it), confirming an attacker behind the proxy gets locked out while a different real visitor behind the same proxy does not. Also confirmed: the defensive fallback to plain REMOTE_ADDR when the IP Block module isn’t loaded at all, checked as a genuine subprocess, not assumed.
Confirmed present in source, not yet executed. Six modules call TFIB_Blocker::resolve_ip() in the actual shipped code, confirmed directly against the plugin files, not from a description: brute-force lockout, the behavioral-cadence and WebAuthn-parse-failure threat-score signals, the admin honeytoken and phantom-record trip logs, the security-event login log, and account-notification emails. Of these, only brute-force lockout has dedicated automated tests today. The other five are real, present code that looks correct on inspection, but haven’t been run yet, testing them fully would need broader WordPress mocking (wp_mail, get_userdata, the threat-score class) not yet built. Worth being precise about that gap rather than implying identical test depth across all six.
Tested in a separate working session, not independently reproduced here. Multisite behavior, network activation, the settings page rendering correctly in a real admin context on multiple network sites, and settings/license state staying genuinely isolated per site, was tested against a real WordPress multisite installation with a real database. That work happened in a different conversation than the one that produced this page, and this page’s own standard is that a claim should be checkable, not just reported, so it’s stated here at that lower, honest confidence level rather than folded into the “verified” language used elsewhere. The one limitation that testing surfaced and is worth carrying over regardless of confidence tier: there’s no network-wide settings panel today, each site in a network needs its own license key and configuration set up individually.
Version history
SecondGate exists as two separate products with independent version numbers: the free plugin (currently version 1.1.x, awaiting initial WordPress.org review) and SecondGate Pro (currently version 3.14.x). The table below tracks testing history separately for each.
SecondGate Pro
| Plugin version | Date | Summary |
|---|---|---|
| 3.14.11 | 20 Aug 2026 | Component 3 (IP resolution) hardening pass described above: added TFIB_Blocker::resolve_ip() as a single shared entry point, and moved six modules onto it. Brute-force lockout tested end to end against real source, including the real Cloudflare-header path. Zero regressions on what’s tested. The other five modules are confirmed present in source but not yet execution-tested; multisite behavior tested in a separate session, not independently reproduced on this page, see the component section above for the honest breakdown. |
| 3.14.5 | 20 Aug 2026 | Component 2 hardening pass described above, in full: the CIDR-parsing bug fixed across all six locations it was found in, two of them refactored to eliminate the duplication itself; the request inspector’s cookie exemption fixed; the object injection scanner’s missing C: marker fixed. Re-verified against real-world data, adversarial cases, a real subprocess run of generated code, and 200,000 fuzzed inputs per component. Zero regressions. |
| 3.14.3 | 19 Aug 2026 | Component 1 hardening pass described above: whitelist narrowed to exactly the constructs WebAuthn/COSE use, input-size and nesting-depth limits added, exception handling broadened to Throwable. Re-verified against the full suite above. Zero regressions. |
SecondGate (free plugin)
| Plugin version | Date | Summary |
|---|---|---|
| 1.1.3 | 20 Aug 2026 | Ports the Component 3 (IP resolution) fix into the free plugin’s own code: brute-force lockout now uses the same shared resolver the IP/country blocker already uses, instead of its own separate copy. Tested end to end against real source, including the real Cloudflare-header path. Zero regressions. |
| 1.1.2 | 20 Aug 2026 | Ports the CIDR-parsing fix from the Component 2 pass above, everywhere it applies to the free plugin’s own code: the shared CIDR utility, the manual allow/deny list matcher, and the country-blocking matcher. The request inspector and object injection scanner are Pro-only features with no code in the free plugin, so those two fixes have nothing to port. Re-verified against real-world data, adversarial cases, and 200,000 fuzzed inputs. Zero regressions. |
| 1.1.1 | Submitted 19 Aug 2026, awaiting WordPress.org review | Ports the Component 1 (WebAuthn/passkey) hardening pass into the free plugin’s own code. Zero regressions. |
This table is updated whenever a covered component changes in either product. If you’re reading this against a version newer than what’s listed here, check back, results may differ for a later release.
