经过两年的沉淀,我第一次尝试在 OSPP 上联系导师、投递简历,并成功地参与进知名开源社区 Casbin 的开发工作中。

项目介绍

我参与的项目 CasWAF 是一款开源的应用层网关防火墙。它可以有效地保护您的网络服务免受各种攻击,包括SQL注入、跨站脚本攻击等。该WAF支持IP限流、爬虫检测等高级特性。

我们希望在CasWAF上:

  1. 进一步优化和增强防护规则,使其可以应对更多种类的网络攻击;
  2. 提升CasWAF在处理大流量请求时的性能和稳定性。这两个目标对于提升CasWAF的核心竞争力及其在防火墙领域的地位至关重要。

其主要特点包括:

  • 全部Web UI图形化操作,无需编写复杂的配置文件。
  • 支持反向代理功能(类似Nginx),支持强制跳转HTTPS。
  • 支持一键自动配置Let's Encrypt SSL证书,支持自动更新。
  • 支持基于ModSecurity的防火墙规则匹配(即WAF核心功能)。
  • 支持基于用户代理字符串、IP、IP子网等维度的限流、支持黑白名单。
  • 支持数据统计大屏,方便管理员查看,实现态势感知。
  • 所有操作均记录在审计日志中,方便管理和审计。
  • 用户界面支持中文、英文等多国语言。

开发需求

在这个项目中,我的主要工作是:

  1. 实现基于 User-Agent, IP & CIDRIP Frequency 等多个维度的多种匹配方式。
  2. 提供多种 Action 以供用户选择,如 Block, CAPTCHA, DropAllow 等。
  3. 实现数据统计大屏,方便管理员查看,实现态势感知。

Timeline

接下来我将按照我的 Git History 来记录我的开发经历。

First PR

我在5月份开始了解项目并联系导师,并在5月底向 CasWAF 仓库提交了第一个 PR (feat: add dashboard page)。

在这个 PR 中,我添加了一个 Dashboard 页面,用于展示数据统计大屏。为了减少后端接口的复杂程度,我仅创建了两个新的 API,用于查询 Record 表中的数据,一个用于提供不同时间粒度的时间线查询,一个用于统计数据查询,并在前端页面中展示。

Dashboard

在数据库层面,这个项目使用的是 XORM 框架,我之前在 Golang 的项目中使用过 GORMent 两个框架,也有手写 SQL 的基础,所以对 XORM 的使用并不难理解。同时,我参考了开源网站数据分析工具 Umami 的 API 设计和数据库实现,实现了以下两个相对灵活的查询函数:

type DataCount struct {
    Data  string `json:"data"`
    Count int64  `json:"count"`
}

func GetMetrics(dataType string, startAt time.Time, top int) (*[]DataCount, error) {
    var dataCounts []DataCount
    err := ormer.Engine.Table("record").
        Where("UNIX_TIMESTAMP(created_time) > ?", startAt.Unix()).
        Select(dataType + " as data, COUNT(*) as count").
        GroupBy("data").
        Desc("count").
        Limit(top).
        Find(&dataCounts)
    if err != nil {
        return nil, err
    }
    return &dataCounts, nil
}

func GetMetricsOverTime(startAt time.Time, timeType string) (*[]DataCount, error) {
    var dataCounts []DataCount
    createdTime := "DATE_FORMAT(created_time, '" + timeType2Format(timeType) + "')"
    err := ormer.Engine.Table("record").
        Where("UNIX_TIMESTAMP(created_time) > ?", startAt.Unix()).
        GroupBy(createdTime).
        Select(createdTime + " as data, COUNT(*) as count").
        Asc("data").
        Find(&dataCounts)
    if err != nil {
        return nil, err
    }
    return &dataCounts, nil
}

func timeType2Format(timeType string) string {
    switch timeType {
    case "hour":
        return "%Y-%m-%d %H"
    case "day":
        return "%Y-%m-%d"
    case "month":
        return "%Y-%m"
    }
    return "%Y-%m-%d %H"
}

在前端页面中,我使用了 ECharts 来展示数据,我之前并没有做过相关的数据可视化工作,不过 ECharts 的文档十分详细,在使用了 echarts-for-react 的封装后,相关的开发并不困难。

Rule Data Structure

在6月底,我确定中选了 CasWAF 项目,并开始深入了解项目的代码结构和规则匹配的实现。同时,也开始着手 Rule 相关内容的实现。在期末考试结束后,我向 CasWAF 仓库提交了第二个 PR (feat: add rule)。

