首页
/ Rocket.Chat 密码重置邮件接口加固:未授权 DDP 方法 sendForgotPasswordEmail 的按客户端速率限制

Rocket.Chat 密码重置邮件接口加固:未授权 DDP 方法 sendForgotPasswordEmail 的按客户端速率限制

2026-09-05 15:48:39作者:傅爽业Veleda

本文围绕 Rocket.Chat 仓库中一条变更集(changeset)记录展开:为未认证的 DDP 方法 sendForgotPasswordEmail 增加“按客户端”的速率限制,使其保护基线与 REST 端点 users.forgotPassword 对齐。读完后你将掌握 Rocket.Chat 中 DDPRateLimiter 限流规则的注册方式与参数含义、该密码重置方法的完整业务逻辑(含防账号枚举设计),以及端到端测试如何验证这一行为。

一、变更集说明了什么

本条目来自 .changeset/fuzzy-ends-refuse.md,其完整内容如下:

---
'@rocket.chat/meteor': patch
---

Adds per-client rate limiting to the unauthenticated sendForgotPasswordEmail method, matching the REST users.forgotPassword endpoint

.changeset/README.md 的说明,.changeset 目录由 @changesets/cli 管理,用于在多包仓库中跟踪版本与发布说明。这份变更集有三个关键信息:

  1. 影响包@rocket.chat/meteor(即 apps/meteor 下的主应用),变更级别为 patch,属于对现有行为的修复性/加固性改动,不改变接口契约;
  2. 改动对象:DDP 方法 sendForgotPasswordEmail,且明确指出它是 unauthenticated(未认证)方法——即无需登录即可调用;
  3. 改动目标:新增 per-client rate limiting(按客户端维度的速率限制),使其与已有的 REST 端点 users.forgotPassword 的保护水平对齐。

二、背景:一个未认证的 DDP 方法意味着什么

方法实现位于 sendForgotPasswordEmail.ts,并通过 index.ts 中的 import './auth/sendForgotPasswordEmail'; 在服务器启动时注册。其方法注册部分如下:

Meteor.methods<ServerMethods>({
	async sendForgotPasswordEmail(to) {
		check(to, String);

		return sendForgotPasswordEmail(to);
	},
});

可以注意到两点:

  • 方法体内没有任何 Meteor.userId() 之类的登录态校验,参数仅用 check(to, String) 做字符串类型校验,因此匿名用户也能发起调用;
  • 文件顶部还通过 declare module '@rocket.chat/ddp-client' 的模块增强,把 sendForgotPasswordEmail(to: string): boolean | undefined 声明进了 ServerMethods 接口,保证客户端调用侧有类型约束。

从端到端测试 saml.spec.ts 可以看到该方法的真实调用路径:测试通过 HTTP POST 请求 /method.call/sendForgotPasswordEmail,请求体为标准 DDP 消息:

{ "msg": "method", "id": "id", "method": "sendForgotPasswordEmail", "params": ["samluser1@example.com"] }

也就是说,未登录的客户端只需一个可发送 DDP 消息的通道就能反复触发“发送密码重置邮件”。这类接口一旦被恶意方高频调用,后果包括:大量无意义的 SMTP 外发、对用户邮箱的骚扰,以及(由于未命中用户时的返回值存在差异面)潜在的探测面。因此,给未认证方法加限流是必要的安全加固。

三、限流规则实现:DDPRateLimiter 的注册参数

变更集对应的核心改动就是该文件末尾新增的限流规则(L49-L59):

DDPRateLimiter.addRule(
	{
		type: 'method',
		name: 'sendForgotPasswordEmail',
		clientAddress() {
			return true;
		},
	},
	10,
	60000,
);

参数逐项拆解:

