API 输入校验、来源限制与请求限流
MyIP 在 Express 后端使用全局请求限制、Referer 来源门禁和路由级参数守卫,在业务处理前拒绝不合要求的请求。本页说明这些保护措施的真实执行顺序、配置方式、错误响应以及部署边界。
目的与范围
本文覆盖 /api 中间件装配、来源主机名检查、查询参数与报告 ID 校验、硬限流与延迟限速,以及限流事件日志。重点是请求进入业务 handler 之前的保护链路,不展开 DNS、WHOIS、ASN、共享报告或遥测转发的业务实现,也不将来源检查描述为用户身份认证。
部署、缓存、可观测性与各业务 API 的详细行为应由相应专题说明;这里仅解释它们与保护链路的交点。本文依据有限范围的源码阅读:第三方限流库内部实现、部分辅助解析器和测试文件未展开,相关限制会单独标注。
概述
保护机制分为三类,其目的与结果不同:
| 层次 | 实现 | 主要作用 | 不通过时的结果 |
|---|---|---|---|
| 请求量控制 | rateLimiter、speedLimiter | 限制请求数量,或让超出阈值的请求等待 | 429,或增加延迟 |
| 来源控制 | requireReferer → refererCheck | 要求请求携带允许主机名的 Referer | 403 |
| 参数控制 | requirePublicIP 等守卫 | 拒绝缺失、格式错误、不支持的输入,统一部分参数形式 | 400 |
这些措施是组合使用的:通过来源门禁不代表参数有效,通过参数校验也不代表业务一定成功。需要本地数据的路由还可能被 requireOfflineData 阻挡;上游请求失败、业务授权与报告内容校验不属于这里的通用守卫职责。
来源:backend-server.js、guards.js。
架构与执行顺序
Source: backend-server.js、guards.js。
图中未启用的可选中间件不会实际挂载;只有检查通过才进入下一阶段。执行顺序有几个直接后果:
- 来源错误和参数错误的请求也先经过请求量控制。 通用限制没有等到业务成功后才执行。
- HTTP 日志位于限流之前。 开启后可以记录被硬限流拦截的请求。
- JSON 解析早于 Referer 校验。 对需要 JSON 解析的请求,解析失败可能先于来源错误发生。
- 默认
no-store头位于限流和 JSON 解析之后。 不能据此保证更早结束的 429 或解析错误已经经过该设置。 - 已装配守卫的可缓存路由先校验、再调用
cacheable。 例如 IP 查询和 ASN 查询;不能将此顺序泛化为所有 handler 内部的校验行为。
来源:backend-server.js、backend-server.js。
来源限制:检查 Referer 主机名
判断算法
refererCheck(referer) 每次调用时构建允许列表:固定的 localhost,加上环境变量 ALLOWED_DOMAINS 按逗号拆分的值。请求提供 Referer 时,通过 new URL(referer).hostname 提取主机名,再执行 includes 精确匹配。URL 解析失败或 Referer 缺失均返回 false。
以下为完整的来源判断函数:
1function refererCheck(referer) {
2 const allowedDomains = ['localhost', ...(process.env.ALLOWED_DOMAINS || '').split(',')];
3
4 if (referer) {
5 // Scanners send garbage Referer headers; a parse failure means
6 // "not an allowed origin", never a thrown 500.
7 let domain;
8 try {
9 domain = new URL(referer).hostname;
10 } catch {
11 return false;
12 }
13 return allowedDomains.includes(domain);
14 }
15 return false; // if no referer is provided, return false
16}Source: referer-check.js。
配置与安全语义
ALLOWED_DOMAINS应按主机名配置,不应将协议、端口或路径写入匹配项,因为比较对象只有hostname。- 列表未调用
trim()或统一大小写;逗号两侧的空格会保留,不能依赖自动清洗。 - 没有通配符和子域后缀匹配。配置父域不意味着自动允许子域。
- 代码不比较来源端口,也没有显式限制 Referer 的协议;这不是严格的 scheme/host/port 同源判断。
localhost始终在列表中,但127.0.0.1不因此自动加入。- 它读取的是
Referer,不是Origin,也没有在该函数中设置 CORS 响应头。 - 该机制不是身份认证。 检查对象是客户端提交的请求头;不能将一个允许的 Referer 当作可信用户或可信程序的证明。
403 响应
requireReferer(req, res, next) 从 req.headers.referer 取值:缺失或空值返回 403,错误信息为 What are you doing?;非空但未通过检查返回 403,错误信息为 Access denied。响应均使用 JSON 的 error 字段,只有成功时才调用 next()。
门禁通过 app.use('/api', requireReferer) 全局挂载,因此后续注册的 /api/monitoring 也要经过它;豁免通用限流不等于豁免来源检查。
来源:guards.js、backend-server.js、backend-server.js。
输入校验与规范化
守卫接口约定
除 requireReferer 外,这组守卫大多是工厂函数:接收参数名,返回 (req, res, next) 中间件。失败分支直接发送 400 JSON,成功分支调用 next();不是向调用者返回业务数据。源码是 JavaScript,没有静态参数类型声明。
| 导出接口 | 默认读取位置 | 校验与修改行为 |
|---|---|---|
requirePublicIP(paramName = 'ip') | req.query.ip | 先检查存在,再验证 IP 格式,再排除非公网地址;不改写值 |
requireValidDomain(paramName = 'domain') | req.query.domain | 转字符串并转小写,验证域名格式,写回查询参数 |
requireValidPrefix(paramName = 'prefix') | req.query.prefix | 调用 isValidBgpPrefix;不执行前端前缀量化策略 |
requireValidASN(paramName = 'asn') | req.query.asn | 调用 parseAsnInput,拒绝 null,写回规范化数字字符串 |
normalizeAsnQuery(paramName = 'q') | req.query.q | 仅处理 trim 后匹配 AS 加数字的字符串;写回 AS<n> |
requireValidCountry(paramName = 'country') | req.query.country | 两个英文字母,写回大写;不验证国家代码是否已分配 |
requireValidReportId(paramName = 'id') | req.params.id | 精确匹配 22 位字母、数字、下划线或连字符 |
requireValidRecordType(paramName = 'type') | req.query.type | 转大写后检查 DNS_RECORD_TYPE_SET,成功写回大写 |
requireValidProviderId(paramName = 'id') | req.query.id | 检查 STATUS_PROVIDER_IDS,不统一大小写 |
来源:guards.js。
ASN 的注释约定是可选 AS 前缀、最多十位数字、数值范围 1 至 4294967295;实际解析委托给 parseAsnInput。本文未读取该辅助函数,也未读取 BGP 前缀解析器与两项白名单定义,因此不额外声明其所有输入细节或枚举完整成员。
IP:语法有效不等于可查询的公网地址
1export const requirePublicIP = (paramName = 'ip') => (req, res, next) => {
2 const ip = req.query[paramName];
3 if (!ip) {
4 return res.status(400).json({ error: 'No IP address provided' });
5 }
6 if (!isValidIP(ip)) {
7 return res.status(400).json({ error: 'Invalid IP address' });
8 }
9 if (!isUsablePublicIP(ip)) {
10 return res.status(400).json({ error: 'Not a public IP address' });
11 }
12 next();
13};Source: guards.js。
分开返回错误的价值是让调用者区分“需要修正 IP 写法”和“地址本身不属于可查询公网空间”。isValidIP 首先要求字符串类型:IPv4 使用十进制四段正则,拒绝多位段的前导零;IPv6 检查十六进制组、最多一次 ::,无压缩时要求八组,有压缩时少于八组。带点分十进制尾部的 IPv6 混合写法、zone ID 等不符合这里的十六进制组规则。
isUsablePublicIP 会再次调用 isValidIP,再执行地址空间排除:
- IPv4 排除
0.0.0.0/8、RFC 1918 私网、CGNAT、环回、链路本地、文档网段、基准测试网段、组播及保留尾部等源码列出的范围。比较采用整数区间,而非会产生有符号溢出的 32 位移位。 - IPv6 先要求位于
2000::/3,再排除源码列出的 Teredo、ORCHIDv2、文档地址与 6to4 范围。
这里判断的是项目定义的可用地址空间,不是实时路由可达性探测。来源:valid-ip.js。
域名与 ASN:在进入后续处理前统一形式
1export const requireValidDomain = (paramName = 'domain') => (req, res, next) => {
2 const raw = req.query[paramName];
3 if (!raw) {
4 return res.status(400).json({ error: 'No domain provided' });
5 }
6 const domain = String(raw).toLowerCase();
7 if (!isValidDomain(domain)) {
8 return res.status(400).json({ error: 'Invalid domain' });
9 }
10 req.query[paramName] = domain;
11 next();
12};Source: guards.js。
域名正则要求至少一个点,最后一段为至少两个英文字母,其他标签允许字母、数字、连字符及可选的首位下划线。因此它支持服务记录式名称,但只是表层语法检查,不验证 DNS 是否存在、公共后缀、标签长度或所有 DNS 命名约束。此守卫本身没有执行 URL 解析,不应因为辅助函数的注释提到调用方解析 URL,就假设这里也存在该步骤。
规范化的代码意图是让 handler 和缓存相关处理看到同一种表示;域名转小写、ASN 转数字字符串、记录类型与国家代码转大写。它不等于在本文范围内证明外部 CDN 的缓存键配置。
WHOIS 的 normalizeAsnQuery 特别不同:它不是通用 q 校验器。非字符串、裸数字以及不匹配 AS 加数字模式的输入直接放行,留给 WHOIS handler 判断;只有 ASN 形态的输入才进入解析、拒绝和规范化分支。
来源:valid-ip.js、guards.js。
真实路由装配示例
DNS 查询按“域名 → 记录类型 → handler”顺序执行:
app.get('/api/dnsresolver', requireValidDomain('hostname'), requireValidRecordType(), dnsResolver);Source: backend-server.js。
ASN 连接查询先验证 ASN,再检查本地数据就绪,最后进入缓存包装与 handler:
app.get('/api/asn-connectivity', requireValidASN(), needsAsGraph, cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler);Source: backend-server.js。
这些是仓库中的装配片段,而非可以单独运行的程序。其他已验证的绑定包括:
| 路由 | 入口守卫 |
|---|---|
/api/ipinfo、/api/ipapicom、/api/ipsb、/api/ipapiis、/api/ip2location、/api/maxmind、/api/ipchecking | requirePublicIP() |
/api/ooni-blocking | requireValidDomain() |
/api/asn-profile | requireValidASN() |
/api/asn-history | requireValidPrefix() |
/api/whois | normalizeAsnQuery(),不是完整查询校验 |
/api/service-status/detail | requireValidProviderId() |
GET /api/report/:id | requireValidReportId() |
POST /api/report、POST /api/persona/evaluate 等在装配处没有使用上述查询守卫,不代表 handler 内没有业务校验。requireValidCountry 已导出,但所读服务器装配段没有直接绑定它;/api/cfradar 的按视图校验需查看其 dispatcher,本文不推断该内部流程。
请求限流与延迟限速
硬限流:20 分钟窗口
rateLimiter 使用 express-rate-limit,窗口为 20 * 60 * 1000 毫秒,最大请求量取 SECURITY_RATE_LIMIT。只有解析后的值不等于 0 才挂载到 /api,因此默认配置不是“禁止所有请求”,而是不启用通用硬限流。
超过阈值后,自定义 handler 返回 429 JSON,字段为 message,值为 Too Many Requests。这与守卫错误使用 error 字段不同,客户端不应假设所有错误响应同构。
限流事件仅在 req.rateLimit.current === req.rateLimit.limit + 1 时记录:该窗口首次进入被限流状态时输出 logger.warn,若配置了文件路径则同时更新本地台账。后续持续被拒绝的请求仍返回 429,但不重复写这条事件日志,从而避免刷日志。
skip 对挂载后的相对路径 /monitoring 返回真;通用硬限流并未豁免 /maxmind。来源:backend-server.js。
延迟限速:一小时窗口、最大等待五秒
1const speedLimiter = slowDown({
2 windowMs: 60 * 60 * 1000,
3 delayAfter: speedLimitSet,
4 delayMs: (used, req) => (used - req.slowDown.limit) * 400,
5 maxDelayMs: 5000,
6 skip: (req) => req.path === '/monitoring' || req.path === '/maxmind',
7})Source: backend-server.js。
SECURITY_DELAY_AFTER 为非零时才挂载。配置表达的策略是在一小时计数超过阈值后,按每个超额请求 400 毫秒递增等待,最多 5000 毫秒。这是延迟而不是业务层拒绝;如果之前的硬限流已经终止请求,就不会进入该阶段。
| 请求路径 | 通用硬限流 | 通用延迟限速 | Referer 门禁 | 专用限制 |
|---|---|---|---|---|
一般 /api/* | 启用时适用 | 启用时适用 | 适用 | 取决于路由 |
/api/maxmind | 启用时适用 | 豁免 | 适用 | 此装配段未配置额外限流 |
/api/monitoring | 豁免 | 豁免 | 适用 | 已注册路由时,20 分钟 600 次 |
遥测通道的独立额度
仅在 VITE_SENTRY_DSN_FRONTEND 非空时,服务器注册 POST /api/monitoring:先经过专用 monitoringLimiter,再用 express.raw 读取最多 10mb 的请求体,然后调用 sentryTunnelHandler。
独立额度避免业务请求耗尽通用配额后,遥测事件也无法提交。原始解析器的 type: () => true 用于接收没有 Content-Type 的二进制 Replay envelope。
需要注意:全局 express.json({ limit: '500kb' }) 注册在前。10mb 不是所有 Content-Type 都无条件适用的前置总上限;会被 JSON 解析器匹配的请求仍先经过全局 JSON 解析。专用 limiter 没有使用通用 limiter 的自定义 handler,因此不能将通用限流的 JSON 格式与台账记录规则直接套用到遥测路由。
来源:backend-server.js、backend-server.js。
核心请求流程
下面以 /api/dnsresolver 为例,展示已启用通用限流时的主要分支;HTTP 日志、解析器与响应头设置的完整位置见架构图。
Source: backend-server.js、backend-server.js、guards.js、guards.js、guards.js。
此顺序意味着同时存在多个问题时,只能先看到前序阶段的错误。排查请求时,应先定位返回来自限流、来源、参数还是业务层,而不是仅依据 URL 判断失败原因。
配置参考
| 配置 | 类型/解析方式 | 默认值 | 行为 |
|---|---|---|---|
ALLOWED_DOMAINS | 字符串,按 , 拆分 | 空字符串 | 额外允许的 Referer 主机名;固定另含 localhost |
SECURITY_RATE_LIMIT | parseInt(value, 10) | 0 | 非零时启用 20 分钟窗口通用硬限流 |
SECURITY_DELAY_AFTER | parseInt(value, 10) | 0 | 非零时启用一小时窗口延迟限速 |
SECURITY_BLACKLIST_LOG_FILE_PATH | 字符串 | 空字符串 | 非空时写本地限流事件台账,路径经 path.join(__dirname, value) 构造 |
LOG_HTTP | 字符串精确比较 | 未设置时关闭 | 仅等于 'true' 时启用 /api HTTP 日志 |
VITE_SENTRY_DSN_FRONTEND | 环境变量真值判断 | 未设置时不注册 | 控制遥测隧道路由及其专用 limiter 是否存在 |
Express trust proxy | 固定数字 | 1 | 影响 Express 对代理链及 req.ip 的解释;不是这里暴露的环境变量 |
| JSON 请求体上限 | 固定字符串 | '500kb' | 全局 JSON 解析器限制;业务 handler 可另加约束 |
通用限流窗口、延迟步长 400ms、最大延迟 5000ms、遥测额度 600 次及原始请求体上限 10mb 都是源码常量,不是已提供的环境配置项。
两个通用阈值没有在装配处检查 NaN、负数或不完整数字字符串。不能把非法配置当作关闭开关;确切的第三方库校验结果未在本次阅读中验证。环境值在初始化时解析,修改阈值后需要重新初始化进程;Referer 允许列表则是在函数调用时读取 process.env。
来源:backend-server.js、backend-server.js、backend-server.js、referer-check.js。
限流日志、持久化与并发
记录 IP 不等于限流键
1function getClientIp(req) {
2 const cfIp = req.headers['cf-connecting-ip'];
3 const forwardedIps = req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0] : null;
4 const cfIpV6 = req.headers['cf-connecting-ipv6'];
5 return cfIp || forwardedIps || cfIpV6 || req.ip;
6}Source: backend-server.js。
这个函数仅在已读代码中的通用限流 handler 内用于事件日志,优先使用 cf-connecting-ip,然后是 x-forwarded-for 的第一段,再是 cf-connecting-ipv6,最后是 req.ip。通用 limiter 没有配置自定义 keyGenerator,所以不能据此宣称限流按该函数返回值分桶。
该函数没有验证这些头是否来自可信代理,也没有对转发链首项执行 trim 或 IP 格式检查。结合固定的 trust proxy = 1,部署时需要确保代理层覆盖不可信转发头,并限制客户端绕过可信代理直接访问后端。否则日志中的 IP 可能不可信,库所见客户端身份与记录值也可能不同。
台账不是封禁数据库
logLimitedIP(ip) 执行以下操作:
- 基于服务器目录与配置值构造日志路径;目录不存在时同步递归创建。
- 异步读取整个文件;
ENOENT视为新文件,其他读取错误只记录错误并返回。 - 按换行拆分,按逗号读取
ip,count,timestamp。 - 如果 IP 已存在,次数加一,保留最早时间戳;否则追加新行。
- 异步写回整个文件;写失败写错误日志。
时间戳使用主机本地时间,并附显式 UTC 偏移。次数代表进入通用限流状态的记录次数,不是该 IP 所有请求数,也不是全部 429 数量。
Source: backend-server.js。
已读实现不会读取这份文件来拒绝未来请求;虽然配置名含 BLACKLIST,它仍是事件台账,而非持久封禁名单。正常的异步读写路径不等待文件落盘就发送 429。
一致性与性能边界
- 台账是无锁的异步“读—修改—整文件覆盖”。同进程内多个回调交错或多个进程写同一文件,都可能丢失更新。
- 每次记录都读写完整文件,工作量随台账增长;没有看到轮转、裁剪或保留期处理。
existsSync与mkdirSync是同步目录操作;目录权限错误没有在该函数中用try/catch包裹,可能中断当前 handler 的正常响应路径。- limiter 配置未指定共享
store。不能从这段应用源码认定多个副本共享统一额度;第三方库的默认存储、窗口重置、响应头与 IPv6 分桶细节需结合实际依赖版本确认。 - 延迟限速会使请求等待更久,而不是让服务器不接收它。容量评估应将等待请求纳入考虑,不能将它当作入口流量防护的全部措施。
错误响应与排查要点
| 场景 | 状态 | 返回内容或处理方式 |
|---|---|---|
| 缺失 Referer | 403 | error: 'What are you doing?' |
| Referer 非空但解析失败或主机不允许 | 403 | error: 'Access denied' |
| IP 缺失/无效/非公网 | 400 | 分别为 No IP address provided、Invalid IP address、Not a public IP address |
| 域名、前缀、ASN、国家代码缺失或无效 | 400 | 对应 No … provided 或 Invalid … |
| 报告 ID 缺失或形态不符合 | 400 | error: 'Invalid report id';不代表已验证报告是否存在 |
| DNS 记录类型、服务提供方 ID 不在集合中 | 400 | Invalid record type 或 Invalid provider id |
| 通用硬限流 | 429 | message: 'Too Many Requests' |
| 仅超过延迟阈值 | 无额外业务错误 | 增加等待后继续 |
| 台账异步读写失败 | 日志错误 | 正常路径中不撤销限流结果,也无重试机制 |
输入类型也存在差异:IP 校验严格要求字符串;域名与 DNS 类型会显式 String(...);ASN 依赖解析器;国家代码正则存在 JavaScript 隐式转换。因此不要将全部守卫描述为统一的查询参数 schema 校验器,尤其需要单独验证重复查询参数的解析结果。
排查 403 时先看请求是否携带 Referer、URL 是否可解析及允许列表空格;排查 400 时区分语法与公网空间;排查 429 时区分通用额度和遥测额度;排查日志 IP 异常时先检查可信代理链,而不是只调整限流阈值。
来源:guards.js、backend-server.js、backend-server.js。
扩展与验证建议
新增查询路由时,优先复用守卫工厂,并在缓存包装和业务 handler 之前装配;参数名不同可通过工厂参数指定,现有 DNS 路由的 requireValidDomain('hostname') 就是实际示例。新增报告读取逻辑时,应继续区分 req.params 路径 ID 与 req.query 查询 ID,避免套错守卫。
若扩展 DNS 类型或服务状态提供方,需同步更新守卫引用的白名单与实际业务能力;若新增特殊限流路由,应明确它是否豁免通用硬限流、延迟限速以及是否仍继承全局 Referer 检查,不要只调整其中一层。
测试证据边界: 本次未读取测试文件,也未运行测试,不能声明已有自动化覆盖。基于已验证分支,建议验收至少包括:
- 缺失、非法 URL、错误主机和正确主机的 Referer,以及带空格的配置列表。
- IPv4 前导零、私网、文档网段,IPv6 压缩形式与保留空间。
- 域名大小写、下划线标签、DNS 类型大小写,以及重复查询参数。
- ASN 规范化与 WHOIS 非 ASN 输入透传;报告 ID 的字符集和长度边界。
- 配置为 0 时不挂载、首次超额记录一次、持续超额不重复写事件。
/api/maxmind与/api/monitoring的不同豁免行为,以及来源门禁仍生效。- 并发更新台账、目录不可写、多实例部署下的额度与日志一致性。
这些是后续验证清单,不是已执行测试的结果。扩展依据:guards.js、backend-server.js、backend-server.js。
相关链接
- 后端路由装配与保护顺序:追踪具体 API 是否绑定通用守卫。
- 请求守卫契约:查阅错误文案、参数来源和规范化规则。
- 来源主机名判断:排查允许列表与 Referer 行为。
- 公网地址空间策略:评估地址范围规则的修改。