在当时的 CasWAF 中,并没有提供除了内置的几条 ModSecurity 规则外的其他规则。所以我的第一个目标是设计一个 Rule 的数据结构,并提供相关的 CRUD API。为此,我参考了 Cloudflare Rules Language 和 Tencent Cloud 的 WAF 规则设计,设计了以下的 Rule 数据结构:

type Expression struct {
    Name     string `json:"name"`
    Operator string `json:"operator"`
    Value    string `json:"value"`
}

type Rule struct {
    Type        string       `xorm:"varchar(100) notnull" json:"type"`
    Expressions []Expression `xorm:"mediumtext" json:"expressions"`
}

在这个数据结构中,Type 代表了规则的类型,Expressions 代表了规则的表达式。在 Expression 中,Name 代表了规则的名称,Operator 代表了规则的操作符,Value 代表了规则的值。对于不同类型的规则,表达式中的操作符和值的合法内容也不同。比如对于 User-Agent 规则,Operator 可以是 regex matchValue 可能是 Mozilla.*;对于 IP 规则,Operator 可能是 is inValue 可能是 10.0.0.0/8。为了提供更好的向后支持,这两个字段都是字符串类型,同时考虑到 ModSecurity 规则的复杂性,我也提供了一个 Expressions 字段使用了 mediumtext 类型。

UA Operator

IP Operator

在这个 PR 中,我还完成了两个前端页面,分别用于 List 和 Edit Rule。涉及到不同类型的规则,我将不同类型的 Rule 的表达式编辑分别封装在了不同的组件中,并在 Edit 页面中做条件渲染,以便于后续的扩展。

User-Agent & IP Rules

在随后的时间里,我陆续完善了对 User-Agent 和 IP 规则的支持,并将原先的 ModSecurity 规则的匹配逻辑重写整合到了 Rule 中。

在其中有个小插曲,算是我个人对于 git 工具使用和大型项目开发不熟悉的问题,在提交了 #36 这个 PR 之后,没等 merge 之前,我就在本地继续开发了下一个 feature,但是由于上个 PR 存在一些修改,这使得 commit tree 不是线型的,导致了第二次 PR 之前出现了大量代码冲突。

origin/master---A---B---C---D--
      \
       A---B---C---D
            \
              E---F---G

也使得这次 PR 修改了过多文件,在和导师沟通之后,我自己将这个 PR 关闭了,我也在后续的开发中更加注意了这个问题。

随后,我使用 git diff 将这个 PR 的修改分离出来,重新提交了两个 PR,feat: add UA rulefeat: add ip rule

#45 这个 PR 当中,我在反代的流程当中,正式加入了对于规则的匹配逻辑,以及最为基础的 BlockAllow 两种 Action。同时在后端中加入了 rule cache,以减少对于数据库的查询,大幅减少了每次请求的处理时间,也对规则的热更新进行了支持。

之后导师对我的代码进行了重构 (feat: refactor out rule package) ,将规则匹配的逻辑提取到了一个单独的包中,以便于后续的扩展和维护。

#46 这个 PR 相对来说比较简单,我在这个 PR 中加入了对于 IP 规则的支持,同时在前端页面中加入了对于 IP 规则的编辑和展示,在后端对传入的参数做了校验,以保证规则的合法性。

WAF Rule

在完成了 User-Agent 和 IP 规则的支持之后,我开始着手对于 WAF 规则的支持。这个项目中的 WAF 规则是基于 ModSecurity 的规则,在我接手这个项目的时候就已经引入了 ModSecurity 的规则引擎,但是也仅仅是在反代的流程中使用它对请求进行匹配,而并没提供后续的 action 以及对于规则的编辑和展示。

feat: add WAF rule 这个 PR 中,我重构了之前 WAF 注入的逻辑,将其与 Rule 结构体整合在了一起。由于在之前的结构中,WAF 是作为一个单独的对象缓存在 Site 中的,这使得在更新 WAF 规则的时候需要对所有的 Site 进行全量的缓存,这样的设计显然是不合理的。在深入了 Coraza WAF 的源码之后,我发现它创建的 WAF 对象实际上仅仅是对所有传入的 ModSecurity Rule 的一个拷贝,实测一次创建仅耗时 6ns,几乎可以忽略,所以说我没有将创建好的 WAF 对象缓存起来,而是在每次请求到来的时候重新创建一个 WAF 对象,以保证规则的热更新。

