Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,34 @@ itself is enabled.
block. Note: this sends the submitted email to a third-party service, so
cover it in your privacy policy / DPA.

## Logging

Turn on **Debug logging** (Forms → Settings → ALTCHA) to record every decision —
useful for verifying behaviour in staging or chasing a report. Each decision
writes one greppable line to the PHP error log:

```
[gravityforms-altcha] altcha: pass {"form":43}
[gravityforms-altcha] altcha: fail {"form":43,"reason":"replay"}
[gravityforms-altcha] rate_limit: blocked {"form":12}
[gravityforms-altcha] content_filter: spam {"form":7,"score":3,"signals":["keyword"]}
[gravityforms-altcha] email_validation: blocked {"form":7,"status":"undeliverable","disposable":false,"reason":"rejected_email","domain":"exmaple.com"}
```

`reason` for ALTCHA is `missing` / `invalid` / `replay`. **No PII is logged** —
IPs only ever appear as a salted hash, and emails as the domain only.

Route the records elsewhere (Sentry, Query Monitor, …) via the
`genero/gravityforms_altcha/log` action — and optionally drop the default
error-log line:

```php
remove_action('genero/gravityforms_altcha/log', ['Genero\GravityFormsAltcha\Logger', 'writeToErrorLog']);
add_action('genero/gravityforms_altcha/log', function (string $event, string $outcome, array $context) {
// ship $event/$outcome/$context to your sink
}, 10, 3);
```

## How it works

