From 46941f65d56cf2beb9e54dcf22cdf111513f505c Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 13 Jul 2026 16:43:02 +0800 Subject: [PATCH] fix(waf): handle empty rule bindings Encode empty site bindings as arrays and normalize legacy JSON null values in the OpenResty runtime to prevent request-time Lua failures. --- docker-compose.yaml | 2 +- docs/changelog/index.md | 1 + docs/design/waf-orchestration-design.md | 2 +- docs/plan/20260713-waf-orchestration.md | 2 +- internal/apps/agent/nginx/waf_runtime.lua | 21 ++++++++++++------- .../apps/agent/nginx/waf_runtime_spec.lua | 15 +++++++++++++ .../openflare/config_version/logics_test.go | 2 ++ .../apps/openflare/config_version/snapshot.go | 9 +++++++- .../config_version/waf_graph_snapshot_test.go | 20 ++++++++++++++++++ 9 files changed, 62 insertions(+), 12 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 7d3762a5..b9c3e2ae 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -110,7 +110,7 @@ services: - ./data/agent/:/data environment: OPENFLARE_SERVER_URL: "http://host.docker.internal:3000" - OPENFLARE_AGENT_TOKEN: "3f7b66c9329a0cf253e71a86681223aa" + OPENFLARE_AGENT_TOKEN: "af2fb112f36a0055ec25dd164c908fea" LOG_LEVEL: "debug" extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 4fa0f6f9..b8a4d2a8 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -39,6 +39,7 @@ sidebar: false ### 修复 +- 修复 WAF 站点没有启用的自定义规则时,空绑定被编码为 `null` 并导致 OpenResty Lua 对 `ngx.null` 执行 `ipairs`、使请求返回 500 的问题;新快照固定输出空数组,运行时同时兼容旧快照。 - 调整了生产环境的访问和数据库日志级别,减少心跳、轮询等高频请求刷屏,同时避免将查询内容写入常规日志。 - 修复了 WAF 编辑器在拖动、删除节点或连线时可能闪烁或显示异常的问题,使规则编辑更稳定。 - 修复了 Agent 同步 WAF IP 组时可能遗漏新引用或保留失效引用的问题,确保已发布的防护规则能够及时生效。 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 101eecb8..5d87a581 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -113,7 +113,7 @@ WAF 列表展示规则名称、启用状态、节点数量、应用路由数量 新 Worker 只接受完整且可解析的规则运行态配置。旧 Worker 在 OpenResty 优雅 reload 期间继续使用旧内存图,新 Worker 使用新图,因此请求不会观察到半更新状态。 -地域数据库不可用时,地域匹配返回 `false` 并限频告警,保持现有行为。IP 组刷新失败时保留旧内存快照。PoW 未完成由挑战模块接管请求,不视为执行错误。 +地域数据库不可用时,地域匹配返回 `false` 并限频告警,保持现有行为。IP 组刷新失败时保留旧内存快照。PoW 未完成由挑战模块接管请求,不视为执行错误。发布快照中的空规则绑定必须编码为 JSON 空数组;运行时将旧快照中的 `null` 可选数组按空数组处理,禁止因 `cjson` 的 `ngx.null` userdata 中断请求。 ## 测试与验收 diff --git a/docs/plan/20260713-waf-orchestration.md b/docs/plan/20260713-waf-orchestration.md index 2aac610c..fd2b0f1f 100644 --- a/docs/plan/20260713-waf-orchestration.md +++ b/docs/plan/20260713-waf-orchestration.md @@ -10,7 +10,7 @@ ## 实现状态(2026-07-13) -Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。地域属性栏使用完整国家与 ISO 3166-2 一级行政区数据,国家同时展示中文名称与代码,行政区支持按名称或代码搜索。 +Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。地域属性栏使用完整国家与 ISO 3166-2 一级行政区数据,国家同时展示中文名称与代码,行政区支持按名称或代码搜索。发布器保证空规则绑定编码为 `[]`,Lua 运行时兼容旧快照中的 `null` 数组,避免未启用或空绑定规则导致请求 500。 当前工作区已完成 `go test ./...`、前端全量 Vitest(56 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。 diff --git a/internal/apps/agent/nginx/waf_runtime.lua b/internal/apps/agent/nginx/waf_runtime.lua index 1c2b28c9..31085701 100644 --- a/internal/apps/agent/nginx/waf_runtime.lua +++ b/internal/apps/agent/nginx/waf_runtime.lua @@ -37,6 +37,11 @@ local function warn_rate_limited(key, ...) end end +local function array_or_empty(value) + if type(value) == "table" then return value end + return {} +end + local function file_exists(path) local file = io.open(path, "rb") if not file then return false end @@ -121,7 +126,7 @@ end local function config_geo_requirements(config) local uses_geo, uses_region = false, false - for _, rule in ipairs(config.rule_groups or {}) do + for _, rule in ipairs(array_or_empty(config.rule_groups)) do for _, node in pairs((rule.graph or {}).nodes or {}) do if node.type == "geo_match" then uses_geo = true @@ -270,18 +275,18 @@ local function ip_in_cidr(ip, cidr) end local function matches_ip_values(config, ip) - for _, item in ipairs(config.ips or {}) do + for _, item in ipairs(array_or_empty(config.ips)) do if item == ip or ipv6_equal(item, ip) then return true end end - for _, cidr in ipairs(config.cidrs or {}) do + for _, cidr in ipairs(array_or_empty(config.cidrs)) do if ip_in_cidr(ip, cidr) then return true end end local snapshot = ip_groups_config or ip_groups_runtime.current() local groups = (snapshot or {}).groups or {} - for _, id in ipairs(config.ip_group_ids or {}) do + for _, id in ipairs(array_or_empty(config.ip_group_ids)) do local group = groups[tostring(id)] if group and group.enabled then - for _, item in ipairs(group.ip_list or {}) do + for _, item in ipairs(array_or_empty(group.ip_list)) do if item == ip or ipv6_equal(item, ip) or ip_in_cidr(ip, item) then return true end end end @@ -360,13 +365,13 @@ end local function active_rules(site) local by_id, result = {}, {} - for _, rule in ipairs(rules_config.rule_groups or {}) do + for _, rule in ipairs(array_or_empty(rules_config.rule_groups)) do by_id[tostring(rule.id)] = rule if rule.enabled and rule.is_global then result[#result + 1] = rule end end - for _, binding in ipairs(rules_config.bindings or {}) do + for _, binding in ipairs(array_or_empty(rules_config.bindings)) do if binding.site_name == site then - for _, id in ipairs(binding.rule_group_ids or {}) do + for _, id in ipairs(array_or_empty(binding.rule_group_ids)) do local rule = by_id[tostring(id)] if rule and rule.enabled and not rule.is_global then result[#result + 1] = rule end end diff --git a/internal/apps/agent/nginx/waf_runtime_spec.lua b/internal/apps/agent/nginx/waf_runtime_spec.lua index 2e841060..28396db4 100644 --- a/internal/apps/agent/nginx/waf_runtime_spec.lua +++ b/internal/apps/agent/nginx/waf_runtime_spec.lua @@ -477,6 +477,20 @@ local function test_damaged_graphs_fail_closed() end end +local function test_null_binding_ids_are_treated_as_empty() + local runtime = load_runtime({ + rule_groups = {}, + -- cjson decodes JSON null to userdata (ngx.null). io.stdout provides the + -- same Lua value type in this standalone regression test. + bindings = { binding("null-binding", io.stdout) }, + }) + + reset_request("null-binding") + local result = runtime.check() + assert_equal(result, "ok", "null binding IDs allow the request") + assert_equal(output.exit, nil, "null binding IDs never abort the request") +end + local function test_request_path_has_no_file_io() local opens = 0 local original_open = io.open @@ -530,6 +544,7 @@ test_pow_takeover_and_completion() test_pow_internal_redirect_bypasses_graph_as_takeover() test_block_config_and_rule_order() test_damaged_graphs_fail_closed() +test_null_binding_ids_are_treated_as_empty() test_request_path_has_no_file_io() return true diff --git a/internal/apps/openflare/config_version/logics_test.go b/internal/apps/openflare/config_version/logics_test.go index f0be1642..e1daf6f5 100644 --- a/internal/apps/openflare/config_version/logics_test.go +++ b/internal/apps/openflare/config_version/logics_test.go @@ -223,6 +223,8 @@ func TestBuildCurrentConfigBundleEnablesGlobalPoWWithoutExplicitBinding(t *testi continue } foundWAFConfig = true + assert.Contains(t, file.Content, `"rule_group_ids":[]`) + assert.NotContains(t, file.Content, `"rule_group_ids":null`) require.NoError(t, json.Unmarshal([]byte(file.Content), &wafRuntime)) } require.True(t, foundWAFConfig, "expected rendered WAF support file") diff --git a/internal/apps/openflare/config_version/snapshot.go b/internal/apps/openflare/config_version/snapshot.go index 2dff4d46..d69de94d 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/internal/apps/openflare/config_version/snapshot.go @@ -370,7 +370,7 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) ( bindings = append(bindings, snapshotWAFBinding{ RouteID: routeID, SiteName: siteName, - RuleGroupIDs: groupIDsByRoute[routeID], + RuleGroupIDs: nonNilUintSlice(groupIDsByRoute[routeID]), }) } sort.Slice(bindings, func(i, j int) bool { @@ -382,6 +382,13 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) ( return snapshotWAFDocument{RuleGroups: ruleGroups, IPGroups: ipGroups, Bindings: bindings}, nil } +func nonNilUintSlice(values []uint) []uint { + if values == nil { + return make([]uint, 0) + } + return values +} + func validateSnapshotWAFIPGroupSize(groups []snapshotWAFIPGroup) error { runtimeGroups := make(map[string]protocol.WAFIPGroup, len(groups)) for _, group := range groups { diff --git a/internal/apps/openflare/config_version/waf_graph_snapshot_test.go b/internal/apps/openflare/config_version/waf_graph_snapshot_test.go index 1daf0aed..98d20697 100644 --- a/internal/apps/openflare/config_version/waf_graph_snapshot_test.go +++ b/internal/apps/openflare/config_version/waf_graph_snapshot_test.go @@ -85,6 +85,26 @@ func TestWAFGraphSnapshotPreservesOrderAndGraphReferences(t *testing.T) { assert.NotContains(t, string(raw), "ip_whitelist") } +func TestWAFGraphSnapshotEncodesEmptyBindingsAsArrays(t *testing.T) { + cleanup := setupConfigVersionTestDB(t) + defer cleanup() + ctx := context.Background() + + route := &model.ProxyRoute{SiteName: "empty-binding.example.com", OriginURL: "http://origin:8080", Upstreams: `["http://origin:8080"]`, Enabled: true} + require.NoError(t, model.CreateProxyRouteRecord(ctx, route)) + createSnapshotZoneDomains(t, ctx, route, route.SiteName) + + snapshot, err := buildSnapshotWAFDocument(ctx, []*model.ProxyRoute{route}) + require.NoError(t, err) + require.Len(t, snapshot.Bindings, 1) + require.NotNil(t, snapshot.Bindings[0].RuleGroupIDs) + + raw, err := json.Marshal(snapshot) + require.NoError(t, err) + assert.Contains(t, string(raw), `"rule_group_ids":[]`) + assert.NotContains(t, string(raw), `"rule_group_ids":null`) +} + func TestBuildSnapshotRejectsInvalidWAFGraph(t *testing.T) { cleanup := setupConfigVersionTestDB(t) defer cleanup()