feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证

This commit is contained in:
2026-10-06 09:26:55 +08:00
parent 224f86ec08
commit af72364078
30 changed files with 1389 additions and 73 deletions
@@ -25,10 +25,21 @@ test('account dialogs expose configured email steps, phone steps, and reference
assert.match(template, /data-aksrt-sms-scene="bind_new_phone"/);
assert.match(template, /\$account_close_icon = function_exists\('aksrt_icon_markup'\)/);
assert.match(template, /<i class="fa far fa-envelope"/);
assert.match(template, /\$account_phone_display = strlen\(\$account_phone\) > 10 \? substr_replace\(\$account_phone, '\*\*\*\*', 3, 4\)/);
assert.match(template, /\$account_phone_display = function_exists\('aksrt_phone_mask_display'\) \? aksrt_phone_mask_display\(\$account_phone\) : \$account_phone;/);
assert.match(template, /\$account_phone !== '' \? \$account_phone_display : __\('暂未绑定'/);
assert.match(template, /<span class="badg">' \. esc_html\(\$account_phone_display\)/);
assert.match(template, /name="phone" value="<\?php echo esc_attr\(\$account_phone\); \?>"/);
assert.doesNotMatch(template, /name="phone" value="/);
assert.match(template, /data-aksrt-phone-digits data-hidden-length/);
assert.match(template, /class="aksrt-phone-digit" inputmode="numeric" maxlength="1"/);
assert.match(template, /aria-label="<\?php echo esc_attr\(sprintf\(__\('手机号第 %d 位数字', 'aksrt'\), 4 \+ \$phone_digit_index\)\); \?>"/);
assert.match(template, /data-aksrt-phone-code-area<\?php echo \$phone_mask !== null \? ' hidden' : ''; \?>/);
assert.match(template, /data-aksrt-phone-digits-error/);
assert.match(javascript, /\/user\/phone-match/);
assert.match(javascript, /middle: middle/);
assert.match(javascript, /stage: 'reset_old_phone'/);
assert.match(javascript, /function initPhoneDigits\(/);
assert.match(css, /\.aksrt-settings-modal \.aksrt-phone-digit \{/);
assert.match(css, /\.aksrt-settings-modal \.aksrt-phone-digits\.has-error \.aksrt-phone-digit \{/);
assert.match(template, /aksrt_render_captcha\('change_email'\)[\s\S]*?id="email-code"/);
assert.match(template, /aksrt_render_captcha\('bind_phone'\)[\s\S]*?id="bind-phone-code"/);
assert.match(template, /if \(!\$account_oauth_new\)[\s\S]*?name="passwordold"/);
@@ -73,6 +84,10 @@ test('email update keeps both proofs through validation failures and consumes th
assert.match(send, /\['verify_email', 'change_email'\]/);
assert.match(send, /email_exists\(\$target\)/);
assert.match(send, /SendLimit::check\(\$target\)/);
assert.match(send, /SendLimit::record\(\$target\)/);
assert.doesNotMatch(send, /aksrt_account_email_rate_/);
assert.doesNotMatch(ajax, /aksrt_account_email_rate_/);
assert.match(verify, /aksrt_require_captcha\(\s*'change_email'/);
assert.match(verify, /aksrt_account_set_proof\(\$user_id, 'email_old'/);
assert.match(change, /aksrt_account_email_verification_required\(\)/);
@@ -93,8 +108,15 @@ test('phone replacement requires old-number proof and a new-number code while re
const bind = controller.slice(controller.indexOf('public function user_bind_phone('), controller.indexOf('public function test_sms_send('));
assert.match(routes, /\/user\/phone-verify[\s\S]*?user_verify_phone/);
assert.match(routes, /\/user\/phone-match[\s\S]*?user_phone_match/);
assert.match(code, /verify_old_phone/);
assert.match(code, /bind_new_phone/);
assert.match(code, /public function user_phone_match\(/);
assert.match(code, /auth_middle_matches\(\$parts, \$middle\)/);
assert.match(code, /reset_old_phone/);
assert.match(code, /填写的号码与绑定手机号不一致/);
assert.match(code, /\$phone = \$current_phone;/);
assert.doesNotMatch(code, /auth_rate_limited\('account-phone-code'/);
assert.match(verify, /auth_consume_code\('account_verify_old_phone_' \./);
assert.match(verify, /aksrt_account_phone_old_/);
assert.match(bind, /aksrt_account_phone_old_/);
@@ -113,7 +135,11 @@ test('password change retains old-password limits and captcha; recovery uses sha
assert.match(password, /get_user_meta\(\$user_id, 'oauth_new', true\)/);
assert.match(password, /wp_check_password\(\$old/);
assert.match(password, /aksrt_account_rate_limited\(\$user_id, 'change_password'\)/);
assert.match(password, /SecurityHardening::error_frequency_wait\(\)/);
assert.match(password, /SecurityHardening::error_frequency_record\(\)/);
assert.doesNotMatch(password, /aksrt_account_rate_limited/);
assert.doesNotMatch(ajax, /function aksrt_account_rate_limited/);
assert.doesNotMatch(ajax, /function aksrt_account_reset_rate/);
assert.match(password, /aksrt_require_captcha\('change_password'/);
assert.match(password, /Auth\\Service::validate_password\(\$password, \$password2\)/);
assert.doesNotMatch(password, /email.*code|password.*strength/i);
@@ -128,8 +128,12 @@ namespace {
return 'aksrt_account_code_7_' . $scene . '_' . md5(strtolower(trim($target)));
}
function account_settings_rate_key(string $scene, string $target): string {
return 'aksrt_account_email_rate_7_' . $scene . '_' . md5(strtolower(trim($target)));
function account_settings_send_cooldown_key(string $target): string {
return 'aksrt_send_cd_' . md5(strtolower(trim($target)));
}
function account_settings_error_frequency_key(): string {
return 'aksrt_err_freq_test';
}
function account_settings_code(string $scene, string $target): string {
@@ -165,6 +169,39 @@ namespace AKSRT\Auth {
}
}
namespace AKSRT\Extensions {
final class SendLimit {
public static function check(string $target): array {
$sent_at = (int) get_transient('aksrt_send_cd_' . md5(strtolower(trim($target))));
if ($sent_at > 0 && time() - $sent_at < 60) {
return ['ok' => false, 'message' => 'cooldown', 'remaining' => 60 - (time() - $sent_at)];
}
return ['ok' => true, 'message' => '', 'remaining' => 0];
}
public static function record(string $target): int {
set_transient('aksrt_send_cd_' . md5(strtolower(trim($target))), time(), 60);
return 60;
}
}
final class SecurityHardening {
public static function error_frequency_wait(): int {
$state = get_transient('aksrt_err_freq_test');
$count = is_array($state) ? (int) ($state['count'] ?? 0) : 0;
if ($count < 5) return 0;
return max(0, ($count >= 10 ? 180 : 30) - (time() - (int) ($state['last'] ?? 0)));
}
public static function error_frequency_record(): void {
$state = get_transient('aksrt_err_freq_test');
$count = is_array($state) ? (int) ($state['count'] ?? 0) + 1 : 1;
set_transient('aksrt_err_freq_test', ['count' => $count, 'last' => time()], 600);
}
public static function error_frequency_clear(): void {
delete_transient('aksrt_err_freq_test');
}
}
}
namespace {
$GLOBALS['aksrt_account_user'] = new WP_User(7, 'old@example.test', 'Test User', 'hash:OldPass123');
$send_action = 'wp_ajax_aksrt_send_account_email_code';
@@ -181,6 +218,9 @@ namespace {
$old_send = account_settings_request($send_action, ['scene' => 'verify_email']);
account_settings_check(!empty($old_send['success']), 'old email verification code could not be sent');
account_settings_check($old_send['data']['countdown'] === 60, 'old email code send did not surface the cooldown countdown');
$cooldown_send = account_settings_request($send_action, ['scene' => 'verify_email']);
account_settings_check(empty($cooldown_send['success']), 'email code resend was not blocked by the send cooldown');
$old_code = account_settings_code('verify_email', 'old@example.test');
$bad_old_code = account_settings_request($verify_action, ['code' => 'wrong']);
account_settings_check(empty($bad_old_code['success']), 'email verification accepted an incorrect old-email code');
@@ -191,7 +231,7 @@ namespace {
account_settings_check(empty($expired_old_code['success']), 'email verification accepted an expired old-email code');
account_settings_check(get_transient(account_settings_code_key('verify_email', 'old@example.test')) === false, 'expired old-email code was not discarded');
account_settings_check(!aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'expired old-email code issued an old-email proof');
delete_transient(account_settings_rate_key('verify_email', 'old@example.test'));
delete_transient(account_settings_send_cooldown_key('old@example.test'));
$old_send = account_settings_request($send_action, ['scene' => 'verify_email']);
account_settings_check(!empty($old_send['success']), 'old email code could not be resent after expiry');
$old_code = account_settings_code('verify_email', 'old@example.test');
@@ -220,7 +260,7 @@ namespace {
account_settings_check(empty($expired_new_code['success']), 'email update accepted an expired new-email code');
account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'expired new-email code validation consumed the old-email proof');
account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) === false, 'expired new-email code was not discarded');
delete_transient(account_settings_rate_key('change_email', $new_email));
delete_transient(account_settings_send_cooldown_key($new_email));
$new_send = account_settings_request($send_action, ['scene' => 'change_email', 'new_email' => $new_email]);
account_settings_check(!empty($new_send['success']), 'new email code could not be resent after expiry');
$new_code = account_settings_code('change_email', $new_email);
@@ -266,11 +306,11 @@ namespace {
}
$limited_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']);
account_settings_check(empty($limited_password['success']), 'password change did not apply its old-password attempt limit');
delete_transient('aksrt_account_rate_change_password_7');
delete_transient(account_settings_error_frequency_key());
$changed_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']);
account_settings_check(!empty($changed_password['success']), 'password change rejected the correct old password');
account_settings_check($GLOBALS['aksrt_account_user']->user_pass === 'hash:NewPass123', 'password change did not persist the new password');
account_settings_check(get_transient('aksrt_account_rate_change_password_7') === false, 'successful password change did not reset its error limit');
account_settings_check(get_transient(account_settings_error_frequency_key()) === false, 'successful password change did not reset its error limit');
update_user_meta(7, 'oauth_new', 1);
$oauth_password = account_settings_request($change_password_action, ['password' => 'OAuthPass123', 'password2' => 'OAuthPass123']);
@@ -0,0 +1,124 @@
'use strict';
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('site security schema registers the 网站安全 page without 人机验证 fields', () => {
const schema = read('src/Admin/Schema/SchemaExtensionsSecurity.php');
const registry = read('src/Admin/Schema/SchemaExtensions.php');
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
const clientMirror = read('assets/admin/src/schema/sections/extensions/security.js');
const clientIndex = read('assets/admin/src/schema/sections/extensions/index.js');
const nav = read('assets/admin/src/constants/sections/extensions.js');
assert.match(registry, /'security'\s+=> SchemaExtensionsSecurity::build\(\)/);
assert.match(schema, /'key'\s+=> 'security'/);
assert.match(schema, /'title'\s+=> '网站安全'/);
for (const key of [
'security_brush_post_10min', 'security_brush_post_day',
'security_brush_comment_10min', 'security_brush_comment_day',
'security_brush_upload_10min', 'security_brush_upload_day',
'security_brush_register_10min', 'security_brush_register_day',
'security_brush_order_10min', 'security_brush_order_day',
'security_captcha_send_cooldown', 'security_captcha_to_hour', 'security_captcha_to_day',
'security_captcha_ip_10min', 'security_captcha_ip_day',
'security_error_frequency_limit', 'security_disabled_pingback', 'security_disabled_xmlrpc',
'security_user_disable_admin', 'close_attachment_page',
]) {
assert.match(schema, new RegExp(`'${key}'`), `missing schema field ${key}`);
assert.match(clientMirror, new RegExp(`${key}`), `missing client mirror field ${key}`);
}
assert.doesNotMatch(schema + clientMirror, /verification_|geetest|tcaptcha|user_verification_type/);
assert.doesNotMatch(permissions, /close_attachment_page/);
assert.match(clientIndex, /import \{ securitySchema \} from '\.\/security\.js'/);
assert.match(clientIndex, /security: securitySchema/);
assert.match(nav, /key: 'extensions-security'/);
assert.match(nav, /route: '\/extensions\/security'/);
assert.match(nav, /section: 'security'/);
});
test('security settings are consumed at runtime and initialized on boot', () => {
const runtime = read('src/Settings/ExtensionRuntime.php');
const bootstrap = read('functions.php');
assert.match(runtime, /public static function get_security_settings\(\): array/);
assert.match(bootstrap, /\\AKSRT\\Extensions\\SecurityHardening::get_instance\(\)->init\(\)/);
});
test('hardening switches wire the documented WordPress hooks', () => {
const hardening = read('src/Extensions/SecurityHardening.php');
assert.match(hardening, /add_filter\('xmlrpc_enabled', '__return_false'\)/);
assert.match(hardening, /add_filter\('xmlrpc_methods', '__return_empty_array'\)/);
assert.match(hardening, /add_action\('pre_ping', \[\$this, 'disable_self_pingback'\]\)/);
assert.match(hardening, /add_action\('admin_init', \[\$this, 'restrict_admin_access'\]\)/);
assert.match(hardening, /wp_doing_ajax\(\) \|\| wp_doing_cron\(\)/);
assert.match(hardening, /current_user_can\('manage_options'\)/);
assert.match(hardening, /brush_allow\(string \$scope, string \$subject, int \$cap_10, int \$cap_day\)/);
assert.match(hardening, /error_frequency_wait\(\): int/);
assert.match(hardening, /error_frequency_record\(\): void/);
assert.match(hardening, /error_frequency_clear\(\): void/);
});
test('verification-code sends are funneled through the configurable send limiter', () => {
const sms = read('src/Sms/SMS.php');
const sendLimit = read('src/Extensions/SendLimit.php');
const ajax = read('inc/modules/user-parts/account-ajax.php');
const controller = read('src/Admin/UserControllerAuthPhoneOperations.php');
assert.match(sms, /SendLimit::check\(\$phone\)/);
assert.match(sms, /SendLimit::record\(\$phone\)/);
assert.doesNotMatch(sms, /RATE_LIMIT|DAILY_LIMIT|aksrt_sms_rate_|aksrt_sms_daily_/);
assert.match(sendLimit, /security_captcha_send_cooldown/);
assert.match(sendLimit, /security_captcha_to_hour/);
assert.match(sendLimit, /security_captcha_to_day/);
assert.match(sendLimit, /security_captcha_ip_10min/);
assert.match(sendLimit, /security_captcha_ip_day/);
assert.match(ajax, /SendLimit::check\(\$target\)/);
assert.match(ajax, /SendLimit::record\(\$target\)/);
assert.match(controller, /SendLimit::check\(\$target\)/);
assert.match(controller, /SendLimit::record\(\$target\)/);
});
test('content brush limits are wired into post, comment, upload, register, and order flows', () => {
const frontSubmit = read('src/Core/FrontSubmitSave.php');
const moderation = read('src/Auth/Moderation.php');
const upload = read('src/Attachment/UploadLimits.php');
const registerEmail = read('src/Auth/ServicePart1a.php');
const registerPhone = read('src/Auth/ServicePart1b.php');
const order = read('src/Payment/OrderRepositoryCore.php');
assert.match(frontSubmit, /brush_allow\('post', \$brush_subject, \(int\) \$security\['security_brush_post_10min'\], \(int\) \$security\['security_brush_post_day'\]\)/);
assert.match(moderation, /brush_allow\(\s*'comment'/);
assert.match(upload, /add_filter\('wp_handle_upload_prefilter', \[\$this, 'brush_limit_upload'\], 90\)/);
assert.match(upload, /brush_allow\('upload', \$subject, \(int\) \$security\['security_brush_upload_10min'\], \(int\) \$security\['security_brush_upload_day'\]\)/);
assert.match(registerEmail, /brush_allow\('register'/);
assert.match(registerPhone, /brush_allow\('register'/);
assert.match(order, /brush_allow\('order', \$brush_subject, \(int\) \$security\['security_brush_order_10min'\], \(int\) \$security\['security_brush_order_day'\]\)/);
});
test('phone masking is shared by the account UI and adapts to international numbers', () => {
const helper = read('inc/helpers/aksrt-phone.php');
const bootstrap = read('inc/bootstrap.php');
const template = read('inc/helpers/user-center-renderers/settings-account.php');
assert.match(bootstrap, /inc\/helpers\/aksrt-phone\.php/);
assert.match(helper, /function aksrt_phone_mask_parts\(string \$phone\): \?array/);
assert.match(helper, /function aksrt_phone_mask_display\(string \$phone\): string/);
assert.match(helper, /min\(11, max\(4, \$length - 7\)\)/);
assert.doesNotMatch(template, /substr_replace\(\$account_phone, '\*\*\*\*', 3, 4\)/);
assert.match(template, /aksrt_phone_mask_parts\(\$account_phone\)/);
assert.match(template, /aksrt_phone_mask_display\(\$account_phone\)/);
});
test('site security runtime contract harness passes', () => {
const harness = path.join(root, 'tests', 'widget-system', 'site-security-harness.php');
const output = execFileSync('php', [harness], { encoding: 'utf8' });
assert.match(output, /Site security harness passed/);
});
@@ -0,0 +1,229 @@
<?php
/** Site security (网站安全) runtime contract harness: brush limits, send limiter, error frequency, phone masking, schema. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('MINUTE_IN_SECONDS', 60);
define('HOUR_IN_SECONDS', 3600);
define('DAY_IN_SECONDS', 86400);
$GLOBALS['aksrt_security_settings'] = [];
$GLOBALS['aksrt_security_transients'] = [];
$GLOBALS['aksrt_security_user_id'] = 7;
$_SERVER['REMOTE_ADDR'] = '203.0.113.9';
function __(string $text, string $domain = ''): string { return $text; }
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
function get_current_user_id(): int { return (int) $GLOBALS['aksrt_security_user_id']; }
function current_user_can(string $capability): bool { return false; }
function wp_date(string $format): string { return date($format); }
function add_filter(string $hook, callable $callback, int $priority = 10, int $args = 1): void {}
function add_action(string $hook, callable $callback, int $priority = 10, int $args = 1): void {}
function set_transient(string $key, mixed $value, int $expiration = 0): bool {
$GLOBALS['aksrt_security_transients'][$key] = ['value' => $value, 'expires' => $expiration > 0 ? time() + $expiration : 0];
return true;
}
function get_transient(string $key): mixed {
$row = $GLOBALS['aksrt_security_transients'][$key] ?? null;
if (!is_array($row)) return false;
if (!empty($row['expires']) && $row['expires'] <= time()) {
unset($GLOBALS['aksrt_security_transients'][$key]);
return false;
}
return $row['value'] ?? false;
}
function delete_transient(string $key): bool {
unset($GLOBALS['aksrt_security_transients'][$key]);
return true;
}
function site_security_check(bool $condition, string $message): void {
if (!$condition) throw new \RuntimeException($message);
}
function site_security_brush_minute_key(string $scope, string $subject): string {
return sprintf('aksrt_brush_%s_m_%s_%s%d', $scope, md5($subject), wp_date('YmdH'), intdiv((int) wp_date('i'), 10));
}
function site_security_brush_day_key(string $scope, string $subject): string {
return sprintf('aksrt_brush_%s_d_%s_%s', $scope, md5($subject), wp_date('Ymd'));
}
require dirname(__DIR__, 2) . '/src/Settings/ExtensionRuntime.php';
require dirname(__DIR__, 2) . '/src/Extensions/SecurityHardening.php';
require dirname(__DIR__, 2) . '/src/Extensions/SendLimit.php';
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-phone.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsIP.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php';
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php';
}
namespace AKSRT\Settings {
final class Settings {
public static function get_all(): array { return $GLOBALS['aksrt_security_settings']; }
}
}
namespace {
use AKSRT\Admin\Schema\SchemaExtensions;
use AKSRT\Extensions\SecurityHardening;
use AKSRT\Extensions\SendLimit;
use AKSRT\Settings\ExtensionRuntime;
// 1) Phone masking: domestic hides 4 digits, international hides length-7 (min 4), short numbers are unmaskable.
$domestic = aksrt_phone_mask_parts('13812341234');
site_security_check($domestic !== null, 'domestic number should be maskable');
site_security_check($domestic['prefix'] === '138' && $domestic['hidden'] === '1234' && $domestic['suffix'] === '1234', 'domestic mask split is wrong');
site_security_check($domestic['hidden_length'] === 4, 'domestic mask should hide 4 digits');
site_security_check(aksrt_phone_mask_display('13812341234') === '138****1234', 'domestic masked display is wrong');
$intl = aksrt_phone_mask_parts('+8613812341234');
site_security_check($intl !== null, 'international number should be maskable');
site_security_check($intl['hidden_length'] === 6, 'international mask should hide length-7 digits');
site_security_check($intl['hidden'] === '381234', 'international hidden segment is wrong');
site_security_check($intl['leading_plus'] === true, 'leading plus flag is wrong');
site_security_check(aksrt_phone_mask_display('+8613812341234') === '+861******1234', 'international masked display is wrong');
$eight = aksrt_phone_mask_parts('12345678');
site_security_check($eight !== null && $eight['hidden_length'] === 4 && $eight['suffix'] === '8', 'short mask should still hide 4 digits with a 1-digit suffix');
site_security_check(aksrt_phone_mask_parts('1234567') === null, 'numbers with fewer than 8 digits must be unmaskable');
site_security_check(aksrt_phone_mask_parts('') === null, 'empty number must be unmaskable');
site_security_check(aksrt_phone_mask_display('1234567') === '1234567', 'unmaskable numbers must display raw');
// 2) Brush limits: 10-minute and daily caps, 0 = unlimited, admin exempt handled by callers.
$allowed = SecurityHardening::brush_allow('post', '12', 2, 0);
site_security_check($allowed === true, 'first post within cap was blocked');
site_security_check(SecurityHardening::brush_allow('post', '12', 2, 0) === true, 'second post within cap was blocked');
site_security_check((int) get_transient(site_security_brush_minute_key('post', '12')) === 2, 'post attempts were not counted');
site_security_check(SecurityHardening::brush_allow('post', '12', 2, 0) !== true, 'post 10-minute cap was not enforced');
$GLOBALS['aksrt_security_transients'] = [];
site_security_check(SecurityHardening::brush_allow('post', '12', 0, 2) === true, 'first daily post was blocked');
site_security_check(SecurityHardening::brush_allow('post', '12', 0, 2) === true, 'second daily post was blocked');
site_security_check(SecurityHardening::brush_allow('post', '12', 0, 2) !== true, 'post daily cap was not enforced');
$GLOBALS['aksrt_security_transients'] = [];
site_security_check(SecurityHardening::brush_allow('order', '203.0.113.9', 0, 0) === true, 'disabled caps should never block');
site_security_check(get_transient(site_security_brush_minute_key('order', '203.0.113.9')) === false, 'disabled caps should not count attempts');
// 3) Error frequency: silent under 5 failures, 30s window 5-9, 180s window at 10+.
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'error frequency should be silent with no failures');
for ($failure = 0; $failure < 4; $failure++) {
SecurityHardening::error_frequency_record();
}
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'error frequency limited before reaching 5 failures');
SecurityHardening::error_frequency_record();
$wait = SecurityHardening::error_frequency_wait();
site_security_check($wait > 0 && $wait <= 30, 'error frequency did not apply the 30s window after 5 failures');
for ($failure = 0; $failure < 5; $failure++) {
SecurityHardening::error_frequency_record();
}
$long_wait = SecurityHardening::error_frequency_wait();
site_security_check($long_wait > 30 && $long_wait <= 180, 'error frequency did not apply the 180s window after 10 failures');
SecurityHardening::error_frequency_clear();
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'error frequency clear did not reset the counter');
// Disabling the switch must silence the limit.
$GLOBALS['aksrt_security_settings']['security_error_frequency_limit'] = false;
SecurityHardening::error_frequency_record();
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'disabling security_error_frequency_limit did not silence the limiter');
$GLOBALS['aksrt_security_settings'] = [];
// 4) Send limit: cooldown, recipient hourly/daily, IP 10-minute/daily caps; 0 = unlimited.
$GLOBALS['aksrt_security_transients'] = [];
$first = SendLimit::check('user@example.test');
site_security_check($first['ok'] === true, 'first verification send was blocked');
site_security_check(SendLimit::record('user@example.test') === 60, 'send limit did not return the cooldown');
$second = SendLimit::check('user@example.test');
site_security_check($second['ok'] === false && $second['remaining'] > 0, 'send cooldown was not enforced');
site_security_check(SendLimit::check('other@example.test')['ok'] === true, 'send cooldown leaked across recipients');
$GLOBALS['aksrt_security_transients'] = [];
for ($send = 0; $send < 15; $send++) {
site_security_check(SendLimit::record('user@example.test') === 60, 'send record failed');
}
site_security_check(SendLimit::check('user@example.test')['ok'] === false, 'recipient hourly cap was not enforced');
site_security_check(SendLimit::check('user2@example.test')['ok'] === true, 'recipient hourly cap leaked across recipients');
$GLOBALS['aksrt_security_transients'] = [];
for ($send = 0; $send < 20; $send++) {
SendLimit::record('user@example.test');
}
site_security_check(SendLimit::check('user2@example.test')['ok'] === false, 'IP 10-minute cap was not enforced');
$GLOBALS['aksrt_security_transients'] = [];
$GLOBALS['aksrt_security_settings']['security_captcha_send_cooldown'] = 0;
$GLOBALS['aksrt_security_settings']['security_captcha_to_hour'] = 0;
$GLOBALS['aksrt_security_settings']['security_captcha_to_day'] = 0;
$GLOBALS['aksrt_security_settings']['security_captcha_ip_10min'] = 0;
$GLOBALS['aksrt_security_settings']['security_captcha_ip_day'] = 0;
for ($send = 0; $send < 100; $send++) {
site_security_check(SendLimit::record('user@example.test') === 0, 'disabled send caps should not cooldown');
}
site_security_check(SendLimit::check('user@example.test')['ok'] === true, 'disabled send caps still blocked sends');
$GLOBALS['aksrt_security_settings'] = [];
// 5) Runtime defaults mirror the schema defaults.
$security = ExtensionRuntime::get_security_settings();
site_security_check($security['security_brush_post_10min'] === 20 && $security['security_brush_post_day'] === 50, 'post cap defaults are wrong');
site_security_check($security['security_brush_comment_10min'] === 20 && $security['security_brush_comment_day'] === 100, 'comment cap defaults are wrong');
site_security_check($security['security_brush_upload_10min'] === 50 && $security['security_brush_upload_day'] === 200, 'upload cap defaults are wrong');
site_security_check($security['security_brush_register_10min'] === 20 && $security['security_brush_register_day'] === 100, 'register cap defaults are wrong');
site_security_check($security['security_brush_order_10min'] === 30 && $security['security_brush_order_day'] === 100, 'order cap defaults are wrong');
site_security_check($security['security_captcha_send_cooldown'] === 60, 'send cooldown default is wrong');
site_security_check($security['security_captcha_to_hour'] === 15 && $security['security_captcha_to_day'] === 30, 'recipient cap defaults are wrong');
site_security_check($security['security_captcha_ip_10min'] === 20 && $security['security_captcha_ip_day'] === 50, 'IP cap defaults are wrong');
site_security_check($security['security_error_frequency_limit'] === true, 'error frequency default is wrong');
site_security_check($security['security_disabled_pingback'] === true && $security['security_disabled_xmlrpc'] === true, 'hardening switch defaults are wrong');
site_security_check($security['security_user_disable_admin'] === true && $security['close_attachment_page'] === true, 'access switch defaults are wrong');
$GLOBALS['aksrt_security_settings']['security_brush_post_10min'] = '35';
$GLOBALS['aksrt_security_settings']['close_attachment_page'] = 0;
$stored = ExtensionRuntime::get_security_settings();
site_security_check($stored['security_brush_post_10min'] === 35, 'stored cap override was not honored');
site_security_check($stored['close_attachment_page'] === false, 'stored switch override was not honored');
$GLOBALS['aksrt_security_settings'] = [];
// 6) Schema: security section is registered, defaults match, and no 人机验证 fields exist.
$extensions = SchemaExtensions::build();
site_security_check(array_key_exists('security', $extensions['sections']), 'security section is not registered under extensions');
$security_schema = $extensions['sections']['security'];
$group_keys = array_column($security_schema['groups'], 'key');
foreach (['brush_post', 'brush_comment', 'brush_upload', 'brush_register', 'brush_order', 'brush_captcha', 'harden'] as $expected_group) {
site_security_check(in_array($expected_group, $group_keys, true), 'missing security group: ' . $expected_group);
}
$fields = [];
foreach ($security_schema['groups'] as $group) {
foreach ($group['fields'] as $field) {
$fields[$field['key']] = $field;
}
}
$expected_fields = [
'security_brush_post_10min' => 20, 'security_brush_post_day' => 50,
'security_brush_comment_10min' => 20, 'security_brush_comment_day' => 100,
'security_brush_upload_10min' => 50, 'security_brush_upload_day' => 200,
'security_brush_register_10min' => 20, 'security_brush_register_day' => 100,
'security_brush_order_10min' => 30, 'security_brush_order_day' => 100,
'security_captcha_send_cooldown' => 60, 'security_captcha_to_hour' => 15, 'security_captcha_to_day' => 30,
'security_captcha_ip_10min' => 20, 'security_captcha_ip_day' => 50,
];
foreach ($expected_fields as $key => $default) {
site_security_check(isset($fields[$key]), 'missing security field: ' . $key);
site_security_check((int) $fields[$key]['default'] === $default, 'wrong default for ' . $key);
site_security_check((int) $fields[$key]['min'] === 0, 'missing zero=unlimited min for ' . $key);
}
foreach (['security_error_frequency_limit', 'security_disabled_pingback', 'security_disabled_xmlrpc', 'security_user_disable_admin', 'close_attachment_page'] as $switch_key) {
site_security_check(isset($fields[$switch_key]) && $fields[$switch_key]['type'] === 'switch', 'missing hardening switch: ' . $switch_key);
site_security_check(!empty($fields[$switch_key]['default']), 'hardening switch should default to on: ' . $switch_key);
}
foreach (array_keys($fields) as $field_key) {
site_security_check(!str_starts_with($field_key, 'verification_'), '人机验证 field leaked into security schema: ' . $field_key);
site_security_check(!str_contains($field_key, 'geetest') && !str_contains($field_key, 'tcaptcha'), 'provider captcha field leaked into security schema: ' . $field_key);
}
site_security_check(isset($fields['close_attachment_page']), 'close_attachment_page was not moved into the security schema');
echo "Site security harness passed\n";
}