此外,我还加入了对于 Coraza WAF 引擎匹配结果的解析,让 WAF 规则支持了 CasWAF 中的 action。

最后,我在前端页面中加入了对于 WAF 规则的编辑和展示。

这是后续的 PR:feat: allow custom reason for WAF rule,补充了一点功能,允许管理员在 WAF 规则中添加自定义的阻断信息。

Using Rules in Site

在完成了 WAF 规则的支持之后,这个防火墙也基本可用了,我开始着手对于 Verbose Mode 的支持。在之前的开发中,我和导师经常会讨论到当请求被阻断的时候,我们应该向用户展示哪些信息。如果我们展示的信息过多,可能会泄露一些敏感信息,但如果我们展示的信息过少,可能会让管理员在调试的时候无法理解为什么请求被阻断。

feat: add verbose mode for sites 这个 PR 中,我加入了对于 Verbose Mode 的支持。在这个 PR 中,我在 Site 结构体中加入了一个 DisableVerbose 字段,用于控制是否展示详细的阻断信息。在前端页面中,我加入了一个开关,用于控制 Verbose Mode 的开关。在后端中,我在阻断请求的时候,如果不展示详细信息,则仅会返回 “the rule has been hit” 至用户。

#48 之后,WAF 规则已经被整合到了 Rule 中,而 Site 中的 enableWaf 字段已经没有意义,所以我在 feat: remove enableWaf field 这个 PR 中将其移除。

feat: add RuleTable to SiteEditPage 中,我在 SiteEditPage 中加入了对于 RuleTable 的展示,以便于管理员在编辑 Site 的时候可以直接选择想要的规则。

rules-in-site

Update antd to 4.24.12

由于需要用到 antdInputNumberaddonAfter 属性,而 antd 的版本过低,所以我在 feat: update antd to 4.24.12 这个 PR 中将 antd 的版本更新到了 4.24.12

addonAfter

然后在 feat: add menu crash in frontend 修复了由于 antd 版本更新导致的菜单栏崩溃的问题。

IP Frequency Rule

在完成了对于 WAF 规则的支持之后,我开始着手对于 IP 频率限制的支持。在 feat: support IP rate limiting 中加入了对于 IP 频率限制的支持。这个 PR 的开启时间早于更新 antd 的 PR,但是由于在这个 PR 中我使用了 antdInputNumber 组件,所以它实际合并的时间晚于更新 antd 的 PR。

type IpRateRule struct {
	ruleName string
}

type IpRateLimiter struct {
	ips map[string]*rate.Limiter
	mu  *sync.RWMutex
	r   rate.Limit
	b   int
}

var blackList = map[string]map[string]time.Time{}

var ipRateLimiters = map[string]*IpRateLimiter{}

在后端的实现中,我使用了 golang.org/x/time/rate 包来实现了对于 IP 的频率限制,它本质上是一个令牌桶算法的实现,可以很好地控制每秒的请求次数。当某一个 IP 的请求次数超过了限制的时候,我会将其加入到一个黑名单中,并记录其触发的时间,当这个 IP 的后续请求到来的时候,我会检查其是否在黑名单中,如果在黑名单中,再判断是否过了阻断时间,如果过了阻断时间,则将其移出黑名单,否则继续阻断。

在前端,为了保持 UI 的风格一致,我封装了一个行数为 1 的表格组件,用于展示 IP 频率限制的规则。

ip-frequency-table

Compound Rule

feat: support compound rules

在完成了对于 IP 频率限制的支持之后,我开始着手对于复合规则的支持。在我的预期中,复合规则应该是由多条任意规则通过逻辑运算符组合而成的,但是在实际的开发中,我发现在后端实现并不困难,是一个很基础的栈的使用;但是前面的页面展示和编辑却是一个难题,我参考了 Cloudflare 的 WAF 规则编辑页面,但是在没有组件库支持的情况下,且不说实现,就是实现一个和现有页面风格一致的编辑页面也是一个巨大的挑战。在和导师讨论之后,我们决定继续沿用表格的形式,不过很可惜无法以一个更加直观的方式展示多条规则的逻辑关系。

compound-expr

图中展示的是一个复合规则的编辑页面,用户可以在这个页面中添加多条规则,并选择逻辑运算符,以组合成一个复合规则。

CAPTCHA action

feat: add captcha page

feat: support CAPTCHA action via Casdoor

feat: rename Captcha to CAPTCHA

