feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,能有效提高文件的安全性' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,可有效提高文件的安全性。' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -824,16 +824,30 @@
|
||||
|
||||
function sendPhoneCode(button, form, stage) {
|
||||
if (!form || button.disabled) 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') {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* Phone number masking helpers shared by the account UI and the
|
||||
* phone-match verification endpoint.
|
||||
*
|
||||
* The hidden middle segment adapts to the number: domestic 11-digit
|
||||
* numbers hide 4 digits (138****1234), longer international numbers
|
||||
* hide length-7 digits (min 4), so the digit-entry UI renders one box
|
||||
* per hidden digit.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a bound phone number into visible prefix / hidden middle / visible suffix.
|
||||
*
|
||||
* Works on the digit core of the number (a leading "+" is ignored for the split
|
||||
* but reported back). Returns null when the number holds fewer than 8 digits —
|
||||
* such numbers cannot be verified by middle-digit entry.
|
||||
*
|
||||
* @return array{prefix:string,hidden:string,suffix:string,hidden_length:int,leading_plus:bool}|null
|
||||
*/
|
||||
function aksrt_phone_mask_parts(string $phone): ?array {
|
||||
$phone = trim($phone);
|
||||
if ($phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$leading_plus = $phone[0] === '+';
|
||||
$core = preg_replace('/\D+/', '', $phone);
|
||||
$length = strlen($core);
|
||||
if ($length < 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hidden_length = min(11, max(4, $length - 7));
|
||||
$suffix_length = $length - 3 - $hidden_length;
|
||||
if ($suffix_length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'prefix' => 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;
|
||||
}
|
||||
@@ -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 {
|
||||
<?php if ($account_phone !== '') : ?>
|
||||
<ul class="mb20 step-simple account-step-list" data-account-step-list="phone"><li class="active"><span><?php esc_html_e('验证老手机', 'aksrt'); ?></span></li><li><span><?php esc_html_e('验证新手机', 'aksrt'); ?></span></li><li><span><?php esc_html_e('修改成功', 'aksrt'); ?></span></li></ul>
|
||||
<div class="account-step-panels">
|
||||
<div class="account-step is-active" data-account-step="phone-old"><p class="muted-2-color"><?php printf(esc_html__('请在下方获取验证码以验证您的老手机号 %s', 'aksrt'), '<br><span class="badg">' . esc_html($account_phone_display) . '</span>'); ?></p><form id="phone-old-verify-form"><input type="hidden" name="phone" value="<?php echo esc_attr($account_phone); ?>"><div class="mb20"><?php if (function_exists('aksrt_render_captcha')) : ?><?php aksrt_render_captcha('bind_phone'); ?><?php endif; ?><div class="relative line-form mb10 modal-line-field modal-line-field--code"><input type="text" name="code" class="line-form-input" maxlength="6" inputmode="numeric" autocomplete="one-time-code" required><div class="scale-placeholder"><?php esc_html_e('验证码', 'aksrt'); ?></div><span class="yztx abs-right"><button type="button" class="but c-blue code-send-btn account-code-send" data-aksrt-sms-trigger data-aksrt-sms-scene="verify_old_phone"><?php esc_html_e('发送验证码', 'aksrt'); ?></button></span><i class="line-form-line"></i></div></div><button type="submit" class="but jb-blue padding-lg btn-block"><i class="fa fas fa-shield-alt" aria-hidden="true"></i> <?php esc_html_e('立即验证', 'aksrt'); ?></button></form></div>
|
||||
<div class="account-step is-active" data-account-step="phone-old">
|
||||
<?php $phone_mask = function_exists('aksrt_phone_mask_parts') ? aksrt_phone_mask_parts($account_phone) : null; ?>
|
||||
<?php if ($phone_mask !== null) : ?>
|
||||
<p class="muted-2-color"><?php printf(esc_html__('为确认是您本人操作,请补全绑定手机号 %s 中缺失的数字', 'aksrt'), '<br><span class="badg">' . esc_html($account_phone_display) . '</span>'); ?></p>
|
||||
<?php else : ?>
|
||||
<p class="muted-2-color"><?php printf(esc_html__('请在下方获取验证码以验证您的老手机号 %s', 'aksrt'), '<br><span class="badg">' . esc_html($account_phone_display) . '</span>'); ?></p>
|
||||
<?php endif; ?>
|
||||
<form id="phone-old-verify-form" novalidate>
|
||||
<?php if ($phone_mask !== null) : ?>
|
||||
<div class="mb20 aksrt-phone-digits" data-aksrt-phone-digits data-hidden-length="<?php echo esc_attr((string) $phone_mask['hidden_length']); ?>" role="group" aria-label="<?php esc_attr_e('请补全手机号中缺失的数字', 'aksrt'); ?>">
|
||||
<?php for ($phone_digit_index = 0; $phone_digit_index < (int) $phone_mask['hidden_length']; $phone_digit_index++) : ?>
|
||||
<input type="text" class="aksrt-phone-digit" inputmode="numeric" maxlength="1" autocomplete="one-time-code" data-digit-index="<?php echo esc_attr((string) $phone_digit_index); ?>" aria-label="<?php echo esc_attr(sprintf(__('手机号第 %d 位数字', 'aksrt'), 4 + $phone_digit_index)); ?>">
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<p class="aksrt-phone-digits__error" data-aksrt-phone-digits-error hidden></p>
|
||||
<?php endif; ?>
|
||||
<div class="mb20" data-aksrt-phone-code-area<?php echo $phone_mask !== null ? ' hidden' : ''; ?>>
|
||||
<?php if (function_exists('aksrt_render_captcha')) : ?><?php aksrt_render_captcha('bind_phone'); ?><?php endif; ?>
|
||||
<div class="relative line-form mb10 modal-line-field modal-line-field--code"><input type="text" name="code" class="line-form-input" maxlength="6" inputmode="numeric" autocomplete="one-time-code" required><div class="scale-placeholder"><?php esc_html_e('验证码', 'aksrt'); ?></div><span class="yztx abs-right"><button type="button" class="but c-blue code-send-btn account-code-send" data-aksrt-sms-trigger data-aksrt-sms-scene="verify_old_phone"><?php esc_html_e('发送验证码', 'aksrt'); ?></button></span><i class="line-form-line"></i></div>
|
||||
<button type="submit" class="but jb-blue padding-lg btn-block"><i class="fa fas fa-shield-alt" aria-hidden="true"></i> <?php esc_html_e('立即验证', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="account-step" data-account-step="phone-new"><p class="muted-2-color"><?php esc_html_e('请输入您需要修改的新手机号码', 'aksrt'); ?></p><?php $render_phone_form(); ?></div>
|
||||
<div class="account-step" data-account-step="phone-success"><div class="text-center padding-h10"><i class="fa fas fa-check-circle c-green em3x mb10" aria-hidden="true"></i><p><?php esc_html_e('手机号修改成功', 'aksrt'); ?></p></div></div>
|
||||
</div>
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -17,6 +17,7 @@ class SchemaExtensions {
|
||||
'ip' => SchemaExtensionsIP::build(),
|
||||
'audit' => SchemaExtensionsAudit::build(),
|
||||
'push' => SchemaExtensionsPush::build(),
|
||||
'security' => SchemaExtensionsSecurity::build(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaExtensionsSecurity {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => '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,能有效提高文件的安全性'],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,6 @@ class SchemaPermissions {
|
||||
['label' => '时间 + 原文件名', 'value' => 'time'],
|
||||
['label' => '随机字符串', 'value' => 'random'],
|
||||
]],
|
||||
['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
'<p>' . esc_html((string) $brush) . '</p>',
|
||||
esc_html__('评论提交失败', 'aksrt'),
|
||||
['response' => 429]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
|
||||
if (empty($config['enabled'])) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
/**
|
||||
* 网站安全:安全加固开关、内容防刷限制与验证错误频率限制。
|
||||
*
|
||||
* 对应后台「扩展&增强 → 网站安全」设置(ExtensionRuntime::get_security_settings()),
|
||||
* 行为对齐参考主题 zib-theme.php / action/function.php 的安全套件。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SecurityHardening {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
$settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
|
||||
if (!empty($settings['security_disabled_xmlrpc'])) {
|
||||
add_filter('xmlrpc_enabled', '__return_false');
|
||||
add_filter('xmlrpc_methods', '__return_empty_array');
|
||||
}
|
||||
|
||||
if (!empty($settings['security_disabled_pingback'])) {
|
||||
add_action('pre_ping', [$this, 'disable_self_pingback']);
|
||||
}
|
||||
|
||||
if (!empty($settings['security_user_disable_admin'])) {
|
||||
add_action('admin_init', [$this, 'restrict_admin_access']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 防 pingback 攻击:移除指向自己站点的 pingback。
|
||||
*/
|
||||
public function disable_self_pingback(array &$links): void {
|
||||
$home = home_url();
|
||||
foreach ($links as $index => $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* 网站安全:验证码邮件/短信发送频率限制([防刷机制]验证码发送限制)。
|
||||
*
|
||||
* 五个维度全部可配置(0 = 不限制):同一收件人冷却、同一收件人 1 小时/一天上限、
|
||||
* 同一 IP 10 分钟/一天上限。短信在 SMS::send_code 内统一接入,邮件在发送验证码
|
||||
* 的调用点接入。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SendLimit {
|
||||
|
||||
/**
|
||||
* 发送前检查。命中任一限制返回 ['ok' => 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');
|
||||
}
|
||||
}
|
||||
@@ -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 下留痕),既不把非法长度写进库,
|
||||
// 也不因此抛错中断调用方。
|
||||
|
||||
@@ -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();
|
||||
|
||||
+7
-22
@@ -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 {
|
||||
|
||||
@@ -25,10 +25,21 @@ test('account dialogs expose configured email steps, phone steps, and reference
|
||||
assert.match(template, /data-aksrt-sms-scene="bind_new_phone"/);
|
||||
assert.match(template, /\$account_close_icon = function_exists\('aksrt_icon_markup'\)/);
|
||||
assert.match(template, /<i class="fa far fa-envelope"/);
|
||||
assert.match(template, /\$account_phone_display = strlen\(\$account_phone\) > 10 \? substr_replace\(\$account_phone, '\*\*\*\*', 3, 4\)/);
|
||||
assert.match(template, /\$account_phone_display = function_exists\('aksrt_phone_mask_display'\) \? aksrt_phone_mask_display\(\$account_phone\) : \$account_phone;/);
|
||||
assert.match(template, /\$account_phone !== '' \? \$account_phone_display : __\('暂未绑定'/);
|
||||
assert.match(template, /<span class="badg">' \. esc_html\(\$account_phone_display\)/);
|
||||
assert.match(template, /name="phone" value="<\?php echo esc_attr\(\$account_phone\); \?>"/);
|
||||
assert.doesNotMatch(template, /name="phone" value="/);
|
||||
assert.match(template, /data-aksrt-phone-digits data-hidden-length/);
|
||||
assert.match(template, /class="aksrt-phone-digit" inputmode="numeric" maxlength="1"/);
|
||||
assert.match(template, /aria-label="<\?php echo esc_attr\(sprintf\(__\('手机号第 %d 位数字', 'aksrt'\), 4 \+ \$phone_digit_index\)\); \?>"/);
|
||||
assert.match(template, /data-aksrt-phone-code-area<\?php echo \$phone_mask !== null \? ' hidden' : ''; \?>/);
|
||||
assert.match(template, /data-aksrt-phone-digits-error/);
|
||||
assert.match(javascript, /\/user\/phone-match/);
|
||||
assert.match(javascript, /middle: middle/);
|
||||
assert.match(javascript, /stage: 'reset_old_phone'/);
|
||||
assert.match(javascript, /function initPhoneDigits\(/);
|
||||
assert.match(css, /\.aksrt-settings-modal \.aksrt-phone-digit \{/);
|
||||
assert.match(css, /\.aksrt-settings-modal \.aksrt-phone-digits\.has-error \.aksrt-phone-digit \{/);
|
||||
assert.match(template, /aksrt_render_captcha\('change_email'\)[\s\S]*?id="email-code"/);
|
||||
assert.match(template, /aksrt_render_captcha\('bind_phone'\)[\s\S]*?id="bind-phone-code"/);
|
||||
assert.match(template, /if \(!\$account_oauth_new\)[\s\S]*?name="passwordold"/);
|
||||
@@ -73,6 +84,10 @@ test('email update keeps both proofs through validation failures and consumes th
|
||||
|
||||
assert.match(send, /\['verify_email', 'change_email'\]/);
|
||||
assert.match(send, /email_exists\(\$target\)/);
|
||||
assert.match(send, /SendLimit::check\(\$target\)/);
|
||||
assert.match(send, /SendLimit::record\(\$target\)/);
|
||||
assert.doesNotMatch(send, /aksrt_account_email_rate_/);
|
||||
assert.doesNotMatch(ajax, /aksrt_account_email_rate_/);
|
||||
assert.match(verify, /aksrt_require_captcha\(\s*'change_email'/);
|
||||
assert.match(verify, /aksrt_account_set_proof\(\$user_id, 'email_old'/);
|
||||
assert.match(change, /aksrt_account_email_verification_required\(\)/);
|
||||
@@ -93,8 +108,15 @@ test('phone replacement requires old-number proof and a new-number code while re
|
||||
const bind = controller.slice(controller.indexOf('public function user_bind_phone('), controller.indexOf('public function test_sms_send('));
|
||||
|
||||
assert.match(routes, /\/user\/phone-verify[\s\S]*?user_verify_phone/);
|
||||
assert.match(routes, /\/user\/phone-match[\s\S]*?user_phone_match/);
|
||||
assert.match(code, /verify_old_phone/);
|
||||
assert.match(code, /bind_new_phone/);
|
||||
assert.match(code, /public function user_phone_match\(/);
|
||||
assert.match(code, /auth_middle_matches\(\$parts, \$middle\)/);
|
||||
assert.match(code, /reset_old_phone/);
|
||||
assert.match(code, /填写的号码与绑定手机号不一致/);
|
||||
assert.match(code, /\$phone = \$current_phone;/);
|
||||
assert.doesNotMatch(code, /auth_rate_limited\('account-phone-code'/);
|
||||
assert.match(verify, /auth_consume_code\('account_verify_old_phone_' \./);
|
||||
assert.match(verify, /aksrt_account_phone_old_/);
|
||||
assert.match(bind, /aksrt_account_phone_old_/);
|
||||
@@ -113,7 +135,11 @@ test('password change retains old-password limits and captcha; recovery uses sha
|
||||
|
||||
assert.match(password, /get_user_meta\(\$user_id, 'oauth_new', true\)/);
|
||||
assert.match(password, /wp_check_password\(\$old/);
|
||||
assert.match(password, /aksrt_account_rate_limited\(\$user_id, 'change_password'\)/);
|
||||
assert.match(password, /SecurityHardening::error_frequency_wait\(\)/);
|
||||
assert.match(password, /SecurityHardening::error_frequency_record\(\)/);
|
||||
assert.doesNotMatch(password, /aksrt_account_rate_limited/);
|
||||
assert.doesNotMatch(ajax, /function aksrt_account_rate_limited/);
|
||||
assert.doesNotMatch(ajax, /function aksrt_account_reset_rate/);
|
||||
assert.match(password, /aksrt_require_captcha\('change_password'/);
|
||||
assert.match(password, /Auth\\Service::validate_password\(\$password, \$password2\)/);
|
||||
assert.doesNotMatch(password, /email.*code|password.*strength/i);
|
||||
|
||||
@@ -128,8 +128,12 @@ namespace {
|
||||
return 'aksrt_account_code_7_' . $scene . '_' . md5(strtolower(trim($target)));
|
||||
}
|
||||
|
||||
function account_settings_rate_key(string $scene, string $target): string {
|
||||
return 'aksrt_account_email_rate_7_' . $scene . '_' . md5(strtolower(trim($target)));
|
||||
function account_settings_send_cooldown_key(string $target): string {
|
||||
return 'aksrt_send_cd_' . md5(strtolower(trim($target)));
|
||||
}
|
||||
|
||||
function account_settings_error_frequency_key(): string {
|
||||
return 'aksrt_err_freq_test';
|
||||
}
|
||||
|
||||
function account_settings_code(string $scene, string $target): string {
|
||||
@@ -165,6 +169,39 @@ namespace AKSRT\Auth {
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Extensions {
|
||||
final class SendLimit {
|
||||
public static function check(string $target): array {
|
||||
$sent_at = (int) get_transient('aksrt_send_cd_' . md5(strtolower(trim($target))));
|
||||
if ($sent_at > 0 && time() - $sent_at < 60) {
|
||||
return ['ok' => false, 'message' => 'cooldown', 'remaining' => 60 - (time() - $sent_at)];
|
||||
}
|
||||
return ['ok' => true, 'message' => '', 'remaining' => 0];
|
||||
}
|
||||
public static function record(string $target): int {
|
||||
set_transient('aksrt_send_cd_' . md5(strtolower(trim($target))), time(), 60);
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
final class SecurityHardening {
|
||||
public static function error_frequency_wait(): int {
|
||||
$state = get_transient('aksrt_err_freq_test');
|
||||
$count = is_array($state) ? (int) ($state['count'] ?? 0) : 0;
|
||||
if ($count < 5) return 0;
|
||||
return max(0, ($count >= 10 ? 180 : 30) - (time() - (int) ($state['last'] ?? 0)));
|
||||
}
|
||||
public static function error_frequency_record(): void {
|
||||
$state = get_transient('aksrt_err_freq_test');
|
||||
$count = is_array($state) ? (int) ($state['count'] ?? 0) + 1 : 1;
|
||||
set_transient('aksrt_err_freq_test', ['count' => $count, 'last' => time()], 600);
|
||||
}
|
||||
public static function error_frequency_clear(): void {
|
||||
delete_transient('aksrt_err_freq_test');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
$GLOBALS['aksrt_account_user'] = new WP_User(7, 'old@example.test', 'Test User', 'hash:OldPass123');
|
||||
$send_action = 'wp_ajax_aksrt_send_account_email_code';
|
||||
@@ -181,6 +218,9 @@ namespace {
|
||||
|
||||
$old_send = account_settings_request($send_action, ['scene' => 'verify_email']);
|
||||
account_settings_check(!empty($old_send['success']), 'old email verification code could not be sent');
|
||||
account_settings_check($old_send['data']['countdown'] === 60, 'old email code send did not surface the cooldown countdown');
|
||||
$cooldown_send = account_settings_request($send_action, ['scene' => 'verify_email']);
|
||||
account_settings_check(empty($cooldown_send['success']), 'email code resend was not blocked by the send cooldown');
|
||||
$old_code = account_settings_code('verify_email', 'old@example.test');
|
||||
$bad_old_code = account_settings_request($verify_action, ['code' => 'wrong']);
|
||||
account_settings_check(empty($bad_old_code['success']), 'email verification accepted an incorrect old-email code');
|
||||
@@ -191,7 +231,7 @@ namespace {
|
||||
account_settings_check(empty($expired_old_code['success']), 'email verification accepted an expired old-email code');
|
||||
account_settings_check(get_transient(account_settings_code_key('verify_email', 'old@example.test')) === false, 'expired old-email code was not discarded');
|
||||
account_settings_check(!aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'expired old-email code issued an old-email proof');
|
||||
delete_transient(account_settings_rate_key('verify_email', 'old@example.test'));
|
||||
delete_transient(account_settings_send_cooldown_key('old@example.test'));
|
||||
$old_send = account_settings_request($send_action, ['scene' => 'verify_email']);
|
||||
account_settings_check(!empty($old_send['success']), 'old email code could not be resent after expiry');
|
||||
$old_code = account_settings_code('verify_email', 'old@example.test');
|
||||
@@ -220,7 +260,7 @@ namespace {
|
||||
account_settings_check(empty($expired_new_code['success']), 'email update accepted an expired new-email code');
|
||||
account_settings_check(aksrt_account_consume_proof(7, 'email_old', 'old@example.test', false), 'expired new-email code validation consumed the old-email proof');
|
||||
account_settings_check(get_transient(account_settings_code_key('change_email', $new_email)) === false, 'expired new-email code was not discarded');
|
||||
delete_transient(account_settings_rate_key('change_email', $new_email));
|
||||
delete_transient(account_settings_send_cooldown_key($new_email));
|
||||
$new_send = account_settings_request($send_action, ['scene' => 'change_email', 'new_email' => $new_email]);
|
||||
account_settings_check(!empty($new_send['success']), 'new email code could not be resent after expiry');
|
||||
$new_code = account_settings_code('change_email', $new_email);
|
||||
@@ -266,11 +306,11 @@ namespace {
|
||||
}
|
||||
$limited_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']);
|
||||
account_settings_check(empty($limited_password['success']), 'password change did not apply its old-password attempt limit');
|
||||
delete_transient('aksrt_account_rate_change_password_7');
|
||||
delete_transient(account_settings_error_frequency_key());
|
||||
$changed_password = account_settings_request($change_password_action, ['password' => 'NewPass123', 'password2' => 'NewPass123', 'passwordold' => 'OldPass123']);
|
||||
account_settings_check(!empty($changed_password['success']), 'password change rejected the correct old password');
|
||||
account_settings_check($GLOBALS['aksrt_account_user']->user_pass === 'hash:NewPass123', 'password change did not persist the new password');
|
||||
account_settings_check(get_transient('aksrt_account_rate_change_password_7') === false, 'successful password change did not reset its error limit');
|
||||
account_settings_check(get_transient(account_settings_error_frequency_key()) === false, 'successful password change did not reset its error limit');
|
||||
|
||||
update_user_meta(7, 'oauth_new', 1);
|
||||
$oauth_password = account_settings_request($change_password_action, ['password' => 'OAuthPass123', 'password2' => 'OAuthPass123']);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('site security schema registers the 网站安全 page without 人机验证 fields', () => {
|
||||
const schema = read('src/Admin/Schema/SchemaExtensionsSecurity.php');
|
||||
const registry = read('src/Admin/Schema/SchemaExtensions.php');
|
||||
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
|
||||
const clientMirror = read('assets/admin/src/schema/sections/extensions/security.js');
|
||||
const clientIndex = read('assets/admin/src/schema/sections/extensions/index.js');
|
||||
const nav = read('assets/admin/src/constants/sections/extensions.js');
|
||||
|
||||
assert.match(registry, /'security'\s+=> SchemaExtensionsSecurity::build\(\)/);
|
||||
assert.match(schema, /'key'\s+=> 'security'/);
|
||||
assert.match(schema, /'title'\s+=> '网站安全'/);
|
||||
for (const key of [
|
||||
'security_brush_post_10min', 'security_brush_post_day',
|
||||
'security_brush_comment_10min', 'security_brush_comment_day',
|
||||
'security_brush_upload_10min', 'security_brush_upload_day',
|
||||
'security_brush_register_10min', 'security_brush_register_day',
|
||||
'security_brush_order_10min', 'security_brush_order_day',
|
||||
'security_captcha_send_cooldown', 'security_captcha_to_hour', 'security_captcha_to_day',
|
||||
'security_captcha_ip_10min', 'security_captcha_ip_day',
|
||||
'security_error_frequency_limit', 'security_disabled_pingback', 'security_disabled_xmlrpc',
|
||||
'security_user_disable_admin', 'close_attachment_page',
|
||||
]) {
|
||||
assert.match(schema, new RegExp(`'${key}'`), `missing schema field ${key}`);
|
||||
assert.match(clientMirror, new RegExp(`${key}`), `missing client mirror field ${key}`);
|
||||
}
|
||||
assert.doesNotMatch(schema + clientMirror, /verification_|geetest|tcaptcha|user_verification_type/);
|
||||
assert.doesNotMatch(permissions, /close_attachment_page/);
|
||||
assert.match(clientIndex, /import \{ securitySchema \} from '\.\/security\.js'/);
|
||||
assert.match(clientIndex, /security: securitySchema/);
|
||||
assert.match(nav, /key: 'extensions-security'/);
|
||||
assert.match(nav, /route: '\/extensions\/security'/);
|
||||
assert.match(nav, /section: 'security'/);
|
||||
});
|
||||
|
||||
test('security settings are consumed at runtime and initialized on boot', () => {
|
||||
const runtime = read('src/Settings/ExtensionRuntime.php');
|
||||
const bootstrap = read('functions.php');
|
||||
|
||||
assert.match(runtime, /public static function get_security_settings\(\): array/);
|
||||
assert.match(bootstrap, /\\AKSRT\\Extensions\\SecurityHardening::get_instance\(\)->init\(\)/);
|
||||
});
|
||||
|
||||
test('hardening switches wire the documented WordPress hooks', () => {
|
||||
const hardening = read('src/Extensions/SecurityHardening.php');
|
||||
|
||||
assert.match(hardening, /add_filter\('xmlrpc_enabled', '__return_false'\)/);
|
||||
assert.match(hardening, /add_filter\('xmlrpc_methods', '__return_empty_array'\)/);
|
||||
assert.match(hardening, /add_action\('pre_ping', \[\$this, 'disable_self_pingback'\]\)/);
|
||||
assert.match(hardening, /add_action\('admin_init', \[\$this, 'restrict_admin_access'\]\)/);
|
||||
assert.match(hardening, /wp_doing_ajax\(\) \|\| wp_doing_cron\(\)/);
|
||||
assert.match(hardening, /current_user_can\('manage_options'\)/);
|
||||
assert.match(hardening, /brush_allow\(string \$scope, string \$subject, int \$cap_10, int \$cap_day\)/);
|
||||
assert.match(hardening, /error_frequency_wait\(\): int/);
|
||||
assert.match(hardening, /error_frequency_record\(\): void/);
|
||||
assert.match(hardening, /error_frequency_clear\(\): void/);
|
||||
});
|
||||
|
||||
test('verification-code sends are funneled through the configurable send limiter', () => {
|
||||
const sms = read('src/Sms/SMS.php');
|
||||
const sendLimit = read('src/Extensions/SendLimit.php');
|
||||
const ajax = read('inc/modules/user-parts/account-ajax.php');
|
||||
const controller = read('src/Admin/UserControllerAuthPhoneOperations.php');
|
||||
|
||||
assert.match(sms, /SendLimit::check\(\$phone\)/);
|
||||
assert.match(sms, /SendLimit::record\(\$phone\)/);
|
||||
assert.doesNotMatch(sms, /RATE_LIMIT|DAILY_LIMIT|aksrt_sms_rate_|aksrt_sms_daily_/);
|
||||
assert.match(sendLimit, /security_captcha_send_cooldown/);
|
||||
assert.match(sendLimit, /security_captcha_to_hour/);
|
||||
assert.match(sendLimit, /security_captcha_to_day/);
|
||||
assert.match(sendLimit, /security_captcha_ip_10min/);
|
||||
assert.match(sendLimit, /security_captcha_ip_day/);
|
||||
assert.match(ajax, /SendLimit::check\(\$target\)/);
|
||||
assert.match(ajax, /SendLimit::record\(\$target\)/);
|
||||
assert.match(controller, /SendLimit::check\(\$target\)/);
|
||||
assert.match(controller, /SendLimit::record\(\$target\)/);
|
||||
});
|
||||
|
||||
test('content brush limits are wired into post, comment, upload, register, and order flows', () => {
|
||||
const frontSubmit = read('src/Core/FrontSubmitSave.php');
|
||||
const moderation = read('src/Auth/Moderation.php');
|
||||
const upload = read('src/Attachment/UploadLimits.php');
|
||||
const registerEmail = read('src/Auth/ServicePart1a.php');
|
||||
const registerPhone = read('src/Auth/ServicePart1b.php');
|
||||
const order = read('src/Payment/OrderRepositoryCore.php');
|
||||
|
||||
assert.match(frontSubmit, /brush_allow\('post', \$brush_subject, \(int\) \$security\['security_brush_post_10min'\], \(int\) \$security\['security_brush_post_day'\]\)/);
|
||||
assert.match(moderation, /brush_allow\(\s*'comment'/);
|
||||
assert.match(upload, /add_filter\('wp_handle_upload_prefilter', \[\$this, 'brush_limit_upload'\], 90\)/);
|
||||
assert.match(upload, /brush_allow\('upload', \$subject, \(int\) \$security\['security_brush_upload_10min'\], \(int\) \$security\['security_brush_upload_day'\]\)/);
|
||||
assert.match(registerEmail, /brush_allow\('register'/);
|
||||
assert.match(registerPhone, /brush_allow\('register'/);
|
||||
assert.match(order, /brush_allow\('order', \$brush_subject, \(int\) \$security\['security_brush_order_10min'\], \(int\) \$security\['security_brush_order_day'\]\)/);
|
||||
});
|
||||
|
||||
test('phone masking is shared by the account UI and adapts to international numbers', () => {
|
||||
const helper = read('inc/helpers/aksrt-phone.php');
|
||||
const bootstrap = read('inc/bootstrap.php');
|
||||
const template = read('inc/helpers/user-center-renderers/settings-account.php');
|
||||
|
||||
assert.match(bootstrap, /inc\/helpers\/aksrt-phone\.php/);
|
||||
assert.match(helper, /function aksrt_phone_mask_parts\(string \$phone\): \?array/);
|
||||
assert.match(helper, /function aksrt_phone_mask_display\(string \$phone\): string/);
|
||||
assert.match(helper, /min\(11, max\(4, \$length - 7\)\)/);
|
||||
assert.doesNotMatch(template, /substr_replace\(\$account_phone, '\*\*\*\*', 3, 4\)/);
|
||||
assert.match(template, /aksrt_phone_mask_parts\(\$account_phone\)/);
|
||||
assert.match(template, /aksrt_phone_mask_display\(\$account_phone\)/);
|
||||
});
|
||||
|
||||
test('site security runtime contract harness passes', () => {
|
||||
const harness = path.join(root, 'tests', 'widget-system', 'site-security-harness.php');
|
||||
const output = execFileSync('php', [harness], { encoding: 'utf8' });
|
||||
assert.match(output, /Site security harness passed/);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/** Site security (网站安全) runtime contract harness: brush limits, send limiter, error frequency, phone masking, schema. */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
define('MINUTE_IN_SECONDS', 60);
|
||||
define('HOUR_IN_SECONDS', 3600);
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
$GLOBALS['aksrt_security_settings'] = [];
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
$GLOBALS['aksrt_security_user_id'] = 7;
|
||||
$_SERVER['REMOTE_ADDR'] = '203.0.113.9';
|
||||
|
||||
function __(string $text, string $domain = ''): string { return $text; }
|
||||
function sanitize_key(string $key): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($key)) ?? ''; }
|
||||
function get_current_user_id(): int { return (int) $GLOBALS['aksrt_security_user_id']; }
|
||||
function current_user_can(string $capability): bool { return false; }
|
||||
function wp_date(string $format): string { return date($format); }
|
||||
function add_filter(string $hook, callable $callback, int $priority = 10, int $args = 1): void {}
|
||||
function add_action(string $hook, callable $callback, int $priority = 10, int $args = 1): void {}
|
||||
function set_transient(string $key, mixed $value, int $expiration = 0): bool {
|
||||
$GLOBALS['aksrt_security_transients'][$key] = ['value' => $value, 'expires' => $expiration > 0 ? time() + $expiration : 0];
|
||||
return true;
|
||||
}
|
||||
function get_transient(string $key): mixed {
|
||||
$row = $GLOBALS['aksrt_security_transients'][$key] ?? null;
|
||||
if (!is_array($row)) return false;
|
||||
if (!empty($row['expires']) && $row['expires'] <= time()) {
|
||||
unset($GLOBALS['aksrt_security_transients'][$key]);
|
||||
return false;
|
||||
}
|
||||
return $row['value'] ?? false;
|
||||
}
|
||||
function delete_transient(string $key): bool {
|
||||
unset($GLOBALS['aksrt_security_transients'][$key]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function site_security_check(bool $condition, string $message): void {
|
||||
if (!$condition) throw new \RuntimeException($message);
|
||||
}
|
||||
|
||||
function site_security_brush_minute_key(string $scope, string $subject): string {
|
||||
return sprintf('aksrt_brush_%s_m_%s_%s%d', $scope, md5($subject), wp_date('YmdH'), intdiv((int) wp_date('i'), 10));
|
||||
}
|
||||
|
||||
function site_security_brush_day_key(string $scope, string $subject): string {
|
||||
return sprintf('aksrt_brush_%s_d_%s_%s', $scope, md5($subject), wp_date('Ymd'));
|
||||
}
|
||||
|
||||
require dirname(__DIR__, 2) . '/src/Settings/ExtensionRuntime.php';
|
||||
require dirname(__DIR__, 2) . '/src/Extensions/SecurityHardening.php';
|
||||
require dirname(__DIR__, 2) . '/src/Extensions/SendLimit.php';
|
||||
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-phone.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsIP.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsAudit.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsPush.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensionsSecurity.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaExtensions.php';
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
final class Settings {
|
||||
public static function get_all(): array { return $GLOBALS['aksrt_security_settings']; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use AKSRT\Admin\Schema\SchemaExtensions;
|
||||
use AKSRT\Extensions\SecurityHardening;
|
||||
use AKSRT\Extensions\SendLimit;
|
||||
use AKSRT\Settings\ExtensionRuntime;
|
||||
|
||||
// 1) Phone masking: domestic hides 4 digits, international hides length-7 (min 4), short numbers are unmaskable.
|
||||
$domestic = aksrt_phone_mask_parts('13812341234');
|
||||
site_security_check($domestic !== null, 'domestic number should be maskable');
|
||||
site_security_check($domestic['prefix'] === '138' && $domestic['hidden'] === '1234' && $domestic['suffix'] === '1234', 'domestic mask split is wrong');
|
||||
site_security_check($domestic['hidden_length'] === 4, 'domestic mask should hide 4 digits');
|
||||
site_security_check(aksrt_phone_mask_display('13812341234') === '138****1234', 'domestic masked display is wrong');
|
||||
|
||||
$intl = aksrt_phone_mask_parts('+8613812341234');
|
||||
site_security_check($intl !== null, 'international number should be maskable');
|
||||
site_security_check($intl['hidden_length'] === 6, 'international mask should hide length-7 digits');
|
||||
site_security_check($intl['hidden'] === '381234', 'international hidden segment is wrong');
|
||||
site_security_check($intl['leading_plus'] === true, 'leading plus flag is wrong');
|
||||
site_security_check(aksrt_phone_mask_display('+8613812341234') === '+861******1234', 'international masked display is wrong');
|
||||
|
||||
$eight = aksrt_phone_mask_parts('12345678');
|
||||
site_security_check($eight !== null && $eight['hidden_length'] === 4 && $eight['suffix'] === '8', 'short mask should still hide 4 digits with a 1-digit suffix');
|
||||
site_security_check(aksrt_phone_mask_parts('1234567') === null, 'numbers with fewer than 8 digits must be unmaskable');
|
||||
site_security_check(aksrt_phone_mask_parts('') === null, 'empty number must be unmaskable');
|
||||
site_security_check(aksrt_phone_mask_display('1234567') === '1234567', 'unmaskable numbers must display raw');
|
||||
|
||||
// 2) Brush limits: 10-minute and daily caps, 0 = unlimited, admin exempt handled by callers.
|
||||
$allowed = SecurityHardening::brush_allow('post', '12', 2, 0);
|
||||
site_security_check($allowed === true, 'first post within cap was blocked');
|
||||
site_security_check(SecurityHardening::brush_allow('post', '12', 2, 0) === true, 'second post within cap was blocked');
|
||||
site_security_check((int) get_transient(site_security_brush_minute_key('post', '12')) === 2, 'post attempts were not counted');
|
||||
site_security_check(SecurityHardening::brush_allow('post', '12', 2, 0) !== true, 'post 10-minute cap was not enforced');
|
||||
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
site_security_check(SecurityHardening::brush_allow('post', '12', 0, 2) === true, 'first daily post was blocked');
|
||||
site_security_check(SecurityHardening::brush_allow('post', '12', 0, 2) === true, 'second daily post was blocked');
|
||||
site_security_check(SecurityHardening::brush_allow('post', '12', 0, 2) !== true, 'post daily cap was not enforced');
|
||||
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
site_security_check(SecurityHardening::brush_allow('order', '203.0.113.9', 0, 0) === true, 'disabled caps should never block');
|
||||
site_security_check(get_transient(site_security_brush_minute_key('order', '203.0.113.9')) === false, 'disabled caps should not count attempts');
|
||||
|
||||
// 3) Error frequency: silent under 5 failures, 30s window 5-9, 180s window at 10+.
|
||||
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'error frequency should be silent with no failures');
|
||||
for ($failure = 0; $failure < 4; $failure++) {
|
||||
SecurityHardening::error_frequency_record();
|
||||
}
|
||||
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'error frequency limited before reaching 5 failures');
|
||||
SecurityHardening::error_frequency_record();
|
||||
$wait = SecurityHardening::error_frequency_wait();
|
||||
site_security_check($wait > 0 && $wait <= 30, 'error frequency did not apply the 30s window after 5 failures');
|
||||
for ($failure = 0; $failure < 5; $failure++) {
|
||||
SecurityHardening::error_frequency_record();
|
||||
}
|
||||
$long_wait = SecurityHardening::error_frequency_wait();
|
||||
site_security_check($long_wait > 30 && $long_wait <= 180, 'error frequency did not apply the 180s window after 10 failures');
|
||||
SecurityHardening::error_frequency_clear();
|
||||
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'error frequency clear did not reset the counter');
|
||||
|
||||
// Disabling the switch must silence the limit.
|
||||
$GLOBALS['aksrt_security_settings']['security_error_frequency_limit'] = false;
|
||||
SecurityHardening::error_frequency_record();
|
||||
site_security_check(SecurityHardening::error_frequency_wait() === 0, 'disabling security_error_frequency_limit did not silence the limiter');
|
||||
$GLOBALS['aksrt_security_settings'] = [];
|
||||
|
||||
// 4) Send limit: cooldown, recipient hourly/daily, IP 10-minute/daily caps; 0 = unlimited.
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
$first = SendLimit::check('user@example.test');
|
||||
site_security_check($first['ok'] === true, 'first verification send was blocked');
|
||||
site_security_check(SendLimit::record('user@example.test') === 60, 'send limit did not return the cooldown');
|
||||
$second = SendLimit::check('user@example.test');
|
||||
site_security_check($second['ok'] === false && $second['remaining'] > 0, 'send cooldown was not enforced');
|
||||
site_security_check(SendLimit::check('other@example.test')['ok'] === true, 'send cooldown leaked across recipients');
|
||||
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
for ($send = 0; $send < 15; $send++) {
|
||||
site_security_check(SendLimit::record('user@example.test') === 60, 'send record failed');
|
||||
}
|
||||
site_security_check(SendLimit::check('user@example.test')['ok'] === false, 'recipient hourly cap was not enforced');
|
||||
site_security_check(SendLimit::check('user2@example.test')['ok'] === true, 'recipient hourly cap leaked across recipients');
|
||||
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
for ($send = 0; $send < 20; $send++) {
|
||||
SendLimit::record('user@example.test');
|
||||
}
|
||||
site_security_check(SendLimit::check('user2@example.test')['ok'] === false, 'IP 10-minute cap was not enforced');
|
||||
|
||||
$GLOBALS['aksrt_security_transients'] = [];
|
||||
$GLOBALS['aksrt_security_settings']['security_captcha_send_cooldown'] = 0;
|
||||
$GLOBALS['aksrt_security_settings']['security_captcha_to_hour'] = 0;
|
||||
$GLOBALS['aksrt_security_settings']['security_captcha_to_day'] = 0;
|
||||
$GLOBALS['aksrt_security_settings']['security_captcha_ip_10min'] = 0;
|
||||
$GLOBALS['aksrt_security_settings']['security_captcha_ip_day'] = 0;
|
||||
for ($send = 0; $send < 100; $send++) {
|
||||
site_security_check(SendLimit::record('user@example.test') === 0, 'disabled send caps should not cooldown');
|
||||
}
|
||||
site_security_check(SendLimit::check('user@example.test')['ok'] === true, 'disabled send caps still blocked sends');
|
||||
$GLOBALS['aksrt_security_settings'] = [];
|
||||
|
||||
// 5) Runtime defaults mirror the schema defaults.
|
||||
$security = ExtensionRuntime::get_security_settings();
|
||||
site_security_check($security['security_brush_post_10min'] === 20 && $security['security_brush_post_day'] === 50, 'post cap defaults are wrong');
|
||||
site_security_check($security['security_brush_comment_10min'] === 20 && $security['security_brush_comment_day'] === 100, 'comment cap defaults are wrong');
|
||||
site_security_check($security['security_brush_upload_10min'] === 50 && $security['security_brush_upload_day'] === 200, 'upload cap defaults are wrong');
|
||||
site_security_check($security['security_brush_register_10min'] === 20 && $security['security_brush_register_day'] === 100, 'register cap defaults are wrong');
|
||||
site_security_check($security['security_brush_order_10min'] === 30 && $security['security_brush_order_day'] === 100, 'order cap defaults are wrong');
|
||||
site_security_check($security['security_captcha_send_cooldown'] === 60, 'send cooldown default is wrong');
|
||||
site_security_check($security['security_captcha_to_hour'] === 15 && $security['security_captcha_to_day'] === 30, 'recipient cap defaults are wrong');
|
||||
site_security_check($security['security_captcha_ip_10min'] === 20 && $security['security_captcha_ip_day'] === 50, 'IP cap defaults are wrong');
|
||||
site_security_check($security['security_error_frequency_limit'] === true, 'error frequency default is wrong');
|
||||
site_security_check($security['security_disabled_pingback'] === true && $security['security_disabled_xmlrpc'] === true, 'hardening switch defaults are wrong');
|
||||
site_security_check($security['security_user_disable_admin'] === true && $security['close_attachment_page'] === true, 'access switch defaults are wrong');
|
||||
|
||||
$GLOBALS['aksrt_security_settings']['security_brush_post_10min'] = '35';
|
||||
$GLOBALS['aksrt_security_settings']['close_attachment_page'] = 0;
|
||||
$stored = ExtensionRuntime::get_security_settings();
|
||||
site_security_check($stored['security_brush_post_10min'] === 35, 'stored cap override was not honored');
|
||||
site_security_check($stored['close_attachment_page'] === false, 'stored switch override was not honored');
|
||||
$GLOBALS['aksrt_security_settings'] = [];
|
||||
|
||||
// 6) Schema: security section is registered, defaults match, and no 人机验证 fields exist.
|
||||
$extensions = SchemaExtensions::build();
|
||||
site_security_check(array_key_exists('security', $extensions['sections']), 'security section is not registered under extensions');
|
||||
$security_schema = $extensions['sections']['security'];
|
||||
$group_keys = array_column($security_schema['groups'], 'key');
|
||||
foreach (['brush_post', 'brush_comment', 'brush_upload', 'brush_register', 'brush_order', 'brush_captcha', 'harden'] as $expected_group) {
|
||||
site_security_check(in_array($expected_group, $group_keys, true), 'missing security group: ' . $expected_group);
|
||||
}
|
||||
$fields = [];
|
||||
foreach ($security_schema['groups'] as $group) {
|
||||
foreach ($group['fields'] as $field) {
|
||||
$fields[$field['key']] = $field;
|
||||
}
|
||||
}
|
||||
$expected_fields = [
|
||||
'security_brush_post_10min' => 20, 'security_brush_post_day' => 50,
|
||||
'security_brush_comment_10min' => 20, 'security_brush_comment_day' => 100,
|
||||
'security_brush_upload_10min' => 50, 'security_brush_upload_day' => 200,
|
||||
'security_brush_register_10min' => 20, 'security_brush_register_day' => 100,
|
||||
'security_brush_order_10min' => 30, 'security_brush_order_day' => 100,
|
||||
'security_captcha_send_cooldown' => 60, 'security_captcha_to_hour' => 15, 'security_captcha_to_day' => 30,
|
||||
'security_captcha_ip_10min' => 20, 'security_captcha_ip_day' => 50,
|
||||
];
|
||||
foreach ($expected_fields as $key => $default) {
|
||||
site_security_check(isset($fields[$key]), 'missing security field: ' . $key);
|
||||
site_security_check((int) $fields[$key]['default'] === $default, 'wrong default for ' . $key);
|
||||
site_security_check((int) $fields[$key]['min'] === 0, 'missing zero=unlimited min for ' . $key);
|
||||
}
|
||||
foreach (['security_error_frequency_limit', 'security_disabled_pingback', 'security_disabled_xmlrpc', 'security_user_disable_admin', 'close_attachment_page'] as $switch_key) {
|
||||
site_security_check(isset($fields[$switch_key]) && $fields[$switch_key]['type'] === 'switch', 'missing hardening switch: ' . $switch_key);
|
||||
site_security_check(!empty($fields[$switch_key]['default']), 'hardening switch should default to on: ' . $switch_key);
|
||||
}
|
||||
foreach (array_keys($fields) as $field_key) {
|
||||
site_security_check(!str_starts_with($field_key, 'verification_'), '人机验证 field leaked into security schema: ' . $field_key);
|
||||
site_security_check(!str_contains($field_key, 'geetest') && !str_contains($field_key, 'tcaptcha'), 'provider captcha field leaked into security schema: ' . $field_key);
|
||||
}
|
||||
site_security_check(isset($fields['close_attachment_page']), 'close_attachment_page was not moved into the security schema');
|
||||
|
||||
echo "Site security harness passed\n";
|
||||
}
|
||||
Reference in New Issue
Block a user