参数 取值 含义
type 'method' 规则作用于 DDP method 消息类型
name 'sendForgotPasswordEmail' 精确匹配方法名,规则只拦截该方法的调用
clientAddress 匹配器 返回 true 引入客户端地址这一匹配维度,把限流键落到客户端而不是全局共享桶;变更集将其明确描述为 per-client 限制
第三个参数(请求数) 10 一个客户端在时间窗口内最多允许的调用次数
第四个参数(时间窗口) 60000 窗口长度,单位毫秒,即 60 秒

即:同一客户端每 60 秒内最多调用 10 次,超出后 DDPRateLimiter 会向该客户端返回限流错误(rate-limit-exceeded),从而挡住自动化批量触发。

值得注意的是,clientAddress() { return true; } 这种匹配器写法在 Rocket.Chat 服务端并非孤例,同一模式下还覆盖着 getRoomByIdbrowseChannelssendSMTPTestEmailresetAvatarsetAvatarFromServiceuserSetUtcOffsetgetSupportedLanguages 等多个方法,可以推断这是该项目对“按来源限流”的统一写法。

四、被限流的方法内部逻辑:为什么要认真防护

限流保护的是这段异步逻辑(L18-L39):

export const sendForgotPasswordEmail = async (to: string): Promise<boolean | undefined> => {
	const email = to.trim().toLowerCase();

	const user = await Users.findOneByEmailAddress(email, { projection: { _id: 1, services: 1 } });

	if (!user) {
		return true;
	}

	if (user.services && !user.services.password) {
		if (!settings.get('Accounts_AllowPasswordChangeForOAuthUsers')) {
			return false;
		}
	}

	try {
		Accounts.sendResetPasswordEmail(user._id, email);
		return true;
	} catch (err) {
		SystemLogger.error({ err });
	}
};

其防御性设计逐层展开:

  1. 输入归一化trim().toLowerCase() 后再查库,避免同一邮箱因大小写/空白差异被多次触发;查询使用 projection: { _id: 1, services: 1 } 只取最小字段,降低不必要的数据读取。
  2. 防账号枚举:邮箱查无此人时直接 return true,与“发送成功”返回同样的值,使调用方无法从返回值区分邮箱是否注册。
  3. OAuth 无密码用户门禁:若用户只有 OAuth 凭据(无 services.password)且管理员关闭了 Accounts_AllowPasswordChangeForOAuthUsers 设置,则返回 false 而不发邮件——防止给纯 OAuth 账号发毫无作用的“重置密码”邮件。
  4. 异常兜底Accounts.sendResetPasswordEmail(Meteor Accounts 包的标准重置邮件 API)抛出错误时不向调用方传播,而是交给 SystemLogger 记录,方法整体表现为静默安全。

这套逻辑本身已具备“对外表现统一”的防枚举意识,而本次新增的限流规则补上了最后一块:即便调用方无法从返回值获得信息,也不能通过高频重试制造 SMTP 压力或骚扰。

五、与 REST 端点 users.forgotPassword 的对齐

变更集强调 “matching the REST users.forgotPassword endpoint”。在 users.ts 中,REST 侧的端点定义如下(节选):

'users.forgotPassword',
{
	authRequired: false,
	body: ajv.compile<{ email: string }>({
		type: 'object',
		properties: { email: { type: 'string' } },
		required: ['email'],
		additionalProperties: false,
	}),
	response: {
		200: ajv.compile<void>({ /* success: true */ }),
		400: validateBadRequestErrorResponse,
	},
},
async function action() {
	const isPasswordResetEnabled = settings.get('Accounts_PasswordReset');

	if (!isPasswordResetEnabled) {
		return API.v1.failure('Password reset is not enabled');
	}

	await sendForgotPasswordEmail(this.bodyParams.email.toLowerCase());
	return API.v1.success();
},