IP Frequency 规则的引入更多是为了防止恶意请求,而 CAPTCHA 规则的引入更多是为了防止机器人请求。由于 Casdoor 暂时没有提供一个独立的 CAPTCHA 组件,我需要先在 Casdoor 中封装一个 Casdoor CAPTCHA 页面。在 CasWAF 中,我参考了 OAuth 的逻辑,实现了 CAPTCHA 的验证和状态保持。

sequenceDiagram participant U as User participant W as CasWAF participant C as Casdoor U->>W: Request W->>U: Redirect to Casdoor U->>C: CAPTCHA C->>U: Code U->>W: Code W->>C: Verify Code C->>W: Response W->>U: Sign session or redirect again

DockerHub CI/CD

feat: fix CI script for failed Dockerhub release

feat: re-generate yarn.lock to fix CI error

feat(Dockerfile): remove npmmirror and update base version

feat: remove arm64 DockerHub image

DockerHub 的 CI 异常是由于之前的 CI 管线中多次发版导致 CI 管线中构建镜像的步骤被跳过,导致 DockerHub 中的镜像无法更新。我通过移除多余的发版步骤、重新生成 yarn.lock 文件、更新 Dockerfile 的基础版本,解决了 DockerHub 的 CI 异常。

通过分析 GitHub Actions 的日志,我发现 arm64 的镜像编译花费了非常多的时间,但是 DockerHub 的使用几乎为0,于是我删除了 arm64 的镜像,大幅度减少了 CI 管线的构建时间,由原先的多于 1 小时缩短到了 5 分钟左右。

remove-arm64

图中中间两个 actions 是没有 Docker 相关的步骤的,所以时间比较短。

Sepreate actions from rules

feat: add action object

feat: add action object

我也忘了当时为什么会提两个名字一样的 PR,不过这两个 PR 的目的是一样的,就是将 Action 从 Rule 中分离出来,以便于后续的扩展。在这两个 PR 中,我将 Action 从 Rule 中分离出来,同时在前端页面中加入了对于 Action 的编辑和展示。

edit-action

在后端中,我将 Action 从 Rule 中分离出来,同时也使用了 cache 来减少对于数据库的查询,以及支持了 Action 的热更新。

Health Check and Alert

在支持 site 的健康检查和异常预警时,我先实现了在 CasWAF 中对于 site 的健康检查和异常预警的逻辑,并允许用户针
对不同的 site 设置不同的健康检查间隔和异常预警阈值。

func startHealthCheckLoop() {
	for _, domain := range healthCheckNeededDomains {
		domain := domain
		if _, ok := healthCheckTryTimesMap[domain]; ok {
			continue
		}
		healthCheckTryTimesMap[domain] = GetSiteByDomain(domain).AlertTryTimes
		go func() {
			for {
				site := GetSiteByDomain(domain)
				if shouldStopHealthCheck(site) {
					delete(healthCheckTryTimesMap, domain)
					return
				}

				err := healthCheck(site, domain)
				if err != nil {
					fmt.Println(err)
				}
				time.Sleep(time.Duration(site.AlertInterval) * time.Second)
			}
		}()
	}
}

之后我在 Casdoor 中实现了发送邮件和短信的逻辑,但 Casdoor-go-sdk 中的 API 不够完善,导致我在发送消息的时候无法指定发送源。最终我通过修改 Casdoor-go-sdk 的代码,添加了通过指定源发送邮件和短信的 API feat: add SendEmailByProvider and SendSmsByProvider APIs, add back Application.CertObj,并在 CasWAF 中调用了这些 API,实现了对 site 的健康检查和异常预警 feat: support health check and alert for sites

health-check-alert

总结

在与导师的沟通中,我学到了很多关于开源项目的知识,其中包括一些代码规范、代码风格等等,也学到了很多关于开源社区的知识,比如如何提交 PR、如何与导师交换意见等等。

在这个项目中,我学到了很多关于 Web 安全的知识,比如如何防止 SQL 注入、XSS 攻击等等,也学到了很多关于 Web 开发的知识,比如如何使用 ECharts 展示数据、如何在 DockerHub 中上传镜像、如何使用 golang.org/x/time/rate 包限流等等。

这是我第一次参与到大型的开源社区项目,对我来说这是一次宝贵的体验。导师经验丰富,给予了我很多帮助。也十分感谢 OSPP 提供一个平台带我走入开源的世界与近距离接触优秀的前辈。

Be a Neutral Listener, Dialectical Thinker, and Practitioner of Knowledge