From 2cb339258cb791e9a36234203a2e8feb59db09d2 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 26 Aug 2026 10:41:56 +0800 Subject: [PATCH] =?UTF-8?q?LIKE=20=E8=BF=87=E6=BB=A4=E5=99=A8=E8=BD=AC?= =?UTF-8?q?=E4=B9=89=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=97=A5=E5=BF=97=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=90=AB=20%/=5F=20=E7=9A=84=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93=E9=80=9A=E9=85=8D=E7=AC=A6?= =?UTF-8?q?=EF=BC=9Bpkg/util=20=E6=96=B0=E5=A2=9E=20EscapeLike=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=8A=A9=E6=89=8B=20+=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result: {"status":"keep","total_issues":8,"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126} --- .auto/ideas.md | 19 ++++++++++++++++ .auto/log.jsonl | 2 ++ .../repository/analytics/access_log_filter.go | 3 ++- .../analytics/node_access_log_filter.go | 7 +++--- .../repository/logstore/postgres_store.go | 17 +++++++------- pkg/util/like.go | 16 ++++++++++++++ pkg/util/like_test.go | 22 +++++++++++++++++++ 7 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 pkg/util/like.go create mode 100644 pkg/util/like_test.go diff --git a/.auto/ideas.md b/.auto/ideas.md index 2140ecaa..c0da48f5 100644 --- a/.auto/ideas.md +++ b/.auto/ideas.md @@ -58,3 +58,22 @@ - pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。 - frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属 纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。 + +## Run #44(全仓 -race 扫描) + +- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs + testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2 + 同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。 +- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是 + 无环的根因修法。 +- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。 + +## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留) + +- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、 + logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。 + 新助手 pkg/util/like.go EscapeLike + 单测。 +- 遗留同类(低风险,用户名/关键词搜索):repository/upload.go:53 keyword contains、 + repository/user.go:73/76/188 username/email 前缀+contains、task_execution.go:155 + task_type 前缀。user.go:228 `base+"-%"` 与 config_version.go:65 为系统生成模式, + 刻意通配勿动。GORM 站点加 ESCAPE 子句即可复用 EscapeLike。 diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 87c47217..27b7d23e 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -42,3 +42,5 @@ {"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}} {"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}} {"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}} +{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}} +{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}} diff --git a/internal/repository/analytics/access_log_filter.go b/internal/repository/analytics/access_log_filter.go index ed392468..ca64d0b7 100644 --- a/internal/repository/analytics/access_log_filter.go +++ b/internal/repository/analytics/access_log_filter.go @@ -8,6 +8,7 @@ import ( "strings" analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/util" ) const userAccessLogFilterClauseCapacity = 4 @@ -32,7 +33,7 @@ func buildUserAccessLogFilterClause(filter AccessLogFilter) (string, []any, bool } if trimmed := strings.TrimSpace(filter.Path); trimmed != "" { parts = append(parts, "path LIKE ?") - args = append(args, "%"+trimmed+"%") + args = append(args, "%"+util.EscapeLike(trimmed)+"%") } if filter.StartTime != nil { parts = append(parts, "created_at >= ?") diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go index 90ad5a85..fc1f71c8 100644 --- a/internal/repository/analytics/node_access_log_filter.go +++ b/internal/repository/analytics/node_access_log_filter.go @@ -8,6 +8,7 @@ import ( "strings" analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/util" ) const ( @@ -37,7 +38,7 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) } if trimmed := normalizeNodeAccessLogRemoteAddr(filter.RemoteAddr); trimmed != "" { parts = append(parts, "remote_addr LIKE ?") - args = append(args, trimmed+"%") + args = append(args, util.EscapeLike(trimmed)+"%") } hosts := normalizeNodeAccessLogHosts(filter.Hosts) if len(hosts) > 0 { @@ -49,11 +50,11 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) parts = append(parts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")") } else if trimmed := strings.TrimSpace(filter.Host); trimmed != "" { parts = append(parts, "host LIKE ?") - args = append(args, trimmed+"%") + args = append(args, util.EscapeLike(trimmed)+"%") } if trimmed := strings.TrimSpace(filter.Path); trimmed != "" { parts = append(parts, "path LIKE ?") - args = append(args, trimmed+"%") + args = append(args, util.EscapeLike(trimmed)+"%") } if filter.StatusCode > 0 { parts = append(parts, "status_code = ?") diff --git a/internal/repository/logstore/postgres_store.go b/internal/repository/logstore/postgres_store.go index 338b10d7..0b68ecf6 100644 --- a/internal/repository/logstore/postgres_store.go +++ b/internal/repository/logstore/postgres_store.go @@ -16,6 +16,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" "github.com/Rain-kl/Wavelet/internal/model" analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/util" "gorm.io/gorm" ) @@ -823,20 +824,20 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string args = append(args, nodeID) } if remoteAddr := strings.TrimSpace(f.RemoteAddr); remoteAddr != "" { - parts = append(parts, "remote_addr LIKE ?") - args = append(args, remoteAddr+"%") + parts = append(parts, `remote_addr LIKE ? ESCAPE '\'`) + args = append(args, util.EscapeLike(remoteAddr)+"%") } hosts := normalizeNodeAccessLogHosts(f.Hosts) if len(hosts) > 0 { parts = append(parts, "lower(trim(host)) IN ?") args = append(args, hosts) } else if host := strings.TrimSpace(f.Host); host != "" { - parts = append(parts, "host LIKE ?") - args = append(args, host+"%") + parts = append(parts, `host LIKE ? ESCAPE '\'`) + args = append(args, util.EscapeLike(host)+"%") } if path := strings.TrimSpace(f.Path); path != "" { - parts = append(parts, "path LIKE ?") - args = append(args, path+"%") + parts = append(parts, `path LIKE ? ESCAPE '\'`) + args = append(args, util.EscapeLike(path)+"%") } if f.StatusCode > 0 { parts = append(parts, "status_code = ?") @@ -1495,8 +1496,8 @@ func buildUserAccessLogWhere(filter analyticsmodel.AccessLogFilter) (string, []a args = append(args, filter.UserIDs) } if trimmed := strings.TrimSpace(filter.Path); trimmed != "" { - parts = append(parts, "path LIKE ?") - args = append(args, "%"+trimmed+"%") + parts = append(parts, `path LIKE ? ESCAPE '\'`) + args = append(args, "%"+util.EscapeLike(trimmed)+"%") } if filter.StartTime != nil { parts = append(parts, "created_at >= ?") diff --git a/pkg/util/like.go b/pkg/util/like.go new file mode 100644 index 00000000..cd5d8a34 --- /dev/null +++ b/pkg/util/like.go @@ -0,0 +1,16 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "strings" + +var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + +// EscapeLike escapes SQL LIKE metacharacters (\, %, _) so a user-supplied +// value matches literally in LIKE patterns. Pair it with an explicit +// `ESCAPE '\'` clause where the dialect has no backslash default (SQLite); +// PostgreSQL and ClickHouse treat backslash as the default LIKE escape. +func EscapeLike(value string) string { + return likeEscaper.Replace(value) +} diff --git a/pkg/util/like_test.go b/pkg/util/like_test.go new file mode 100644 index 00000000..b84f8160 --- /dev/null +++ b/pkg/util/like_test.go @@ -0,0 +1,22 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "testing" + +func TestEscapeLike(t *testing.T) { + cases := map[string]string{ + "": "", + "/my_page": `/my\_page`, + "100%": `100\%`, + `a\b`: `a\\b`, + `%_\`: `\%\_\\`, + "normal/path": "normal/path", + } + for input, want := range cases { + if got := EscapeLike(input); got != want { + t.Errorf("EscapeLike(%q) = %q, want %q", input, got, want) + } + } +}