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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The format follows [Keep a Changelog] (https://keepachangelog.com/en/1.1.0/).
- **The device test names the browser and its version** (#2052). "Ordinary browser" becomes, for instance, "Chrome (v. 129.0.6668.81)", "Brave / Chromium (v. …)" or "Opera 103 / Chromium (v. 117.…)", read from the brands a Chromium browser declares and then from the user agent; an app's own browser names the app, and the PDF row says what it checked.
- **The device test diagnoses more, and says how to fix what fails** (#2054). New rows: local storage, the ALTCHA captcha's needs, the device ID the device limit reads (a short code, signals read, whether they hold between reads, read-only), the clock against the server's, time zone and language, screen, connection and theme. A failing row opens how to fix it; a check still running counts down from 20 seconds.
- **The device test names a blocker and gives a verdict** (#2062). New row: whether a request reaches the server, with its time. The device ID and the captcha say when a blocker stopped them; a CSS-timed notice shows when the test's own scripts never ran. A summary sits above the rows, location is one row, the PDF comes last, and the copy carries version and setup.
- **The device test talks to the plugin, under a per-address limit** (#2064). One request now feeds the server and clock rows, and names a page served from an old cache. Settings → Rate Limit caps runs per address (default 60 per 10 minutes; 0 off), past which the row warns with the wait. The ALTCHA cap shares the counter.

### Fixed

Expand Down
157 changes: 94 additions & 63 deletions assets/js/ffc-device-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -543,39 +543,6 @@
set('captcha', 'ok', text('captchaYes', 'Supported'));
}

/**
* Whether a request reaches the server the forms post to (#2062): an
* empty POST to admin-ajax.php, which WordPress answers with a 400 and
* "0" when it names no action. Anything else came from a firewall in
* front of the site; no answer at all means a blocker or the network
* stopped it.
*/
function checkServer() {
if (!row('server')) {
return;
}
var ns = window.FFC || {};
var url = (window.ffc_ajax && window.ffc_ajax.ajax_url) || (typeof ns.getAjaxUrl === 'function' ? ns.getAjaxUrl() : '');
var blocked = function () {
set('server', 'fail', text('serverBlocked', 'Did not reach the server: sending a form will fail.'), 'helpServerBlocked');
};
if (!url || typeof window.fetch !== 'function') {
blocked();
return;
}
pending('server', text('serverReading', 'Sending a test request…'));
var started = Date.now();
window.fetch(url, { method: 'POST', cache: 'no-store', credentials: 'same-origin' }).then(function (response) {
return response.text().then(function (body) {
if (400 === response.status && '0' === String(body).trim()) {
set('server', 'ok', text('serverOk', 'Reached, answered in %s ms').replace('%s', String(Date.now() - started)));
} else {
set('server', 'fail', text('serverRefused', 'Refused by the server or a firewall (code %s): sending a form will fail.').replace('%s', String(response.status)), 'helpServerRefused');
}
});
}).catch(blocked);
}

/**
* A duration, rounded to the unit a person reads.
*
Expand All @@ -594,41 +561,106 @@
}

/**
* The device clock against the server's (#2054), read from the `Date`
* header of a request for this same page: no endpoint, nothing sent but
* the request itself. A clock more than a minute off breaks date windows
* and the captcha's expiry.
* The device clock against the server's (#2054), from the time the ping
* answered with. The server's second is matched against the middle of
* the request. A clock more than a minute off breaks date windows and
* the captcha's expiry.
*
* @param {number} server Server time, in seconds since the epoch.
* @param {number} sent When the request left, in ms.
* @param {number} arrived When the answer arrived, in ms.
*/
function paintClock(server, sent, arrived) {
var skew = ((sent + arrived) / 2 - server * 1000) / 1000;
if (Math.abs(skew) <= 60) {
set('clock', 'ok', text('clockOk', 'Correct (differs by %s)').replace('%s', duration(skew)));
} else if (skew > 0) {
set('clock', 'warn', text('clockAhead', 'Ahead by %s: date windows and the captcha may fail. Set the clock to automatic.').replace('%s', duration(skew)), 'helpClock');
} else {
set('clock', 'warn', text('clockBehind', 'Behind by %s: date windows and the captcha may fail. Set the clock to automatic.').replace('%s', duration(skew)), 'helpClock');
}
}

/**
* The test's one server request (#2064), which feeds two rows: whether
* a request reaches the server the forms post to, and the clock. The
* answer also carries the plugin's version, so a page served from an
* old cache, whose scripts belong to an earlier release, is named.
*
* A 429 is the site's limit on how often one address may run the test:
* a warning with the wait, not a fault of the device. Any other answer
* that is not the ping's came from a firewall in front of the site, and
* no answer at all means a blocker or the network stopped the request.
*/
function checkClock() {
if (!row('clock')) {
function checkServer() {
var server = row('server');
var clock = row('clock');
if (!server && !clock) {
return;
}
var unknown = function () {
set('clock', 'info', text('clockUnknown', 'Could not compare with the server.'));
var ping = cfg.ping || {};
var clockUnknown = function () {
if (clock) {
set('clock', 'info', text('clockUnknown', 'Could not compare with the server.'));
}
};
if (typeof window.fetch !== 'function') {
unknown();
var blocked = function () {
if (server) {
set('server', 'fail', text('serverBlocked', 'Did not reach the server: sending a form will fail.'), 'helpServerBlocked');
}
clockUnknown();
};
if (!ping.url || !ping.action || typeof window.fetch !== 'function') {
blocked();
return;
}
pending('clock', text('clockReading', 'Comparing with the server…'));
if (server) {
pending('server', text('serverReading', 'Sending a test request…'));
}
if (clock) {
pending('clock', text('clockReading', 'Comparing with the server…'));
}
var sent = Date.now();
window.fetch(window.location.href, { method: 'HEAD', cache: 'no-store', credentials: 'same-origin' }).then(function (response) {
var server = Date.parse(response.headers.get('Date') || '');
if (isNaN(server)) {
unknown();
return;
}
// The header is the server's second; the device's matching moment
// is the middle of the request.
var skew = ((sent + Date.now()) / 2 - server) / 1000;
if (Math.abs(skew) <= 60) {
set('clock', 'ok', text('clockOk', 'Correct (differs by %s)').replace('%s', duration(skew)));
} else if (skew > 0) {
set('clock', 'warn', text('clockAhead', 'Ahead by %s: date windows and the captcha may fail. Set the clock to automatic.').replace('%s', duration(skew)), 'helpClock');
} else {
set('clock', 'warn', text('clockBehind', 'Behind by %s: date windows and the captcha may fail. Set the clock to automatic.').replace('%s', duration(skew)), 'helpClock');
}
}, unknown);
window.fetch(ping.url, {
method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
body: new URLSearchParams({ action: ping.action })
}).then(function (response) {
var arrived = Date.now();
return response.json().catch(function () {
return null;
}).then(function (json) {
var data = json && json.data;
if (429 === response.status && data) {
if (server) {
set('server', 'warn', text('serverLimited', 'Too many tests from this network: wait %s, then reload the page.').replace('%s', duration(data.retryAfter || 0)), 'helpServerLimited');
}
clockUnknown();
return;
}
if (200 !== response.status || !json || !json.success || !data) {
if (server) {
set('server', 'fail', text('serverRefused', 'Refused by the server or a firewall (code %s): sending a form will fail.').replace('%s', String(response.status)), 'helpServerRefused');
}
clockUnknown();
return;
}
if (clock) {
paintClock(Number(data.time), sent, arrived);
}
if (!server) {
return;
}
var built = window.ffcCoreConfig && window.ffcCoreConfig.version;
var ms = String(arrived - sent);
if (built && data.version && String(built) !== String(data.version)) {
set('server', 'warn', fill(text('serverStale', 'Reached in %1$s ms, but this page is an old copy (version %2$s; the site runs %3$s).'), [ms, built, data.version]), 'helpServerStale');
} else {
set('server', 'ok', text('serverOk', 'Reached, answered in %s ms').replace('%s', ms));
}
});
}).catch(blocked);
}

/** The time zone and the language the browser reports (#2054). */
Expand Down Expand Up @@ -893,11 +925,10 @@
? text('pdfNo', 'Not supported: missing %s.').replace('%s', missing.join(', '))
: text('pdfYes', 'Supported: this browser can build and save the PDF.'), 'helpPdf');

checkServer();
checkStorage();
checkCaptcha();
checkDevice();
checkClock();
checkServer();
checkLocale();
checkScreen();
checkConnection();
Expand Down
2 changes: 1 addition & 1 deletion assets/js/ffc-device-check.min.js

Large diffs are not rendered by default.

72 changes: 44 additions & 28 deletions includes/admin/class-ffc-settings-ajax-endpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -424,97 +424,113 @@ public static function allowlist(): array {

// Rate-limit nested non-boolean fields.
$rate_limit_typed = array(
'ip_max_per_hour' => array(
'ip_max_per_hour' => array(
array( 'ip', 'max_per_hour' ),
'int',
array(
'min' => 1,
'max' => 1000,
),
),
'ip_max_per_day' => array(
'ip_max_per_day' => array(
array( 'ip', 'max_per_day' ),
'int',
array(
'min' => 1,
'max' => 10000,
),
),
'ip_cooldown_seconds' => array(
'ip_cooldown_seconds' => array(
array( 'ip', 'cooldown_seconds' ),
'int',
array(
'min' => 1,
'max' => 3600,
),
),
'ip_captcha_max_per_window' => array(
'ip_captcha_max_per_window' => array(
array( 'ip', 'captcha_max_per_window' ),
'int',
array(
'min' => \FreeFormCertificate\Core\Captcha\CaptchaSettings::MINT_CAP_MIN,
'max' => \FreeFormCertificate\Core\Captcha\CaptchaSettings::MINT_CAP_MAX,
),
),
'ip_captcha_window_seconds' => array(
'ip_captcha_window_seconds' => array(
array( 'ip', 'captcha_window_seconds' ),
'int',
array(
'min' => \FreeFormCertificate\Core\Captcha\CaptchaSettings::MINT_WINDOW_MIN,
'max' => \FreeFormCertificate\Core\Captcha\CaptchaSettings::MINT_WINDOW_MAX,
),
),
'ip_apply_to' => array( array( 'ip', 'apply_to' ), 'string', array() ),
'ip_message' => array( array( 'ip', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'email_max_per_day' => array( array( 'email', 'max_per_day' ), 'int', array( 'min' => 1 ) ),
'email_max_per_week' => array( array( 'email', 'max_per_week' ), 'int', array( 'min' => 1 ) ),
'email_max_per_month' => array( array( 'email', 'max_per_month' ), 'int', array( 'min' => 1 ) ),
'email_message' => array( array( 'email', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'cpf_max_per_month' => array( array( 'cpf', 'max_per_month' ), 'int', array( 'min' => 1 ) ),
'cpf_max_per_year' => array( array( 'cpf', 'max_per_year' ), 'int', array( 'min' => 1 ) ),
'cpf_block_threshold' => array( array( 'cpf', 'block_threshold' ), 'int', array( 'min' => 1 ) ),
'cpf_block_hours' => array( array( 'cpf', 'block_hours' ), 'int', array( 'min' => 1 ) ),
'cpf_block_duration' => array( array( 'cpf', 'block_duration' ), 'int', array( 'min' => 1 ) ),
'cpf_message' => array( array( 'cpf', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'global_max_per_minute' => array( array( 'global', 'max_per_minute' ), 'int', array( 'min' => 1 ) ),
'global_max_per_hour' => array( array( 'global', 'max_per_hour' ), 'int', array( 'min' => 1 ) ),
'global_message' => array( array( 'global', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'device_max_per_form' => array(
'ip_device_check_max_per_window' => array(
array( 'ip', 'device_check_max_per_window' ),
'int',
array(
'min' => \FreeFormCertificate\Settings\RateLimitSettingsReader::DEVICE_CHECK_CAP_MIN,
'max' => \FreeFormCertificate\Settings\RateLimitSettingsReader::DEVICE_CHECK_CAP_MAX,
),
),
'ip_device_check_window_seconds' => array(
array( 'ip', 'device_check_window_seconds' ),
'int',
array(
'min' => \FreeFormCertificate\Settings\RateLimitSettingsReader::DEVICE_CHECK_WINDOW_MIN,
'max' => \FreeFormCertificate\Settings\RateLimitSettingsReader::DEVICE_CHECK_WINDOW_MAX,
),
),
'ip_apply_to' => array( array( 'ip', 'apply_to' ), 'string', array() ),
'ip_message' => array( array( 'ip', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'email_max_per_day' => array( array( 'email', 'max_per_day' ), 'int', array( 'min' => 1 ) ),
'email_max_per_week' => array( array( 'email', 'max_per_week' ), 'int', array( 'min' => 1 ) ),
'email_max_per_month' => array( array( 'email', 'max_per_month' ), 'int', array( 'min' => 1 ) ),
'email_message' => array( array( 'email', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'cpf_max_per_month' => array( array( 'cpf', 'max_per_month' ), 'int', array( 'min' => 1 ) ),
'cpf_max_per_year' => array( array( 'cpf', 'max_per_year' ), 'int', array( 'min' => 1 ) ),
'cpf_block_threshold' => array( array( 'cpf', 'block_threshold' ), 'int', array( 'min' => 1 ) ),
'cpf_block_hours' => array( array( 'cpf', 'block_hours' ), 'int', array( 'min' => 1 ) ),
'cpf_block_duration' => array( array( 'cpf', 'block_duration' ), 'int', array( 'min' => 1 ) ),
'cpf_message' => array( array( 'cpf', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'global_max_per_minute' => array( array( 'global', 'max_per_minute' ), 'int', array( 'min' => 1 ) ),
'global_max_per_hour' => array( array( 'global', 'max_per_hour' ), 'int', array( 'min' => 1 ) ),
'global_message' => array( array( 'global', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'device_max_per_form' => array(
array( 'device', 'max_per_form' ),
'int',
array(
'min' => 1,
'max' => 100,
),
),
'device_match_threshold' => array(
'device_match_threshold' => array(
array( 'device', 'match_threshold' ),
'int',
array(
'min' => 3,
'max' => 12,
),
),
'device_match_strong_min' => array(
'device_match_strong_min' => array(
array( 'device', 'match_strong_min' ),
'int',
array(
'min' => 0,
'max' => 6,
),
),
'device_retention_days' => array(
'device_retention_days' => array(
array( 'device', 'retention_days' ),
'int',
array(
'min' => 1,
'max' => 3650,
),
),
'device_message' => array( array( 'device', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'logging_retention_days' => array( array( 'logging', 'retention_days' ), 'int', array( 'min' => 1 ) ),
'logging_max_logs' => array( array( 'logging', 'max_logs' ), 'int', array( 'min' => 100 ) ),
'read_message' => array( array( 'read', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'device_message' => array( array( 'device', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
'logging_retention_days' => array( array( 'logging', 'retention_days' ), 'int', array( 'min' => 1 ) ),
'logging_max_logs' => array( array( 'logging', 'max_logs' ), 'int', array( 'min' => 100 ) ),
'read_message' => array( array( 'read', 'message' ), 'string', array( 'as' => 'multiline_text' ) ),
);

// Per-endpoint numeric thresholds under read.endpoints.<ep>.{max_per_minute,max_per_hour}.
Expand Down
41 changes: 2 additions & 39 deletions includes/core/captcha/class-ffc-altcha-challenge-endpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

namespace FreeFormCertificate\Core\Captcha;

use FreeFormCertificate\Core\RequestInput;
use FreeFormCertificate\Core\WindowThrottle;

if ( ! defined( 'ABSPATH' ) ) {
exit;
Expand Down Expand Up @@ -107,43 +107,6 @@ public static function handle(): void {
* @return bool True when the caller may mint another challenge.
*/
private static function allow_mint(): bool {
$cap = CaptchaSettings::mint_cap();
if ( $cap <= 0 ) {
return true;
}

$ip = RequestInput::get_user_ip();
if ( '' === $ip ) {
return true;
}

/*
* Fixed windows, not a sliding one: the key carries the window number,
* so a steady caller crosses into a fresh bucket instead of holding
* one open. Re-setting a single transient on every mint would renew
* its expiry each time and never reset the count. This is the same
* bucketing `RateLimitChecker::check_global_limit()` already uses.
*
* The address is hashed and never stored raw — an option name is
* readable by anything that can list options, and a visitor IP is
* personal data.
*
* The window length is part of the bucket number, so shortening or
* lengthening it in the settings simply moves callers into a fresh
* bucket rather than reinterpreting counts taken under the old one.
*/
$window = CaptchaSettings::mint_window();
$bucket = (int) floor( time() / $window );
$key = self::PREFIX . substr( hash( 'sha256', $ip ), 0, 32 ) . '_' . $bucket;
$count = \get_transient( $key );
$count = is_numeric( $count ) ? (int) $count : 0;

if ( $count >= $cap ) {
return false;
}

\set_transient( $key, $count + 1, $window );

return true;
return WindowThrottle::take( self::PREFIX, CaptchaSettings::mint_cap(), CaptchaSettings::mint_window() );
}
}
Loading
Loading