空 User-Agent 请求需结合请求头完整性与行为模式轻量过滤:先记录日志,再检查是否为 HEAD + 空 UA + 无 Accept 头,同一 IP 连续 3 次触发才 Abort 并返回 403。

空 User-Agent 请求怎么识别和拦截
空 User-Agent 是最基础的扫描器特征之一,但不能只靠它做判断——很多合法爬虫(如 RSS 阅读器)或老旧客户端也可能不带 UA,直接 403 容易误杀。
实际做法是结合请求头完整性 + 行为模式做轻量过滤:
-
c.GetHeader("User-Agent")返回空字符串或仅含空白字符时,先记录日志,不立即拒绝 - 同时检查
c.Request.Method是否为非常规组合(如HEAD+ 空 UA + 无Accept头) - 若连续 3 次来自同一 IP 的空 UA 请求,才在中间件里调用
c.Abort()并返回 403
示例片段:
func BlockEmptyUA() gin.HandlerFunc {
return func(c *gin.Context) {
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
if ua == "" && c.Request.Method == "HEAD" && c.GetHeader("Accept") == "" {
ip := c.ClientIP()
// 这里可接简单内存计数器或 Redis
if shouldBlockIP(ip) {
c.AbortWithStatus(403)
return
}
}
c.Next()
}
}
Gin 中间件如何限制高频恶意扫描行为
单纯封 UA 没用,扫描器会伪造。真正有效的是对「路径遍历型请求」做速率控制 + 路径特征匹配。
关键点不是拦所有 /admin,而是拦 /admin.php、/wp-login.php、/phpmyadmin/ 这类已知攻击路径的高频访问:
- 用
c.Request.URL.Path提取路径,不做正则全匹配(性能差),改用前缀判断:strings.HasPrefix(path, "/phpmyadmin/") - 对匹配到的路径,用
net/http的http.ServeMux不适合 Gin,应使用golang.org/x/time/rate做 per-IP 限流 - 注意:不要对
/health或/metrics这类监控端点限流,否则 Prometheus 抓不到数据
限流阈值建议设为每分钟 5 次 —— 正常用户几乎不会触发,扫描器基本秒超。
为什么 Gin 的 c.Next() 和 c.Abort() 在这里特别关键
洋葱模型决定了中间件执行顺序,而恶意请求拦截必须「早中断、晚记录」:
- 如果在检测到空 UA 后只写日志不调用
c.Abort(),请求仍会往下走,可能触发业务逻辑里的 DB 查询或模板渲染,白白消耗资源 - 但若在
c.Next()之前就c.Abort(),后续中间件(比如 JWT 验证)就不会执行,节省开销 - 更隐蔽的问题:某些日志中间件依赖
c.Writer.Status(),如果提前Abort(),这个值还是 0,得手动设状态码再 abort
所以标准写法是:c.AbortWithStatus(403),而不是 c.Status(403); c.Abort() —— 前者自动设置状态并终止,后者容易漏掉状态写入。
user_agent 库在真实扫描识别中能起多大作用
它本身不防扫描,但能帮你把「疑似扫描器」从噪声里捞出来。比如:
-
ua.Bot()返回 true,且ua.Browser().Name == "Unknown",大概率是工具生成的 UA(如 sqlmap、dirsearch) -
ua.Mobile() == false && ua.Desktop() == false,说明解析失败或 UA 极度异常,值得标记 - 别在每次请求都 new 一个
useragent.New(),用 sync.Pool 复用实例,否则 GC 压力明显上升
真正要命的不是 UA 字符串本身,而是它和请求路径、频率、来源 IP 的组合。单点判断永远不准,得串起来看。
复杂点在于:有些扫描器会轮换 UA,甚至模仿 Chrome 最新版本;而真实用户也可能用隐私浏览器禁用 UA 发送。留白空间比硬规则更重要。


















