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
@@ -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";
}