231 lines
14 KiB
PHP
231 lines
14 KiB
PHP
<?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/SchemaExtensionsSystem.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";
|
|
}
|