两条通道的对照关系很清晰:

  • 鉴权:REST 端点显式声明 authRequired: false,DDP 方法侧无登录校验,两者都是匿名可达;
  • 校验:REST 侧用 AJV 严格 schema(仅允许 email 字段且必填)做请求体校验,DDP 侧用 check(to, String) 做参数校验;
  • 复用:REST 的 action 直接 await sendForgotPasswordEmail(...),即调用与 DDP 方法同一个导出函数,业务逻辑单一来源;
  • 全局开关:REST 侧受 Accounts_PasswordReset 设置约束。该设置定义于 accounts.tstype: 'boolean'public: true、默认值为 true,关闭后密码重置功能整体停用。

变更集的含义是:REST 通道此前已处于 API 限流体系之下,而未认证的 DDP 通道长期没有对应保护;本次 patch 把 DDP 通道补到同等水平(10 次/分钟/客户端)。

六、项目内限流工具链的横向参考

sendForgotPasswordEmail 直接使用了 Meteor 原生的 DDPRateLimiterimport { DDPRateLimiter } from 'meteor/ddp-rate-limiter'),而项目内还存在一个轻量封装 RateLimiter.js

import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';

export const RateLimiterClass = new (class {
	limitMethod(methodName, numRequests, timeInterval, matchers) {
		if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') {
			return;
		}
		const match = { type: 'method', name: methodName };
		Object.entries(matchers).forEach(([key, matcher]) => {
			match[key] = (...args) => matcher(...args);
		});
		return DDPRateLimiter.addRule(match, numRequests, timeInterval);
	}
})();

该封装的增量价值在于:TEST_MODEtrueapi 时跳过限流规则注册,保证测试环境不被限流干扰。项目内多个敏感方法经由它注册,例如:

  • sendMessage:5 次 / 1000 ms(sendMessage.ts);
  • createDirectMessage:10 次 / 60000 ms(createDirectMessage.ts);
  • setRealNamesetEmailsetUserStatus:1 次 / 1000 ms;
  • registerUser:规则数与时间窗口跟随设置值动态重建,会先 DDPRateLimiter.removeRule 再重新 addRuleregisterUser.ts)。

对照来看,sendForgotPasswordEmail 的 10 次/60000 ms 属于相对宽松的“低频操作”档位(与 createDirectMessage 同档),符合密码重置这类偶发操作的正常使用模式。

七、端到端测试验证

saml.spec.ts 用两组断言覆盖了该方法的门禁分支:

  1. Accounts_AllowPasswordChangeForOAuthUsers 设为 false 后,对 SAML(纯 OAuth)用户调用 sendForgotPasswordEmail,期望方法返回 false——即不发邮件;
  2. 将同一设置改为 true 后再次调用,期望返回 true——即允许并发送重置邮件。

这两组用例验证的是业务分支;而本次变更集新增的限流规则本身,由于属于传输层保护,在测试环境中通常由 RateLimiterClassTEST_MODE 跳过机制(或直接调用原生 DDPRateLimiter 时的行为)来规避干扰,避免测试用例被限流误伤。

八、小结

项目 内容
变更级别 patch,影响包 @rocket.chat/meteor
保护对象 未认证 DDP 方法 sendForgotPasswordEmail
限流实现 DDPRateLimiter.addRuletype: 'method'name: 'sendForgotPasswordEmail'clientAddress 匹配器
限流参数 每客户端 10 次 / 60000 ms(60 秒)
对齐目标 REST users.forgotPasswordauthRequired: false,AJV 校验,受 Accounts_PasswordReset 开关控制)
关键文件 方法实现与限流规则REST 端点限流封装e2e 测试

这次改动的示范意义在于:在 Rocket.Chat 这类同时暴露 REST API 与 DDP 方法的双通道架构中,同一个业务能力若走两条通道,必须逐一核对其鉴权与限流配置,避免“REST 已设防、DDP 裸奔”的不对称风险。对自建应用时,同样的检查清单是:找出所有匿名可达的方法与端点,为每个端点显式注册限流规则,并让测试环境通过 TEST_MODE 之类的开关显式豁免限流。

登录后查看全文
热门项目推荐
相关项目推荐