feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证
This commit is contained in:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user