1. **Form render** — when enabled for the form, the plugin injects a hidden
Expand Down Expand Up @@ -192,6 +220,14 @@ add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [...
add_filter('genero/gravityforms_altcha/spam_score_threshold', fn () => 4);
```

### `genero/gravityforms_altcha/logging`

Force decision logging on or off regardless of the *Debug logging* setting:

```php
add_filter('genero/gravityforms_altcha/logging', fn () => defined('WP_DEBUG') && WP_DEBUG);
```

### `genero/gravityforms_altcha/bouncer_api_key`

Provide the Bouncer key in code instead of the `BOUNCER_API_KEY` env var (e.g.
Expand Down
2 changes: 1 addition & 1 deletion gravityforms-altcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Plugin Name: ALTCHA for Gravity Forms
* Plugin URI: https://github.com/generoi/gravityforms-altcha
* Description: Invisible ALTCHA spam protection for Gravity Forms — uses the MIT-licensed altcha-org/altcha PHP library and the ALTCHA widget web component to proof-of-work-verify every form submission with no user interaction.
* Version: 0.3.0
* Version: 0.4.0
* Requires at least: 6.0
* Requires PHP: 8.2
* Author: Genero
Expand Down
25 changes: 24 additions & 1 deletion src/EmailValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,19 @@ public function validateField($result, $value, $form, $field)
return $result;
}

if (! self::shouldBlock(self::validate($email), Settings::emailBlockModes())) {
$verdict = self::validate($email);
$block = self::shouldBlock($verdict, Settings::emailBlockModes());

Logger::record('email_validation', $block ? 'blocked' : 'allowed', [
'form' => $form['id'] ?? null,
'status' => $verdict['status'],
'disposable' => $verdict['disposable'],
'reason' => $verdict['reason'],
// Domain only — never the full address.
'domain' => self::emailDomain($email),
]);

if (! $block) {
return $result;
}

Expand Down Expand Up @@ -118,6 +130,17 @@ public static function validate(string $email): array
*
* @param array{reason?: ?string} $result
*/
/**
* The domain part of an email, for privacy-safe logging (never the local
* part). Returns '' when there's no '@'.
*/
public static function emailDomain(string $email): string
{
$at = strrpos($email, '@');

return $at === false ? '' : strtolower(substr($email, $at + 1));
}

public static function isDefinitive(array $result): bool
{
$reason = (string) ($result['reason'] ?? '');
Expand Down
15 changes: 13 additions & 2 deletions src/Integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,25 @@ public function validate(array $result): array
return $result;
}

$formId = $result['form']['id'] ?? null;

$payload = isset($_POST[self::POST_FIELD]) && is_string($_POST[self::POST_FIELD])
? sanitize_text_field(wp_unslash($_POST[self::POST_FIELD]))
: '';

if ($this->challenge()->verify($payload) && ! $this->isReplay($payload)) {
return $result;
if ($this->challenge()->verify($payload)) {
if (! $this->isReplay($payload)) {
Logger::record('altcha', 'pass', ['form' => $formId]);

return $result;
}
$reason = 'replay';
} else {
$reason = $payload === '' ? 'missing' : 'invalid';
}

Logger::record('altcha', 'fail', ['form' => $formId, 'reason' => $reason]);

$result['is_valid'] = false;
$result['form']['validation_summary_message'] = $this->errorMessage();
$result['form']['failed_validation_page'] = $result['form']['page_count'] ?? 1;
Expand Down
64 changes: 64 additions & 0 deletions src/Logger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Genero\GravityFormsAltcha;

/**
* Lightweight, opt-in logging so each protection decision can be verified
* through the logs. Off by default; enabled by the "Debug logging" setting (or
* the `genero/gravityforms_altcha/logging` filter).
*
* Every decision fires the `genero/gravityforms_altcha/log` action so it can be
* routed anywhere (Sentry, Query Monitor, …); a default listener writes a
* greppable line to the PHP error log (registered in {@see Plugin}).
*
* Privacy: callers pass only non-PII context — a form id, a salted IP hash, an
* email *domain* (never the full address or raw IP), spam signal names, etc.
*/
class Logger
{
public const HOOK = 'genero/gravityforms_altcha/log';

/**
* @param string $event e.g. altcha | rate_limit | content_filter | email_validation
* @param string $outcome e.g. pass | fail | blocked | allowed
* @param array<string, mixed> $context non-PII detail
*/
public static function record(string $event, string $outcome, array $context = []): void
{
if (! self::enabled()) {
return;
}

do_action(self::HOOK, $event, $outcome, $context);
}

public static function enabled(): bool
{
/**
* Filters whether decisions are logged. Defaults to the "Debug logging"
* setting (wired in Plugin); return true/false to override.
*/
return (bool) apply_filters('genero/gravityforms_altcha/logging', false);
}

/**
* Default sink — a single greppable line per decision. Other listeners can
* be added on the same hook, or this one removed to fully take over routing.
*
* @param array<string, mixed> $context
*/
public static function writeToErrorLog(string $event, string $outcome, array $context = []): void
{
error_log(self::format($event, $outcome, $context));
}

/**
* @param array<string, mixed> $context
*/
public static function format(string $event, string $outcome, array $context = []): string
{
$suffix = $context !== [] ? ' '.json_encode($context) : '';

return sprintf('[gravityforms-altcha] %s: %s%s', $event, $outcome, $suffix);
}
}
6 changes: 5 additions & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class Plugin
{
public const VERSION = '0.3.0';
public const VERSION = '0.4.0';

public const SLUG = 'gravityforms-altcha';

Expand Down Expand Up @@ -63,6 +63,10 @@ public function registerHooks(): void
return;
}

// Logging: drive the toggle from settings, and write the default sink.
add_filter('genero/gravityforms_altcha/logging', [Settings::class, 'loggingEnabled']);
add_action(Logger::HOOK, [Logger::class, 'writeToErrorLog'], 10, 3);

Integration::register();
ChallengeEndpoint::register();
SpamFilter::register();
Expand Down
12 changes: 12 additions & 0 deletions src/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ public function plugin_settings_fields()
'tooltip' => esc_html__('Asks the visitor to correct a bad email address before submitting. Verifies via Bouncer — requires the BOUNCER_API_KEY environment variable, and sends the email to a third-party service. Fails open (never blocks) if the service is unavailable. Applies to all Gravity Forms.', 'gravityforms-altcha'),
'default_value' => false,
],
[
'name' => 'enable_logging',
'type' => 'toggle',
'label' => esc_html__('Debug logging', 'gravityforms-altcha'),
'tooltip' => esc_html__('Logs every ALTCHA, rate-limit, content-filter and email decision (pass or fail) to the PHP error log so you can verify behaviour. No raw IPs or full email addresses are logged. Leave off in normal operation.', 'gravityforms-altcha'),
'default_value' => false,
],
[
'name' => 'email_block',
'type' => 'checkbox',
Expand Down Expand Up @@ -220,6 +227,11 @@ public static function emailValidationEnabled(): bool
return (bool) self::get_instance()->get_plugin_setting('enable_email_validation');
}

public static function loggingEnabled(): bool
{
return (bool) self::get_instance()->get_plugin_setting('enable_logging');
}

/**
* Which Bouncer verdicts the admin has opted to reject. Unchecked / unset →
* false, so a verdict is only ever acted on when explicitly enabled.
Expand Down
52 changes: 44 additions & 8 deletions src/SpamFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,31 @@ public function flag($isSpam, $form, $entry): bool
return (bool) $isSpam;
}

if (Settings::rateLimitEnabled() && $this->exceedsRateLimit($form)) {
return true;
$formId = $form['id'] ?? null;

if (Settings::rateLimitEnabled()) {
if ($this->exceedsRateLimit($form)) {
Logger::record('rate_limit', 'blocked', ['form' => $formId]);

return true;
}
Logger::record('rate_limit', 'pass', ['form' => $formId]);
}

if (Settings::contentFilterEnabled()) {
$text = $this->extractText($form, $entry);
if (self::contentIsSpam($text['body'], $text['identity'], self::keywords(), self::scoreThreshold())) {
$report = self::contentReport($text['body'], $text['identity'], self::keywords(), self::scoreThreshold());

if ($report['spam']) {
Logger::record('content_filter', 'spam', [
'form' => $formId,
'score' => $report['score'],
'signals' => $report['signals'],
]);

return true;
}
Logger::record('content_filter', 'pass', ['form' => $formId, 'score' => $report['score']]);
}

return (bool) $isSpam;
Expand Down Expand Up @@ -213,48 +229,68 @@ private function fieldValue($field, array $entry): string
* @param array<int, string> $keywords
*/
public static function contentIsSpam(string $body, string $identity, array $keywords, int $threshold): bool
{
return self::contentReport($body, $identity, $keywords, $threshold)['spam'];
}

/**
* Scores content and reports which signals fired — used both for the spam
* decision and for logging. A definite keyword flags on a single hit;
* otherwise weaker signals must accumulate to the threshold, so a lone link
* or foreign word never trips it.
*
* @param array<int, string> $keywords
* @return array{spam: bool, score: int, signals: array<int, string>}
*/
public static function contentReport(string $body, string $identity, array $keywords, int $threshold): array
{
$identity = self::normalize($identity);
$body = self::normalize($body);
$combined = trim($identity.' '.$body);

if ($combined === '') {
return false;
return ['spam' => false, 'score' => 0, 'signals' => []];
}

// Definite-spam keywords — matched on word boundaries (unicode-aware) so
// a keyword can't trip on a substring of an innocent word.
foreach ($keywords as $keyword) {
if ($keyword !== '' && self::containsWord($combined, $keyword)) {
return true;
return ['spam' => true, 'score' => $threshold, 'signals' => ['keyword']];
}
}

$score = 0;
$signals = [];

// Links (http(s):// and scheme-less www.), graduated: a single link is
// innocent, a pile of them is not.
$links = self::countLinks($combined);
if ($links >= 5) {
$score += 3;
$signals[] = 'many_links';
} elseif ($links >= 3) {
$score += 2;
$signals[] = 'links';
}

// A URL in the name field is near-certain spam — real names aren't links.
if ($identity !== '' && self::countLinks($identity) >= 1) {
$score += 3;
$signals[] = 'url_in_name';
}

if (preg_match('~\[url=|</?a\s~i', $combined)) {
$score += 2; // injected markup
$score += 2;
$signals[] = 'markup';
}

if (preg_match('~[\p{Cyrillic}\p{Han}\p{Hangul}]~u', $combined)) {
$score += 2; // wrong script for a FI/SV site
$score += 2;
$signals[] = 'wrong_script';
}

return $score >= $threshold;
return ['spam' => $score >= $threshold, 'score' => $score, 'signals' => $signals];
}

/**
Expand Down
28 changes: 28 additions & 0 deletions test/mocked/LoggerRecordTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Genero\GravityFormsAltcha\Tests\Mocked;

use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Genero\GravityFormsAltcha\Logger;

class LoggerRecordTest extends MockedTestCase
{
public function test_records_nothing_when_disabled(): void
{
Functions\when('apply_filters')->justReturn(false);
Actions\expectDone(Logger::HOOK)->never();

Logger::record('altcha', 'pass', ['form' => 1]);
}

public function test_fires_the_log_action_when_enabled(): void
{
Functions\when('apply_filters')->justReturn(true);
Actions\expectDone(Logger::HOOK)->once()->with('rate_limit', 'blocked', ['form' => 1]);

Logger::record('rate_limit', 'blocked', ['form' => 1]);
}
}
4 changes: 4 additions & 0 deletions test/mocked/MockedTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Genero\GravityFormsAltcha\Tests\Mocked;

use Brain\Monkey;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

/**
Expand All @@ -13,6 +14,9 @@
*/
abstract class MockedTestCase extends TestCase
{
// Counts Brain\Monkey/Mockery expectations as PHPUnit assertions.
use MockeryPHPUnitIntegration;

protected function setUp(): void
{
parent::setUp();
Expand Down
Loading
Loading