From af723640787872bb7569585300346cc6ac2809d5 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 6 Oct 2026 09:26:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E6=96=B0=E5=A2=9E=E7=BD=91?= =?UTF-8?q?=E7=AB=99=E5=AE=89=E5=85=A8=E6=89=A9=E5=B1=95=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E4=BF=AE=E6=94=B9=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/constants/sections/extensions.js | 10 + .../src/schema/sections/extensions/index.js | 2 + .../schema/sections/extensions/security.js | 105 ++++++++ .../src/schema/sections/permissions/index.js | 2 - assets/css/routes/user-center/settings.css | 42 ++++ assets/js/routes/user-center.js | 140 ++++++++++- functions.php | 3 + inc/bootstrap.php | 1 + inc/helpers/aksrt-phone.php | 68 ++++++ .../settings-account.php | 26 +- inc/modules/user-parts/account-ajax.php | 51 ++-- src/Admin/Schema/SchemaExtensions.php | 1 + src/Admin/Schema/SchemaExtensionsSecurity.php | 56 +++++ src/Admin/Schema/SchemaPermissions.php | 1 - src/Admin/UserController.php | 5 + .../UserControllerAuthPhoneOperations.php | 115 ++++++++- src/Attachment/UploadLimits.php | 22 ++ src/Auth/Moderation.php | 19 ++ src/Auth/ServicePart1a.php | 10 + src/Auth/ServicePart1b.php | 10 + src/Core/FrontSubmitSave.php | 12 + src/Extensions/SecurityHardening.php | 151 ++++++++++++ src/Extensions/SendLimit.php | 96 ++++++++ src/Payment/OrderRepositoryCore.php | 13 + src/Settings/ExtensionRuntime.php | 35 +++ src/Sms/SMS.php | 29 +-- .../account-settings-contract.test.cjs | 32 ++- .../account-settings-harness.php | 52 +++- .../site-security-contract.test.cjs | 124 ++++++++++ tests/widget-system/site-security-harness.php | 229 ++++++++++++++++++ 30 files changed, 1389 insertions(+), 73 deletions(-) create mode 100644 assets/admin/src/schema/sections/extensions/security.js create mode 100644 inc/helpers/aksrt-phone.php create mode 100644 src/Admin/Schema/SchemaExtensionsSecurity.php create mode 100644 src/Extensions/SecurityHardening.php create mode 100644 src/Extensions/SendLimit.php create mode 100644 tests/widget-system/site-security-contract.test.cjs create mode 100644 tests/widget-system/site-security-harness.php diff --git a/assets/admin/src/constants/sections/extensions.js b/assets/admin/src/constants/sections/extensions.js index 4be63a92..830c0695 100644 --- a/assets/admin/src/constants/sections/extensions.js +++ b/assets/admin/src/constants/sections/extensions.js @@ -36,6 +36,16 @@ export const extensionsSection = { canonicalDomain: 'extensions', section: 'push', }), + schemaPage({ + key: 'extensions-security', + name: 'ExtensionsSecurity', + label: '网站安全', + route: '/extensions/security', + title: '网站安全', + desc: '防刷限制、验证码发送限制与站点安全加固。', + canonicalDomain: 'extensions', + section: 'security', + }), customPage({ key: 'extensions-ai', name: 'ExtensionAI', diff --git a/assets/admin/src/schema/sections/extensions/index.js b/assets/admin/src/schema/sections/extensions/index.js index 94711ee2..7b909c7b 100644 --- a/assets/admin/src/schema/sections/extensions/index.js +++ b/assets/admin/src/schema/sections/extensions/index.js @@ -6,9 +6,11 @@ import { ipSchema } from './ip.js' import { auditSchema } from './audit.js' import { pushSchema } from './push.js' +import { securitySchema } from './security.js' export const extensionsSchemas = { ip: ipSchema, audit: auditSchema, push: pushSchema, + security: securitySchema, } diff --git a/assets/admin/src/schema/sections/extensions/security.js b/assets/admin/src/schema/sections/extensions/security.js new file mode 100644 index 00000000..8b6fa1bb --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/security.js @@ -0,0 +1,105 @@ +export const securitySchema = { + key: 'extensions.security', + title: '网站安全', + icon: 'fa fas fa-umbrella', + description: '防刷限制、验证码发送限制与站点安全加固。', + defaults: { + 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, + security_error_frequency_limit: true, + security_disabled_pingback: true, + security_disabled_xmlrpc: true, + security_user_disable_admin: true, + close_attachment_page: true, + }, + groups: [ + { + key: 'brush_post', + title: '[防刷机制]发布限制', + icon: 'fa fas fa-pen', + description: '限制单个用户发布文章、帖子、板块的总数量(为0则不限制)。', + fields: [ + { key: 'security_brush_post_10min', label: '10分钟内最多发布 (篇)', type: 'number', default: 20, min: 0, max: 200 }, + { key: 'security_brush_post_day', label: '一天内最多发布 (篇)', type: 'number', default: 50, min: 0, max: 2000 }, + ], + }, + { + key: 'brush_comment', + title: '[防刷机制]评论限制', + icon: 'fa fas fa-comments', + description: '限制单个用户发布评论的总数量(为0则不限制)。', + fields: [ + { key: 'security_brush_comment_10min', label: '10分钟内最多评论 (条)', type: 'number', default: 20, min: 0, max: 200 }, + { key: 'security_brush_comment_day', label: '一天内最多评论 (条)', type: 'number', default: 100, min: 0, max: 2000, help: '如果启用了免登陆可评论,则根据用户IP地址判断' }, + ], + }, + { + key: 'brush_upload', + title: '[防刷机制]上传限制', + icon: 'fa fas fa-upload', + description: '限制单个用户上传内容的总数量(为0则不限制)。防刷机制主要为了防止用户恶意多次提交内容,请根据网站的用户量合理设置,此机制不会限制管理员。', + fields: [ + { key: 'security_brush_upload_10min', label: '10分钟内最多上传 (个)', type: 'number', default: 50, min: 0, max: 200 }, + { key: 'security_brush_upload_day', label: '一天内最多上传 (个)', type: 'number', default: 200, min: 0, max: 2000 }, + ], + }, + { + key: 'brush_register', + title: '[防刷机制]注册限制', + icon: 'fa fas fa-user-plus', + description: '限制每个IP地址用户注册数量,此项无法判断管理员身份,所以管理员也会受到限制。', + fields: [ + { key: 'security_brush_register_10min', label: '10分钟内最多注册 (个)', type: 'number', default: 20, min: 0, max: 200 }, + { key: 'security_brush_register_day', label: '一天内最多注册 (个)', type: 'number', default: 100, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' }, + ], + }, + { + key: 'brush_order', + title: '[防刷机制]下单限制', + icon: 'fa fas fa-shopping-cart', + description: '限制每个用户下单数量,未登录用户按IP地址判断。', + fields: [ + { key: 'security_brush_order_10min', label: '10分钟内最多下单 (个订单)', type: 'number', default: 30, min: 0, max: 200 }, + { key: 'security_brush_order_day', label: '一天内最多下单 (个订单)', type: 'number', default: 100, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' }, + ], + }, + { + key: 'brush_captcha', + title: '[防刷机制]验证码发送限制', + icon: 'fa fas fa-paper-plane', + description: '限制验证码邮件/短信的发送频率,防止恶意程序批量轰炸特定邮箱或手机号。', + fields: [ + { key: 'security_captcha_send_cooldown', label: '同一收件人/IP发送冷却 (秒)', type: 'number', default: 60, min: 0, max: 600, help: '同一邮箱或手机号、同一IP地址发送验证码后,需间隔此时间才可再次发送(0为不限制)' }, + { key: 'security_captcha_to_hour', label: '同一收件人1小时内最多 (次)', type: 'number', default: 15, min: 0, max: 500 }, + { key: 'security_captcha_to_day', label: '同一收件人一天内最多 (次)', type: 'number', default: 30, min: 0, max: 1000 }, + { key: 'security_captcha_ip_10min', label: '同一IP 10分钟内最多 (次)', type: 'number', default: 20, min: 0, max: 500 }, + { key: 'security_captcha_ip_day', label: '同一IP一天内最多 (次)', type: 'number', default: 50, min: 0, max: 2000, help: '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)' }, + ], + }, + { + key: 'harden', + title: '安全加固', + icon: 'fa fas fa-shield-alt', + fields: [ + { key: 'security_error_frequency_limit', label: '错误频率限制', type: 'switch', default: true, help: '用户进行密码、邮箱、手机号、验证码等验证时,验证错误次数超过5次后限制30秒验证一次,错误超过10次后,限制3分钟验证一次,有效防止暴力破解' }, + { key: 'security_disabled_pingback', label: '防pingback攻击', type: 'switch', default: true }, + { key: 'security_disabled_xmlrpc', label: '禁用XML-RPC', type: 'switch', default: true, help: '如果无需使用xmlrpc功能,建议直接删除WordPress目录下的xmlrpc.php文件' }, + { key: 'security_user_disable_admin', label: '非管理员禁止进入后台', type: 'switch', default: true, help: '为了安全,如非必要请勿允许非管理员进入后台' }, + { key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', default: true, help: '非管理员前台不显示附件(图片、文件等)页面,直接显示404,能有效提高文件的安全性' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js index 2eea9afc..3523db93 100644 --- a/assets/admin/src/schema/sections/permissions/index.js +++ b/assets/admin/src/schema/sections/permissions/index.js @@ -240,7 +240,6 @@ export const permissionsSchemas = { ], newfilename: false, newfilename_type: 'time', - close_attachment_page: true, }, groups: [ { @@ -333,7 +332,6 @@ export const permissionsSchemas = { { label: '随机字符串', value: 'random' }, ], }, - { key: 'close_attachment_page', label: '非管理员屏蔽附件页面', type: 'switch', help: '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。' }, ], }, ], diff --git a/assets/css/routes/user-center/settings.css b/assets/css/routes/user-center/settings.css index 50d25f3a..9a4d1087 100644 --- a/assets/css/routes/user-center/settings.css +++ b/assets/css/routes/user-center/settings.css @@ -716,6 +716,48 @@ .aksrt-settings-modal .code-send-btn.is-counting { pointer-events: none; opacity: .6; } .aksrt-settings-modal .aksrt-captcha-wrap { margin: 12px 0 8px; } +/* 修改手机:脱敏中间位数字框 */ +.aksrt-settings-modal .aksrt-phone-digits { + display: flex; + justify-content: center; + gap: 10px; + margin-bottom: 10px; +} + +.aksrt-settings-modal .aksrt-phone-digit { + width: 48px; + height: 52px; + padding: 0; + border: 1px solid var(--color-line-soft, rgba(0, 0, 0, 0.12)); + border-radius: 8px; + background: transparent; + color: var(--color-ink, #333); + font-size: 22px; + font-weight: 600; + line-height: 1; + text-align: center; + caret-color: var(--color-primary, #2a7de1); + outline: none; + transition: border-color .15s ease, box-shadow .15s ease; +} + +.aksrt-settings-modal .aksrt-phone-digit:focus { + border-color: var(--color-primary, #2a7de1); + box-shadow: 0 0 0 3px rgba(42, 125, 225, 0.15); +} + +.aksrt-settings-modal .aksrt-phone-digits.has-error .aksrt-phone-digit { + border-color: var(--color-danger, #e02020); + box-shadow: 0 0 0 3px rgba(224, 32, 32, 0.12); +} + +.aksrt-settings-modal .aksrt-phone-digits__error { + margin: 0 0 12px; + color: var(--color-danger, #e02020); + font-size: 13px; + text-align: center; +} + @media (max-width: 640px) { .aksrt-modal-runtime.aksrt-user-settings-modal.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog { width: 100%; diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 6c332210..c47a702b 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -824,16 +824,30 @@ function sendPhoneCode(button, form, stage) { if (!form || button.disabled) return; - var phone = String((form.querySelector('[name="phone"]') || {}).value || '').trim(); - if (!phone) { notyf('请输入手机号', 'warning'); return; } + var payload = { stage: stage }; + if (stage === 'verify_old_phone') { + var dialog = form.closest('[data-account-dialog="phone"]'); + var boxes = dialog ? phoneDigitBoxes(dialog) : []; + if (boxes.length) { + var hiddenLength = parseInt(dialog.querySelector('[data-aksrt-phone-digits]').getAttribute('data-hidden-length'), 10) || boxes.length; + var middle = phoneDigitsValue(dialog); + if (middle.length !== hiddenLength) { notyf('请先补全手机号缺失的数字', 'warning'); return; } + payload.middle = middle; + } + } else { + var phone = String((form.querySelector('[name="phone"]') || {}).value || '').trim(); + if (!phone) { notyf('请输入手机号', 'warning'); return; } + payload.phone = phone; + } runCaptcha('bind_phone', form).then(function (captchaPayload) { if (captchaPayload === null) return null; var restRoot = settingsRestRoot(); var nonce = settingsRestNonce(); + Object.keys(captchaPayload || {}).forEach(function (key) { payload[key] = captchaPayload[key]; }); var request = fetch(restRoot + '/user/phone-code', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce }, - body: JSON.stringify(Object.assign({ phone: phone, stage: stage }, captchaPayload || {})) + body: JSON.stringify(payload) }); button.disabled = true; return request; @@ -902,6 +916,116 @@ }).finally(function () { resetCaptcha('bind_phone', form); }); } + /* ---- 修改手机:脱敏中间位数字框(格数随号码长度变化) ---- */ + function phoneDigitBoxes(dialog) { + return Array.prototype.slice.call(dialog.querySelectorAll('[data-aksrt-phone-digits] .aksrt-phone-digit')); + } + + function phoneDigitsValue(dialog) { + return phoneDigitBoxes(dialog).map(function (input) { return String(input.value || '').replace(/\D/g, ''); }).join(''); + } + + function setPhoneDigitsError(dialog, message) { + var group = dialog.querySelector('[data-aksrt-phone-digits]'); + var error = dialog.querySelector('[data-aksrt-phone-digits-error]'); + if (error) { + error.textContent = message || ''; + error.hidden = !message; + } + if (group) group.classList.toggle('has-error', !!message); + } + + function hidePhoneCodeArea(dialog, options) { + var area = dialog.querySelector('[data-aksrt-phone-code-area]'); + if (!area) return; + var wasVisible = !area.hidden; + area.hidden = true; + area.querySelectorAll('.account-code-send,[data-aksrt-sms-trigger]').forEach(resetCountdown); + if (wasVisible && options && options.destroyCode) { + // 中间位被修改:销毁服务端已发放的老号验证码状态。 + fetch(settingsRestRoot() + '/user/phone-code', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': settingsRestNonce() }, + body: JSON.stringify({ stage: 'reset_old_phone' }) + }).catch(function () {}); + } + } + + function verifyPhoneMiddle(dialog) { + var group = dialog.querySelector('[data-aksrt-phone-digits]'); + var boxes = phoneDigitBoxes(dialog); + if (!group || !boxes.length) return; + var hiddenLength = parseInt(group.getAttribute('data-hidden-length'), 10) || boxes.length; + var middle = phoneDigitsValue(dialog); + if (middle.length !== hiddenLength) return; + + dialog.__aksrtPhoneMatchTicket = (dialog.__aksrtPhoneMatchTicket || 0) + 1; + var ticket = dialog.__aksrtPhoneMatchTicket; + fetch(settingsRestRoot() + '/user/phone-match', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': settingsRestNonce() }, + body: JSON.stringify({ middle: middle }) + }).then(function (response) { return response.json().then(function (payload) { return { ok: response.ok, payload: payload }; }); }).then(function (result) { + if (ticket !== dialog.__aksrtPhoneMatchTicket) return; + if (result.ok && result.payload.success) { + var area = dialog.querySelector('[data-aksrt-phone-code-area]'); + if (area) area.hidden = false; + setPhoneDigitsError(dialog, ''); + } else { + hidePhoneCodeArea(dialog, { destroyCode: true }); + setPhoneDigitsError(dialog, (result.payload && result.payload.message) || '填写的号码与绑定手机号不一致'); + } + }).catch(function () { + if (ticket === dialog.__aksrtPhoneMatchTicket) { + hidePhoneCodeArea(dialog, { destroyCode: false }); + setPhoneDigitsError(dialog, '网络错误,请重试'); + } + }); + } + + function initPhoneDigits(dialog) { + var boxes = phoneDigitBoxes(dialog); + if (!boxes.length || dialog.dataset.aksrtDigitsBound === 'true') return; + dialog.dataset.aksrtDigitsBound = 'true'; + + boxes.forEach(function (input, index) { + input.addEventListener('input', function () { + var value = String(input.value || '').replace(/\D/g, ''); + input.value = value === '' ? '' : value.charAt(value.length - 1); + hidePhoneCodeArea(dialog, { destroyCode: true }); + setPhoneDigitsError(dialog, ''); + if (input.value !== '' && index < boxes.length - 1) boxes[index + 1].focus(); + verifyPhoneMiddle(dialog); + }); + input.addEventListener('keydown', function (event) { + if (event.key === 'Backspace' && !input.value && index > 0) { + event.preventDefault(); + boxes[index - 1].value = ''; + boxes[index - 1].focus(); + hidePhoneCodeArea(dialog, { destroyCode: true }); + setPhoneDigitsError(dialog, ''); + } else if (event.key === 'ArrowLeft' && index > 0) { + boxes[index - 1].focus(); + } else if (event.key === 'ArrowRight' && index < boxes.length - 1) { + boxes[index + 1].focus(); + } + }); + input.addEventListener('paste', function (event) { + var text = String((event.clipboardData || window.clipboardData).getData('text') || ''); + var digits = text.replace(/\D/g, ''); + if (!digits) return; + event.preventDefault(); + for (var offset = 0; offset < boxes.length - index && offset < digits.length; offset++) { + boxes[index + offset].value = digits.charAt(offset); + } + boxes[Math.min(index + digits.length, boxes.length - 1)].focus(); + hidePhoneCodeArea(dialog, { destroyCode: true }); + setPhoneDigitsError(dialog, ''); + verifyPhoneMiddle(dialog); + }); + }); + } + function sendRecoveryCode(button, form) { if (!form || button.disabled) return; var target = String((form.querySelector('[name="target"]') || {}).value || '').trim(); @@ -1031,6 +1155,7 @@ }); }); } else if (type === 'phone') { + initPhoneDigits(dialog); dialog.querySelectorAll('[data-aksrt-sms-trigger]').forEach(function (button) { button.addEventListener('click', function () { sendPhoneCode(button, button.closest('form'), button.getAttribute('data-aksrt-sms-scene')); }); }); @@ -1054,6 +1179,15 @@ if (!shell || !shell.classList.contains('aksrt-user-settings-modal')) return; shell.querySelectorAll('.account-code-send,[data-aksrt-sms-trigger],[data-account-recovery-send]').forEach(resetCountdown); shell.querySelectorAll('form').forEach(function (form) { form.reset(); }); + shell.querySelectorAll('[data-account-dialog="phone"]').forEach(function (dialog) { + dialog.querySelectorAll('.aksrt-phone-digit').forEach(function (input) { input.value = ''; }); + var group = dialog.querySelector('[data-aksrt-phone-digits]'); + if (group) group.classList.remove('has-error'); + var error = dialog.querySelector('[data-aksrt-phone-digits-error]'); + if (error) { error.hidden = true; error.textContent = ''; } + var area = dialog.querySelector('[data-aksrt-phone-code-area]'); + if (area) area.hidden = !!group; + }); } if (document.documentElement.dataset.aksrtSettingsModalCleanupInitialized !== 'true') { diff --git a/functions.php b/functions.php index 21ca21fb..6bf908db 100644 --- a/functions.php +++ b/functions.php @@ -167,6 +167,9 @@ if (class_exists('\AKSRT\Settings\MailNotifications')) { \AKSRT\Auth\Router::get_instance()->init(); \AKSRT\Core\Templates::get_instance()->init(); \AKSRT\Auth\Moderation::get_instance()->init(); +if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::get_instance()->init(); +} // 短代码统一注册入口(registry) if (class_exists('\AKSRT\Core\Shortcodes')) { diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 7fc6d9a2..62b50ef5 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -19,6 +19,7 @@ require_once AKSRT_DIR . '/inc/helpers/aksrt-avatar.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-domain-model.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-template-tags.php'; require_once AKSRT_DIR . '/inc/helpers/aksrt-frontend-tags.php'; +require_once AKSRT_DIR . '/inc/helpers/aksrt-phone.php'; // Shared comment kernel. require_once AKSRT_DIR . '/inc/helpers/aksrt-ip-geo.php'; require_once AKSRT_DIR . '/inc/helpers/comments/content.php'; diff --git a/inc/helpers/aksrt-phone.php b/inc/helpers/aksrt-phone.php new file mode 100644 index 00000000..d41d43ef --- /dev/null +++ b/inc/helpers/aksrt-phone.php @@ -0,0 +1,68 @@ + substr($core, 0, 3), + 'hidden' => substr($core, 3, $hidden_length), + 'suffix' => substr($core, $length - $suffix_length), + 'hidden_length' => $hidden_length, + 'leading_plus' => $leading_plus, + ]; +} + +/** + * Masked display form of a phone number, e.g. 138****1234 or +861******1234. + * Falls back to the raw number when it cannot be masked. + */ +function aksrt_phone_mask_display(string $phone): string { + $parts = aksrt_phone_mask_parts($phone); + if ($parts === null) { + return $phone; + } + + $mask = str_repeat('*', (int) $parts['hidden_length']); + $display = $parts['prefix'] . $mask . $parts['suffix']; + return $parts['leading_plus'] ? '+' . $display : $display; +} diff --git a/inc/helpers/user-center-renderers/settings-account.php b/inc/helpers/user-center-renderers/settings-account.php index 38c7d69f..fdad2225 100644 --- a/inc/helpers/user-center-renderers/settings-account.php +++ b/inc/helpers/user-center-renderers/settings-account.php @@ -11,7 +11,7 @@ $account_user = isset($target_user) && $target_user instanceof WP_User ? $target $account_user_id = $account_user instanceof WP_User ? (int) $account_user->ID : (int) ($user_id ?? 0); $account_email = $account_user instanceof WP_User ? (string) $account_user->user_email : ''; $account_phone = $account_user_id > 0 ? (string) get_user_meta($account_user_id, 'phone_number', true) : ''; -$account_phone_display = strlen($account_phone) > 10 ? substr_replace($account_phone, '****', 3, 4) : $account_phone; +$account_phone_display = function_exists('aksrt_phone_mask_display') ? aksrt_phone_mask_display($account_phone) : $account_phone; $account_oauth_new = $account_user_id > 0 && get_user_meta($account_user_id, 'oauth_new', true); $user_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; $sms_settings = class_exists('\AKSRT\Sms\SMS') ? \AKSRT\Sms\SMS::get_instance()->get_provider_config() : []; @@ -147,7 +147,29 @@ $render_phone_form = static function () use ($agreement): void {
- +
diff --git a/inc/modules/user-parts/account-ajax.php b/inc/modules/user-parts/account-ajax.php index c2e74da2..721b6b9d 100644 --- a/inc/modules/user-parts/account-ajax.php +++ b/inc/modules/user-parts/account-ajax.php @@ -92,21 +92,6 @@ function aksrt_account_consume_proof(int $user_id, string $kind, string $target, return true; } -function aksrt_account_rate_limited(int $user_id, string $scene): bool { - $key = 'aksrt_account_rate_' . sanitize_key($scene) . '_' . $user_id; - $count = (int) get_transient($key); - if ($count >= 8) { - return true; - } - - set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS); - return false; -} - -function aksrt_account_reset_rate(int $user_id, string $scene): void { - delete_transient('aksrt_account_rate_' . sanitize_key($scene) . '_' . $user_id); -} - function aksrt_account_email_verification_required(): bool { $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; return !empty($settings['email_set_captch']); @@ -155,11 +140,12 @@ add_action('wp_ajax_aksrt_send_account_email_code', function (): void { wp_send_json_error(aksrt_account_captcha_error_data($captcha)); } } - $rate_key = 'aksrt_account_email_rate_' . $user_id . '_' . $scene . '_' . md5(strtolower($target)); - if (get_transient($rate_key)) { - wp_send_json_error(['message' => __('操作过于频繁,请稍后再试', 'aksrt')]); + if (class_exists('\AKSRT\Extensions\SendLimit')) { + $limit = \AKSRT\Extensions\SendLimit::check($target); + if (empty($limit['ok'])) { + wp_send_json_error(['message' => (string) $limit['message']]); + } } - set_transient($rate_key, true, 60); $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); $sent = class_exists('\AKSRT\Settings\Settings') @@ -170,16 +156,16 @@ add_action('wp_ajax_aksrt_send_account_email_code', function (): void { ]) : false; if (!$sent) { - delete_transient($rate_key); wp_send_json_error(['message' => __('验证码邮件发送失败,请稍后重试', 'aksrt')]); } + $cooldown = class_exists('\AKSRT\Extensions\SendLimit') ? (int) \AKSRT\Extensions\SendLimit::record($target) : 0; aksrt_account_store_code($user_id, $scene, $target, $code); wp_send_json_success([ 'message' => $scene === 'verify_email' ? __('验证码已发送到您的老邮箱', 'aksrt') : __('验证码已发送到新邮箱', 'aksrt'), - 'countdown' => 60, + 'countdown' => $cooldown > 0 ? $cooldown : 60, ]); }); @@ -208,9 +194,15 @@ add_action('wp_ajax_aksrt_verify_account_email', function (): void { $code = sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')); if (!aksrt_account_consume_code($user_id, 'verify_email', (string) $user->user_email, $code)) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } wp_send_json_error(['message' => __('验证码错误或已过期', 'aksrt')]); } + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_clear(); + } aksrt_account_set_proof($user_id, 'email_old', (string) $user->user_email); wp_send_json_success(['message' => __('验证成功', 'aksrt')]); }); @@ -250,10 +242,13 @@ add_action('wp_ajax_aksrt_change_password', function (): void { if ($old_password === $password) { wp_send_json_error(['message' => __('新密码和原密码不能相同', 'aksrt')]); } - if (aksrt_account_rate_limited($user_id, 'change_password')) { - wp_send_json_error(['message' => __('错误次数过多,请稍后再试', 'aksrt')]); + if (class_exists('\AKSRT\Extensions\SecurityHardening') && ($wait = \AKSRT\Extensions\SecurityHardening::error_frequency_wait()) > 0) { + wp_send_json_error(['message' => sprintf(__('验证错误次数过多,请 %d 秒后再试', 'aksrt'), $wait)]); } if (!wp_check_password($old_password, $user->user_pass, $user_id)) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } wp_send_json_error(['message' => __('原密码错误', 'aksrt')]); } } @@ -271,7 +266,9 @@ add_action('wp_ajax_aksrt_change_password', function (): void { } delete_user_meta($user_id, 'oauth_new'); - aksrt_account_reset_rate($user_id, 'change_password'); + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_clear(); + } wp_send_json_success([ 'message' => $oauth_new ? __('密码设置成功', 'aksrt') @@ -335,6 +332,9 @@ add_action('wp_ajax_aksrt_change_email', function (): void { false ) ) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } wp_send_json_error(['message' => __('验证码错误或已过期', 'aksrt')]); } @@ -349,6 +349,9 @@ add_action('wp_ajax_aksrt_change_email', function (): void { if ($verification_required) { delete_transient(aksrt_account_code_key($user_id, 'change_email', $new_email)); } + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_clear(); + } update_user_meta($user_id, 'aksrt_email_verified', 1); if ($old_email !== '' && is_email($old_email) && class_exists('\AKSRT\Settings\Settings')) { \AKSRT\Settings\Settings::send_event_email('change_notice', $old_email, [ diff --git a/src/Admin/Schema/SchemaExtensions.php b/src/Admin/Schema/SchemaExtensions.php index f267fb1c..7f6dc87d 100644 --- a/src/Admin/Schema/SchemaExtensions.php +++ b/src/Admin/Schema/SchemaExtensions.php @@ -17,6 +17,7 @@ class SchemaExtensions { 'ip' => SchemaExtensionsIP::build(), 'audit' => SchemaExtensionsAudit::build(), 'push' => SchemaExtensionsPush::build(), + 'security' => SchemaExtensionsSecurity::build(), ], ]; } diff --git a/src/Admin/Schema/SchemaExtensionsSecurity.php b/src/Admin/Schema/SchemaExtensionsSecurity.php new file mode 100644 index 00000000..11f7a1a6 --- /dev/null +++ b/src/Admin/Schema/SchemaExtensionsSecurity.php @@ -0,0 +1,56 @@ + 'security', + 'title' => '网站安全', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'brush_post', 'title' => '[防刷机制]发布限制', 'description' => '限制单个用户发布文章、帖子、板块的总数量(为0则不限制)', 'fields' => [ + ['key' => 'security_brush_post_10min', 'label' => '10分钟内最多发布 (篇)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 200], + ['key' => 'security_brush_post_day', 'label' => '一天内最多发布 (篇)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 2000], + ]], + ['key' => 'brush_comment', 'title' => '[防刷机制]评论限制', 'description' => '限制单个用户发布评论的总数量(为0则不限制)', 'fields' => [ + ['key' => 'security_brush_comment_10min', 'label' => '10分钟内最多评论 (条)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 200], + ['key' => 'security_brush_comment_day', 'label' => '一天内最多评论 (条)', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 2000, 'help' => '如果启用了免登陆可评论,则根据用户IP地址判断'], + ]], + ['key' => 'brush_upload', 'title' => '[防刷机制]上传限制', 'description' => '限制单个用户上传内容的总数量(为0则不限制)。防刷机制主要为了防止用户恶意多次提交内容,请根据网站的用户量合理设置,此机制不会限制管理员', 'fields' => [ + ['key' => 'security_brush_upload_10min', 'label' => '10分钟内最多上传 (个)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 200], + ['key' => 'security_brush_upload_day', 'label' => '一天内最多上传 (个)', 'type' => 'number', 'default' => 200, 'min' => 0, 'max' => 2000], + ]], + ['key' => 'brush_register', 'title' => '[防刷机制]注册限制', 'description' => '限制每个IP地址用户注册数量,此项无法判断管理员身份,所以管理员也会受到限制', 'fields' => [ + ['key' => 'security_brush_register_10min', 'label' => '10分钟内最多注册 (个)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 200], + ['key' => 'security_brush_register_day', 'label' => '一天内最多注册 (个)', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 2000, 'help' => '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)'], + ]], + ['key' => 'brush_order', 'title' => '[防刷机制]下单限制', 'description' => '限制每个用户下单数量,未登录用户按IP地址判断', 'fields' => [ + ['key' => 'security_brush_order_10min', 'label' => '10分钟内最多下单 (个订单)', 'type' => 'number', 'default' => 30, 'min' => 0, 'max' => 200], + ['key' => 'security_brush_order_day', 'label' => '一天内最多下单 (个订单)', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 2000, 'help' => '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)'], + ]], + ['key' => 'brush_captcha', 'title' => '[防刷机制]验证码发送限制', 'description' => '限制验证码邮件/短信的发送频率,防止恶意程序批量轰炸特定邮箱或手机号', 'fields' => [ + ['key' => 'security_captcha_send_cooldown', 'label' => '同一收件人/IP发送冷却 (秒)', 'type' => 'number', 'default' => 60, 'min' => 0, 'max' => 600, 'help' => '同一邮箱或手机号、同一IP地址发送验证码后,需间隔此时间才可再次发送(0为不限制)'], + ['key' => 'security_captcha_to_hour', 'label' => '同一收件人1小时内最多 (次)', 'type' => 'number', 'default' => 15, 'min' => 0, 'max' => 500], + ['key' => 'security_captcha_to_day', 'label' => '同一收件人一天内最多 (次)', 'type' => 'number', 'default' => 30, 'min' => 0, 'max' => 1000], + ['key' => 'security_captcha_ip_10min', 'label' => '同一IP 10分钟内最多 (次)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 500], + ['key' => 'security_captcha_ip_day', 'label' => '同一IP一天内最多 (次)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 2000, 'help' => '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)'], + ]], + ['key' => 'harden', 'title' => '安全加固', 'fields' => [ + ['key' => 'security_error_frequency_limit', 'label' => '错误频率限制', 'type' => 'switch', 'default' => true, 'help' => '用户进行密码、邮箱、手机号、验证码等验证时,验证错误次数超过5次后限制30秒验证一次,错误超过10次后,限制3分钟验证一次,有效防止暴力破解'], + ['key' => 'security_disabled_pingback', 'label' => '防pingback攻击', 'type' => 'switch', 'default' => true], + ['key' => 'security_disabled_xmlrpc', 'label' => '禁用XML-RPC', 'type' => 'switch', 'default' => true, 'help' => '如果无需使用xmlrpc功能,建议直接删除WordPress目录下的xmlrpc.php文件'], + ['key' => 'security_user_disable_admin', 'label' => '非管理员禁止进入后台', 'type' => 'switch', 'default' => true, 'help' => '为了安全,如非必要请勿允许非管理员进入后台'], + ['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '非管理员前台不显示附件(图片、文件等)页面,直接显示404,能有效提高文件的安全性'], + ]], + ], + ]; + } +} diff --git a/src/Admin/Schema/SchemaPermissions.php b/src/Admin/Schema/SchemaPermissions.php index 2a5aed58..b83eb549 100644 --- a/src/Admin/Schema/SchemaPermissions.php +++ b/src/Admin/Schema/SchemaPermissions.php @@ -137,7 +137,6 @@ class SchemaPermissions { ['label' => '时间 + 原文件名', 'value' => 'time'], ['label' => '随机字符串', 'value' => 'random'], ]], - ['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。'], ]], ], ], diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index 4a48d469..33d94509 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -205,6 +205,11 @@ class UserController extends ControllerBase { 'callback' => [$this, 'user_phone_code'], 'permission_callback' => static function () { return is_user_logged_in(); }, ]); + register_rest_route(self::API_NAMESPACE, '/user/phone-match', [ + 'methods' => 'POST', + 'callback' => [$this, 'user_phone_match'], + 'permission_callback' => static function () { return is_user_logged_in(); }, + ]); register_rest_route(self::API_NAMESPACE, '/user/phone-verify', [ 'methods' => 'POST', 'callback' => [$this, 'user_verify_phone'], diff --git a/src/Admin/UserControllerAuthPhoneOperations.php b/src/Admin/UserControllerAuthPhoneOperations.php index 8a2e88c8..c005c49c 100644 --- a/src/Admin/UserControllerAuthPhoneOperations.php +++ b/src/Admin/UserControllerAuthPhoneOperations.php @@ -196,6 +196,14 @@ trait UserControllerAuthPhoneOperations { return $this->success(['message' => $generic, 'countdown' => (int) ($result['remaining_time'] ?? 60)]); } + // [防刷机制]验证码发送限制(短信通道由 SMS::send_code 内部接入)。 + if (class_exists('\AKSRT\Extensions\SendLimit')) { + $limit = \AKSRT\Extensions\SendLimit::check($target); + if (empty($limit['ok'])) { + return $this->error((string) $limit['message'], 429); + } + } + $code = (string) random_int(100000, 999999); $scene_event = [ 'login' => 'login_code', @@ -221,8 +229,9 @@ trait UserControllerAuthPhoneOperations { } return $this->error(__('验证码发送失败,请稍后再试', 'aksrt'), 500); } + $cooldown = class_exists('\AKSRT\Extensions\SendLimit') ? (int) \AKSRT\Extensions\SendLimit::record($target) : 0; $this->auth_store_code($scene, $target, $code); - return $this->success(['message' => $generic, 'countdown' => 60]); + return $this->success(['message' => $generic, 'countdown' => $cooldown > 0 ? $cooldown : 60]); } @@ -465,13 +474,35 @@ trait UserControllerAuthPhoneOperations { $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403); $stage = sanitize_key((string) ($payload['stage'] ?? 'bind_new_phone')); - $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); - if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) return $this->error(__('手机号格式不正确', 'aksrt')); $user_id = get_current_user_id(); $current_phone = (string) get_user_meta($user_id, 'phone_number', true); + + if ($stage === 'reset_old_phone') { + // 前端在修改已通过校验的中间位后调用:销毁已发放的老号验证码状态。 + if ($current_phone !== '') { + delete_transient($this->auth_code_key('account_verify_old_phone_' . $user_id, $current_phone)); + } + return $this->success(['message' => __('已取消本次验证', 'aksrt')]); + } + if ($stage === 'verify_old_phone') { - if ($current_phone === '' || !hash_equals($current_phone, $phone)) return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409); + // 老号验证只接收脱敏中间位,发送目标恒为服务端绑定号, + // 避免客户端提交任意号码导致向第三方手机号发码。 + if ($current_phone === '') return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409); + $parts = function_exists('aksrt_phone_mask_parts') ? aksrt_phone_mask_parts($current_phone) : null; + if ($parts !== null) { + $middle = sanitize_text_field((string) ($payload['middle'] ?? '')); + if (!$this->auth_middle_matches($parts, $middle)) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } + return $this->error(__('填写的号码与绑定手机号不一致', 'aksrt'), 400); + } + } + $phone = $current_phone; } elseif ($stage === 'bind_new_phone') { + $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); + if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) return $this->error(__('手机号格式不正确', 'aksrt')); if ($current_phone !== '' && !hash_equals($current_phone, $phone)) { $proof = get_transient('aksrt_account_phone_old_' . $user_id); if (!is_array($proof) || !hash_equals($current_phone, (string) ($proof['phone'] ?? ''))) return $this->error(__('请先验证老手机号', 'aksrt'), 403); @@ -484,9 +515,11 @@ trait UserControllerAuthPhoneOperations { } $captcha_error = $this->auth_require_captcha('bind_phone', $payload); if ($captcha_error) return $captcha_error; + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + $wait = \AKSRT\Extensions\SecurityHardening::error_frequency_wait(); + if ($wait > 0) return $this->error(sprintf(__('验证错误次数过多,请 %d 秒后再试', 'aksrt'), $wait), 429); + } if (!class_exists('\AKSRT\Sms\SMS')) return $this->error(__('短信服务不可用', 'aksrt'), 503); - $rate_subject = $stage . '|' . $user_id . '|' . $phone; - if ($this->auth_rate_limited('account-phone-code', $rate_subject, 5, HOUR_IN_SECONDS)) return $this->error(__('验证码请求过于频繁,请稍后再试', 'aksrt'), 429); $code = (string) random_int(100000, 999999); $result = \AKSRT\Sms\SMS::get_instance()->send_code($phone, $code); if (empty($result['success'])) return $this->error((string) ($result['message'] ?? __('发送失败', 'aksrt'))); @@ -494,6 +527,53 @@ trait UserControllerAuthPhoneOperations { return $this->success(['message' => __('验证码短信已发送', 'aksrt'), 'countdown' => (int) ($result['remaining_time'] ?? 60)]); } + /** + * 校验客户端提交的脱敏中间位是否与绑定号一致(不发短信)。 + */ + public function user_phone_match(\WP_REST_Request $request): \WP_REST_Response { + $payload = $this->auth_payload($request); + if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403); + $settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; + if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403); + $user_id = get_current_user_id(); + $current_phone = (string) get_user_meta($user_id, 'phone_number', true); + $parts = function_exists('aksrt_phone_mask_parts') ? aksrt_phone_mask_parts($current_phone) : null; + if ($parts === null) return $this->error(__('当前绑定手机号不支持此验证方式', 'aksrt'), 409); + + // 中间位是可枚举面:错误频率限制 + 每用户/IP 每小时尝试上限双保险。 + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + $wait = \AKSRT\Extensions\SecurityHardening::error_frequency_wait(); + if ($wait > 0) return $this->error(sprintf(__('验证错误次数过多,请 %d 秒后再试', 'aksrt'), $wait), 429); + } + $ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + if ($this->auth_rate_limited('phone-match', (string) $user_id . '|' . $ip, 20, HOUR_IN_SECONDS)) { + return $this->error(__('尝试次数过多,请稍后再试', 'aksrt'), 429); + } + + $middle = sanitize_text_field((string) ($payload['middle'] ?? '')); + if (!$this->auth_middle_matches($parts, $middle)) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } + return $this->error(__('填写的号码与绑定手机号不一致', 'aksrt'), 400); + } + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_clear(); + } + return $this->success(['match' => true]); + } + + /** + * 比对脱敏中间位:长度必须与隐藏位数一致,按位恒时比较。 + */ + private function auth_middle_matches(array $parts, string $middle): bool { + $middle = preg_replace('/\D+/', '', $middle); + if ($middle === '' || strlen($middle) !== (int) $parts['hidden_length']) { + return false; + } + return hash_equals((string) $parts['hidden'], $middle); + } + public function user_verify_phone(\WP_REST_Request $request): \WP_REST_Response { $payload = $this->auth_payload($request); if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403); @@ -503,10 +583,17 @@ trait UserControllerAuthPhoneOperations { if ($captcha_error) return $captcha_error; $user_id = get_current_user_id(); $current_phone = (string) get_user_meta($user_id, 'phone_number', true); - $phone = sanitize_text_field((string) ($payload['phone'] ?? '')); $code = sanitize_text_field((string) ($payload['code'] ?? '')); - if ($current_phone === '' || !hash_equals($current_phone, $phone)) return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409); - if (!$this->auth_consume_code('account_verify_old_phone_' . $user_id, $current_phone, $code)) return $this->error(__('验证码错误或已过期', 'aksrt')); + if ($current_phone === '') return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409); + if (!$this->auth_consume_code('account_verify_old_phone_' . $user_id, $current_phone, $code)) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } + return $this->error(__('验证码错误或已过期', 'aksrt')); + } + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_clear(); + } set_transient('aksrt_account_phone_old_' . $user_id, ['phone' => $current_phone], 30 * MINUTE_IN_SECONDS); return $this->success(['message' => __('手机号验证成功', 'aksrt')]); } @@ -533,10 +620,18 @@ trait UserControllerAuthPhoneOperations { $agreement = function_exists('aksrt_get_auth_agreement_view') ? aksrt_get_auth_agreement_view() : ['enabled' => false, 'required' => false]; if (!empty($agreement['required']) && empty($payload['agree_terms'])) return $this->error(__('请先同意相关协议', 'aksrt'), 400); $code_scene = 'account_bind_new_phone_' . $user_id; - if (!$this->auth_consume_code($code_scene, $phone, $code, false)) return $this->error(__('验证码错误或已过期', 'aksrt')); + if (!$this->auth_consume_code($code_scene, $phone, $code, false)) { + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_record(); + } + return $this->error(__('验证码错误或已过期', 'aksrt')); + } if (!class_exists('\AKSRT\Auth\Service')) return $this->error(__('认证服务不可用', 'aksrt'), 500); $result = \AKSRT\Auth\Service::bind_phone($payload, true); if (empty($result['success'])) return $this->error((string) ($result['error'] ?? __('绑定失败', 'aksrt'))); + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + \AKSRT\Extensions\SecurityHardening::error_frequency_clear(); + } delete_transient($this->auth_code_key($code_scene, $phone)); delete_transient('aksrt_account_phone_old_' . $user_id); return $this->success(['message' => $result['message'] ?? __('绑定成功', 'aksrt')]); diff --git a/src/Attachment/UploadLimits.php b/src/Attachment/UploadLimits.php index e23d3b20..cc7c128d 100644 --- a/src/Attachment/UploadLimits.php +++ b/src/Attachment/UploadLimits.php @@ -45,6 +45,7 @@ class UploadLimits { public function init(): void { add_filter('upload_mimes', [$this, 'filter_upload_mimes']); add_filter('getimagesize_mimes_to_exts', [$this, 'filter_image_mimes_to_exts']); + add_filter('wp_handle_upload_prefilter', [$this, 'brush_limit_upload'], 90); add_filter('wp_handle_upload_prefilter', [$this, 'rename_upload_file'], 99); add_action('template_redirect', [$this, 'close_attachment_page']); } @@ -305,6 +306,27 @@ class UploadLimits { return $file; } + /** + * [防刷机制]上传限制(后台「扩展&增强 → 网站安全」),管理员不受限。 + */ + public function brush_limit_upload(array $file): array { + if (!empty($file['error']) || !class_exists('\AKSRT\Extensions\SecurityHardening')) { + return $file; + } + if (current_user_can('manage_options')) { + return $file; + } + + $security = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $user_id = get_current_user_id(); + $subject = $user_id > 0 ? (string) $user_id : (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '')); + $brush = \AKSRT\Extensions\SecurityHardening::brush_allow('upload', $subject, (int) $security['security_brush_upload_10min'], (int) $security['security_brush_upload_day']); + if ($brush !== true) { + $file['error'] = (string) $brush; + } + return $file; + } + /** * Hide attachment pages from non-admins (returns 404 instead). */ diff --git a/src/Auth/Moderation.php b/src/Auth/Moderation.php index c5282a72..23b0de28 100644 --- a/src/Auth/Moderation.php +++ b/src/Auth/Moderation.php @@ -124,6 +124,25 @@ class Moderation { } } + // [防刷机制]评论限制(后台「扩展&增强 → 网站安全」),管理员不受限; + // 未登录用户按 IP 判断,与下方审核限流(间隔/每小时)互为补充。 + if (!current_user_can('manage_options') && class_exists('\AKSRT\Extensions\SecurityHardening')) { + $security = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $brush = \AKSRT\Extensions\SecurityHardening::brush_allow( + 'comment', + $actor > 0 ? (string) $actor : $this->get_client_ip(), + (int) $security['security_brush_comment_10min'], + (int) $security['security_brush_comment_day'] + ); + if ($brush !== true) { + wp_die( + '

' . esc_html((string) $brush) . '

', + esc_html__('评论提交失败', 'aksrt'), + ['response' => 429] + ); + } + } + $config = $this->get_config(); if (empty($config['enabled'])) { diff --git a/src/Auth/ServicePart1a.php b/src/Auth/ServicePart1a.php index 67a1f0f7..47207d64 100644 --- a/src/Auth/ServicePart1a.php +++ b/src/Auth/ServicePart1a.php @@ -140,6 +140,16 @@ trait ServicePart1a { } } + // [防刷机制]注册限制(后台「扩展&增强 → 网站安全」),按 IP 判断且不豁免管理员。 + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + $security = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $brush_ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $brush = \AKSRT\Extensions\SecurityHardening::brush_allow('register', $brush_ip, (int) $security['security_brush_register_10min'], (int) $security['security_brush_register_day']); + if ($brush !== true) { + return ['success' => false, 'error' => (string) $brush]; + } + } + $user_id = wp_create_user($user_login, $password, $user_email); if (is_wp_error($user_id)) { if ($ref_service) { diff --git a/src/Auth/ServicePart1b.php b/src/Auth/ServicePart1b.php index b0732e04..e41876a5 100644 --- a/src/Auth/ServicePart1b.php +++ b/src/Auth/ServicePart1b.php @@ -114,6 +114,16 @@ trait ServicePart1b { } } + // [防刷机制]注册限制(后台「扩展&增强 → 网站安全」),按 IP 判断且不豁免管理员。 + if (class_exists('\AKSRT\Extensions\SecurityHardening')) { + $security = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $brush_ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $brush = \AKSRT\Extensions\SecurityHardening::brush_allow('register', $brush_ip, (int) $security['security_brush_register_10min'], (int) $security['security_brush_register_day']); + if ($brush !== true) { + return ['success' => false, 'error' => (string) $brush]; + } + } + // 创建用户 $user_id = wp_create_user($username, $pass, ''); if (is_wp_error($user_id)) { diff --git a/src/Core/FrontSubmitSave.php b/src/Core/FrontSubmitSave.php index 4834b8b8..e1999e9a 100644 --- a/src/Core/FrontSubmitSave.php +++ b/src/Core/FrontSubmitSave.php @@ -121,6 +121,18 @@ trait FrontSubmitSave { $author_id = (int) $settings['default_user_id']; } + // [防刷机制]发布限制(后台「扩展&增强 → 网站安全」),管理员不受限。 + if (!$is_edit && !current_user_can('manage_options') && class_exists('\AKSRT\Extensions\SecurityHardening')) { + $security = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $brush_subject = $is_guest + ? (string) (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : ($_SERVER['REMOTE_ADDR'] ?? '')) + : (string) $user_id; + $brush = \AKSRT\Extensions\SecurityHardening::brush_allow('post', $brush_subject, (int) $security['security_brush_post_10min'], (int) $security['security_brush_post_day']); + if ($brush !== true) { + return ['success' => false, 'message' => (string) $brush]; + } + } + $status = 'draft'; if (!$is_draft) { $status = $this->resolve_publish_status($user_id); diff --git a/src/Extensions/SecurityHardening.php b/src/Extensions/SecurityHardening.php new file mode 100644 index 00000000..9aa7e705 --- /dev/null +++ b/src/Extensions/SecurityHardening.php @@ -0,0 +1,151 @@ + $link) { + if (0 === strpos((string) $link, $home)) { + unset($links[$index]); + } + } + } + + /** + * 非管理员禁止进入后台(放行 admin-ajax 与定时任务)。 + */ + public function restrict_admin_access(): void { + if (wp_doing_ajax() || wp_doing_cron()) { + return; + } + if (!is_user_logged_in() || current_user_can('manage_options')) { + return; + } + wp_safe_redirect(home_url('/')); + exit; + } + + /** + * 内容防刷限制:检查并计数(10 分钟窗口 + 当天两个维度,0 = 不限制)。 + * + * @param string $scope post|comment|upload|register|order + * @param string $subject 用户 ID 或 IP 等限流主体 + * @param int $cap_10 10 分钟上限 + * @param int $cap_day 一天上限 + * @return true|string true = 放行(并计数),字符串 = 拒绝原因 + */ + public static function brush_allow(string $scope, string $subject, int $cap_10, int $cap_day) { + if ($cap_10 <= 0 && $cap_day <= 0) { + return true; + } + + $scope = sanitize_key($scope); + $hash = md5((string) $subject); + $minute_key = sprintf('aksrt_brush_%s_m_%s_%s%d', $scope, $hash, wp_date('YmdH'), intdiv((int) wp_date('i'), 10)); + $day_key = sprintf('aksrt_brush_%s_d_%s_%s', $scope, $hash, wp_date('Ymd')); + + if ($cap_10 > 0 && (int) get_transient($minute_key) >= $cap_10) { + return __('操作过于频繁,请稍后再试', 'aksrt'); + } + if ($cap_day > 0 && (int) get_transient($day_key) >= $cap_day) { + return __('今日操作次数已达上限,请明天再试', 'aksrt'); + } + + if ($cap_10 > 0) { + set_transient($minute_key, (int) get_transient($minute_key) + 1, 10 * MINUTE_IN_SECONDS); + } + if ($cap_day > 0) { + set_transient($day_key, (int) get_transient($day_key) + 1, DAY_IN_SECONDS); + } + return true; + } + + /** + * 错误频率限制:返回下一次验证需等待的秒数(0 = 可立即验证)。 + * 错误超过 5 次限 30 秒一次,超过 10 次限 3 分钟一次。 + */ + public static function error_frequency_wait(): int { + $settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + if (empty($settings['security_error_frequency_limit'])) { + return 0; + } + + $state = get_transient(self::error_frequency_key()); + if (!is_array($state)) { + return 0; + } + + $count = (int) ($state['count'] ?? 0); + $last = (int) ($state['last'] ?? 0); + if ($count < 5) { + return 0; + } + + $remaining = ($count >= 10 ? 180 : 30) - (time() - $last); + return $remaining > 0 ? $remaining : 0; + } + + /** 记录一次验证失败(滑动 10 分钟窗口)。 */ + public static function error_frequency_record(): void { + $settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + if (empty($settings['security_error_frequency_limit'])) { + return; + } + + $key = self::error_frequency_key(); + $state = get_transient($key); + $count = is_array($state) ? (int) ($state['count'] ?? 0) + 1 : 1; + set_transient($key, ['count' => $count, 'last' => time()], 10 * MINUTE_IN_SECONDS); + } + + /** 验证成功后清除错误计数。 */ + public static function error_frequency_clear(): void { + delete_transient(self::error_frequency_key()); + } + + private static function error_frequency_key(): string { + $ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + return 'aksrt_err_freq_' . md5((string) get_current_user_id() . '|' . $ip); + } +} diff --git a/src/Extensions/SendLimit.php b/src/Extensions/SendLimit.php new file mode 100644 index 00000000..355a5cf0 --- /dev/null +++ b/src/Extensions/SendLimit.php @@ -0,0 +1,96 @@ + false, 'message' => ..., 'remaining' => 秒]。 + * + * @return array{ok:bool,message:string,remaining:int} + */ + public static function check(string $target): array { + $settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $target_hash = md5(strtolower(trim($target))); + $ip_hash = md5(self::client_ip()); + + if ($settings['security_captcha_send_cooldown'] > 0) { + $sent_at = (int) get_transient('aksrt_send_cd_' . $target_hash); + if ($sent_at > 0) { + $remaining = $settings['security_captcha_send_cooldown'] - (time() - $sent_at); + if ($remaining > 0) { + return [ + 'ok' => false, + 'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining), + 'remaining' => $remaining, + ]; + } + } + } + + $recipient_checks = [ + ['security_captcha_to_hour', 'aksrt_send_to_h_' . $target_hash . '_' . wp_date('YmdH'), __('该邮箱或手机号1小时内验证码发送次数已达上限', 'aksrt')], + ['security_captcha_to_day', 'aksrt_send_to_d_' . $target_hash . '_' . wp_date('Ymd'), __('该邮箱或手机号今日验证码发送次数已达上限', 'aksrt')], + ]; + $ip_checks = [ + ['security_captcha_ip_10min', sprintf('aksrt_send_ip_m_%s_%s%d', $ip_hash, wp_date('YmdH'), intdiv((int) wp_date('i'), 10)), __('当前IP验证码发送过于频繁,请稍后再试', 'aksrt')], + ['security_captcha_ip_day', 'aksrt_send_ip_d_' . $ip_hash . '_' . wp_date('Ymd'), __('当前IP今日验证码发送次数已达上限', 'aksrt')], + ]; + + foreach (array_merge($recipient_checks, $ip_checks) as [$cap_key, $transient_key, $message]) { + if ($settings[$cap_key] > 0 && (int) get_transient($transient_key) >= $settings[$cap_key]) { + return ['ok' => false, 'message' => $message, 'remaining' => $settings['security_captcha_send_cooldown']]; + } + } + + return ['ok' => true, 'message' => '', 'remaining' => 0]; + } + + /** + * 发送成功后记录计数,返回冷却秒数(用于前端倒计时)。 + */ + public static function record(string $target): int { + $settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $target_hash = md5(strtolower(trim($target))); + $ip = self::client_ip(); + $ip_hash = md5($ip); + + if ($settings['security_captcha_to_hour'] > 0) { + $key = 'aksrt_send_to_h_' . $target_hash . '_' . wp_date('YmdH'); + set_transient($key, (int) get_transient($key) + 1, HOUR_IN_SECONDS); + } + if ($settings['security_captcha_to_day'] > 0) { + $key = 'aksrt_send_to_d_' . $target_hash . '_' . wp_date('Ymd'); + set_transient($key, (int) get_transient($key) + 1, DAY_IN_SECONDS); + } + if ($settings['security_captcha_ip_10min'] > 0) { + $key = sprintf('aksrt_send_ip_m_%s_%s%d', $ip_hash, wp_date('YmdH'), intdiv((int) wp_date('i'), 10)); + set_transient($key, (int) get_transient($key) + 1, 10 * MINUTE_IN_SECONDS); + } + if ($settings['security_captcha_ip_day'] > 0) { + $key = 'aksrt_send_ip_d_' . $ip_hash . '_' . wp_date('Ymd'); + set_transient($key, (int) get_transient($key) + 1, DAY_IN_SECONDS); + } + + $cooldown = $settings['security_captcha_send_cooldown']; + if ($cooldown > 0) { + set_transient('aksrt_send_cd_' . $target_hash, time(), $cooldown); + } + return $cooldown; + } + + private static function client_ip(): string { + return function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + } +} diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index 87df58b9..852dedae 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -48,6 +48,19 @@ trait OrderRepositoryCore { public function create(array $data): string { global $wpdb; + // [防刷机制]下单限制(后台「扩展&增强 → 网站安全」),管理员不受限,未登录按 IP。 + if (class_exists('\AKSRT\Extensions\SecurityHardening') && !current_user_can('manage_options')) { + $security = \AKSRT\Settings\ExtensionRuntime::get_security_settings(); + $order_user_id = (int) ($data['user_id'] ?? 0); + $brush_subject = $order_user_id > 0 + ? (string) $order_user_id + : (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '')); + $brush = \AKSRT\Extensions\SecurityHardening::brush_allow('order', $brush_subject, (int) $security['security_brush_order_10min'], (int) $security['security_brush_order_day']); + if ($brush !== true) { + return ''; + } + } + // 自带订单号先过形态校验:只接受分配器能产出的「全数字、20 或 22 位」。 // 不合规的一律弃用并回落到分配器(WP_DEBUG 下留痕),既不把非法长度写进库, // 也不因此抛错中断调用方。 diff --git a/src/Settings/ExtensionRuntime.php b/src/Settings/ExtensionRuntime.php index 5c0f8a47..80f47ed8 100644 --- a/src/Settings/ExtensionRuntime.php +++ b/src/Settings/ExtensionRuntime.php @@ -154,6 +154,41 @@ class ExtensionRuntime { ]; } + /** 获取网站安全设置(防刷限制、验证码发送限制与安全加固)。 */ + public static function get_security_settings(): array { + $settings = Settings::get_all(); + + $number = static function (string $key, int $default, int $min, int $max) use ($settings): int { + return max($min, min($max, (int) ($settings[$key] ?? $default))); + }; + $switch = static function (string $key, bool $default) use ($settings): bool { + return !isset($settings[$key]) ? $default : !empty($settings[$key]); + }; + + return [ + 'security_brush_post_10min' => $number('security_brush_post_10min', 20, 0, 200), + 'security_brush_post_day' => $number('security_brush_post_day', 50, 0, 2000), + 'security_brush_comment_10min' => $number('security_brush_comment_10min', 20, 0, 200), + 'security_brush_comment_day' => $number('security_brush_comment_day', 100, 0, 2000), + 'security_brush_upload_10min' => $number('security_brush_upload_10min', 50, 0, 200), + 'security_brush_upload_day' => $number('security_brush_upload_day', 200, 0, 2000), + 'security_brush_register_10min' => $number('security_brush_register_10min', 20, 0, 200), + 'security_brush_register_day' => $number('security_brush_register_day', 100, 0, 2000), + 'security_brush_order_10min' => $number('security_brush_order_10min', 30, 0, 200), + 'security_brush_order_day' => $number('security_brush_order_day', 100, 0, 2000), + 'security_captcha_send_cooldown' => $number('security_captcha_send_cooldown', 60, 0, 600), + 'security_captcha_to_hour' => $number('security_captcha_to_hour', 15, 0, 500), + 'security_captcha_to_day' => $number('security_captcha_to_day', 30, 0, 1000), + 'security_captcha_ip_10min' => $number('security_captcha_ip_10min', 20, 0, 500), + 'security_captcha_ip_day' => $number('security_captcha_ip_day', 50, 0, 2000), + 'security_error_frequency_limit' => $switch('security_error_frequency_limit', true), + 'security_disabled_pingback' => $switch('security_disabled_pingback', true), + 'security_disabled_xmlrpc' => $switch('security_disabled_xmlrpc', true), + 'security_user_disable_admin' => $switch('security_user_disable_admin', true), + 'close_attachment_page' => $switch('close_attachment_page', true), + ]; + } + /** 获取短信接口设置。 */ public static function get_sms_settings(): array { $settings = Settings::get_all(); diff --git a/src/Sms/SMS.php b/src/Sms/SMS.php index 839f520b..93c0e89d 100644 --- a/src/Sms/SMS.php +++ b/src/Sms/SMS.php @@ -14,9 +14,6 @@ class SMS { private static $instance = null; - private const RATE_LIMIT = 60; - private const DAILY_LIMIT = 10; - public static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -72,39 +69,27 @@ class SMS { return ['success' => false, 'message' => sprintf(__('短信接口缺少配置:%s', 'aksrt'), implode(', ', $missing))]; } - $phone_hash = md5($phone); - $rate_limit = self::RATE_LIMIT; - $rate_key = 'aksrt_sms_rate_' . $phone_hash; - if (get_transient($rate_key) !== false) { - $sent_time = (int) get_transient('aksrt_sms_rate_ts_' . $phone_hash); - $remaining = max(1, $rate_limit - (time() - $sent_time)); + // [防刷机制]验证码发送限制(后台「扩展&增强 → 网站安全」)。 + $limit = \AKSRT\Extensions\SendLimit::check($phone); + if (empty($limit['ok'])) { return [ 'success' => false, - 'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining), - 'remaining_time' => $remaining, + 'message' => (string) $limit['message'], + 'remaining_time' => (int) $limit['remaining'], ]; } - $daily_limit = self::DAILY_LIMIT; - $daily_key = 'aksrt_sms_daily_' . $phone_hash . '_' . wp_date('Ymd'); - $daily_count = (int) get_transient($daily_key); - if ($daily_count >= $daily_limit) { - return ['success' => false, 'message' => __('今日发送次数已达上限', 'aksrt')]; - } - $result = $provider->send_code($phone, $code, $config); if (empty($result['success'])) { return ['success' => false, 'message' => (string) ($result['message'] ?? __('验证码发送失败,请稍后重试', 'aksrt'))]; } - set_transient($rate_key, '1', $rate_limit); - set_transient('aksrt_sms_rate_ts_' . $phone_hash, time(), $rate_limit); - set_transient($daily_key, $daily_count + 1, DAY_IN_SECONDS); + $cooldown = \AKSRT\Extensions\SendLimit::record($phone); return [ 'success' => true, 'message' => __('验证码短信已发送', 'aksrt'), - 'remaining_time' => $rate_limit, + 'remaining_time' => $cooldown > 0 ? $cooldown : 60, ]; } public function get_provider_status(): array { diff --git a/tests/widget-system/account-settings-contract.test.cjs b/tests/widget-system/account-settings-contract.test.cjs index 4c0cef66..d3169a6b 100644 --- a/tests/widget-system/account-settings-contract.test.cjs +++ b/tests/widget-system/account-settings-contract.test.cjs @@ -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, / 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, /' \. 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); diff --git a/tests/widget-system/account-settings-harness.php b/tests/widget-system/account-settings-harness.php index a40cd9df..690df639 100644 --- a/tests/widget-system/account-settings-harness.php +++ b/tests/widget-system/account-settings-harness.php @@ -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']); diff --git a/tests/widget-system/site-security-contract.test.cjs b/tests/widget-system/site-security-contract.test.cjs new file mode 100644 index 00000000..6fbc8d9b --- /dev/null +++ b/tests/widget-system/site-security-contract.test.cjs @@ -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/); +}); diff --git a/tests/widget-system/site-security-harness.php b/tests/widget-system/site-security-harness.php new file mode 100644 index 00000000..18a927d1 --- /dev/null +++ b/tests/widget-system/site-security-harness.php @@ -0,0 +1,229 @@ + $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"; +}