Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1eb1e805 | ||
|
|
6ee1eca789 |
No files matched your search
+1
-1
@@ -248,7 +248,7 @@ Gateway 0.5.4 started
|
|||||||
|
|
||||||
近期变更:
|
近期变更:
|
||||||
|
|
||||||
- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时)。
|
- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。
|
||||||
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
|
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
|
||||||
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
|
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
|
||||||
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
|
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
2. **gateway 层邮箱级双窗口限流**:3 次/小时 + 10 次/天(CAPTCHA 通过后才计入配额)。
|
2. **gateway 层邮箱级双窗口限流**:3 次/小时 + 10 次/天(CAPTCHA 通过后才计入配额)。
|
||||||
3. **smtp 层邮箱级长期限流兜底**:同样 3 次/小时 + 10 次/天,作为 gateway 被绕过/重启时的最后防线。
|
3. **smtp 层邮箱级长期限流兜底**:同样 3 次/小时 + 10 次/天,作为 gateway 被绕过/重启时的最后防线。
|
||||||
4. **smtp 层全局熔断**:`SMTP_HOURLY_LIMIT` 环境变量(默认 100 封/小时),防 smtp 账号被服务商封禁。
|
4. **smtp 层全局熔断**:`SMTP_HOURLY_LIMIT` 环境变量(默认 100 封/小时),防 smtp 账号被服务商封禁。
|
||||||
- 改动文件:[gateway/src/main.cpp](../src/main.cpp)(新增 `_EMAIL_ADDR_RATE_HOUR`/`_EMAIL_ADDR_RATE_DAY` + `handleSendEmailCode` 强制 CAPTCHA + 邮箱限流)、[smtp/smtp_v2.py](../../smtp/smtp_v2.py)(新增 `send_log`/`global_send_log` + 限流检查 + 全局熔断 + 预占/回滚)。
|
5. **前端同步**:`register.html`/`register.js` 接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加——邮箱验证码已是二次验证)。
|
||||||
|
- 改动文件:[gateway/src/main.cpp](../src/main.cpp)(新增 `_EMAIL_ADDR_RATE_HOUR`/`_EMAIL_ADDR_RATE_DAY` + `handleSendEmailCode` 强制 CAPTCHA + 邮箱限流)、[smtp/smtp_v2.py](../../smtp/smtp_v2.py)(新增 `send_log`/`global_send_log` + 限流检查 + 全局熔断 + 预占/回滚)、[static/public/register.html](../../static/public/register.html) + [static/public/js/register.js](../../static/public/js/register.js)(前端接入图形验证码)。
|
||||||
|
|
||||||
### P0-2 注册接口 CAPTCHA 可选,可被批量注册
|
### P0-2 注册接口 CAPTCHA 可选,可被批量注册
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,45 @@ const repeatPasswordInput = document.getElementById('repwd');
|
|||||||
const emlTip = document.getElementById('emlTip');
|
const emlTip = document.getElementById('emlTip');
|
||||||
const pwdTip = document.getElementById('pwdTip');
|
const pwdTip = document.getElementById('pwdTip');
|
||||||
const codeTip = document.getElementById('codeTip');
|
const codeTip = document.getElementById('codeTip');
|
||||||
|
const captchaImg = document.getElementById('captchaImg');
|
||||||
|
const captchaInput = document.getElementById('captchaAnswer');
|
||||||
|
const captchaTip = document.getElementById('captchaTip');
|
||||||
const submitButton = document.getElementById('formsub');
|
const submitButton = document.getElementById('formsub');
|
||||||
|
|
||||||
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||||
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
|
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
|
||||||
|
|
||||||
|
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
|
||||||
|
let captchaToken = '';
|
||||||
|
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||||
|
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
if (captchaLoading) return;
|
||||||
|
captchaLoading = true;
|
||||||
|
captchaToken = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||||
|
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
|
||||||
|
captchaImg.removeAttribute('src');
|
||||||
|
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error('captcha load failed');
|
||||||
|
captchaToken = res.headers.get('X-Captcha-Token') || '';
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
|
||||||
|
captchaImg.src = URL.createObjectURL(blob);
|
||||||
|
captchaImg.alt = '点击刷新';
|
||||||
|
} catch (e) {
|
||||||
|
captchaImg.removeAttribute('src');
|
||||||
|
captchaImg.alt = '加载失败,点击重试';
|
||||||
|
} finally {
|
||||||
|
captchaLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captchaImg.addEventListener('click', refreshCaptcha);
|
||||||
|
refreshCaptcha();
|
||||||
|
|
||||||
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
|
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
|
||||||
function startResendCooldown(seconds) {
|
function startResendCooldown(seconds) {
|
||||||
clearInterval(cooldownTimer);
|
clearInterval(cooldownTimer);
|
||||||
@@ -34,16 +68,20 @@ function startResendCooldown(seconds) {
|
|||||||
form.addEventListener('reset', function () {
|
form.addEventListener('reset', function () {
|
||||||
clearTipStyle();
|
clearTipStyle();
|
||||||
codeTarget = '';
|
codeTarget = '';
|
||||||
|
captchaInput.value = '';
|
||||||
|
refreshCaptcha();
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearTipStyle() {
|
function clearTipStyle() {
|
||||||
emlTip.innerHTML = '';
|
emlTip.innerHTML = '';
|
||||||
pwdTip.innerHTML = '';
|
pwdTip.innerHTML = '';
|
||||||
codeTip.innerHTML = '';
|
codeTip.innerHTML = '';
|
||||||
|
captchaTip.innerHTML = '';
|
||||||
|
|
||||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
|
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
}
|
}
|
||||||
clearTipStyle();
|
clearTipStyle();
|
||||||
|
|
||||||
@@ -101,6 +139,23 @@ function checkCode() {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkCaptcha() {
|
||||||
|
captchaTip.innerHTML = '';
|
||||||
|
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||||
|
const v = captchaInput.value.trim();
|
||||||
|
if (!v) {
|
||||||
|
captchaTip.textContent = '请输入图形验证码!';
|
||||||
|
captchaTip.classList.add('tip-red');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!captchaToken) {
|
||||||
|
captchaTip.textContent = '图形验证码未加载,请点击图片刷新';
|
||||||
|
captchaTip.classList.add('tip-red');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
passwordInput.addEventListener('focus', checkPassword);
|
passwordInput.addEventListener('focus', checkPassword);
|
||||||
repeatPasswordInput.addEventListener('focus', checkPassword);
|
repeatPasswordInput.addEventListener('focus', checkPassword);
|
||||||
|
|
||||||
@@ -126,9 +181,9 @@ codeInput.addEventListener('blur', checkCode);
|
|||||||
getCodeButton.addEventListener('click', async function () {
|
getCodeButton.addEventListener('click', async function () {
|
||||||
if (getCodeButton.disabled) return; // 防重复点击
|
if (getCodeButton.disabled) return; // 防重复点击
|
||||||
const email = checkEmail();
|
const email = checkEmail();
|
||||||
if (!email) {
|
if (!email) return;
|
||||||
return;
|
const captchaAnswer = checkCaptcha();
|
||||||
}
|
if (!captchaAnswer) return;
|
||||||
|
|
||||||
getCodeButton.disabled = true;
|
getCodeButton.disabled = true;
|
||||||
codeTip.textContent = '验证码发送中…';
|
codeTip.textContent = '验证码发送中…';
|
||||||
@@ -139,7 +194,11 @@ getCodeButton.addEventListener('click', async function () {
|
|||||||
const res = await fetch('/register/send_code', {
|
const res = await fetch('/register/send_code', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email })
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
captcha_token: captchaToken,
|
||||||
|
captcha_answer: captchaAnswer
|
||||||
|
})
|
||||||
});
|
});
|
||||||
httpStatus = res.status;
|
httpStatus = res.status;
|
||||||
const responseText = await res.text();
|
const responseText = await res.text();
|
||||||
@@ -160,6 +219,9 @@ getCodeButton.addEventListener('click', async function () {
|
|||||||
codeTip.classList.add('tip-red');
|
codeTip.classList.add('tip-red');
|
||||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||||
} finally {
|
} finally {
|
||||||
|
// CAPTCHA Token 一次性消费,无论成败都刷新
|
||||||
|
captchaInput.value = '';
|
||||||
|
refreshCaptcha();
|
||||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<label>邮箱号:</label>
|
<label>邮箱号:</label>
|
||||||
<input type="email" id="regemail" placeholder="请输入邮箱号">
|
<input type="email" id="regemail" placeholder="请输入邮箱号">
|
||||||
<div class="code-box">验证码:
|
<span id="emlTip"></span>
|
||||||
|
<label>图形验证码:</label>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<input type="text" id="captchaAnswer" placeholder="请输入图形验证码" maxlength="4" autocomplete="off" style="flex:1;min-width:0;width:auto">
|
||||||
|
<img id="captchaImg" alt="加载中…" title="点击刷新验证码" style="width:120px;height:38px;flex:none;cursor:pointer;border:1.5px solid var(--border);border-radius:10px;box-sizing:border-box">
|
||||||
|
</div>
|
||||||
|
<span id="captchaTip"></span>
|
||||||
|
<div class="code-box">邮箱验证码:
|
||||||
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
||||||
<input type="button" id="getCode" value="获取验证码">
|
<input type="button" id="getCode" value="获取验证码">
|
||||||
<span id="codeTip"></span>
|
<span id="codeTip"></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="emlTip"></span>
|
|
||||||
<label>密码:</label>
|
<label>密码:</label>
|
||||||
<input type="password" id="regpsw" placeholder="密码至少6位">
|
<input type="password" id="regpsw" placeholder="密码至少6位">
|
||||||
<label>验证密码:</label>
|
<label>验证密码:</label>
|
||||||
@@ -36,6 +42,6 @@
|
|||||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="js/validation.js"></script>
|
<script src="js/validation.js"></script>
|
||||||
<script src="js/register.js?v=6"></script>
|
<script src="js/register.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user