返回所有文章
2081 字8 分钟阅读访问统计加载中

转小写会改变字节长度,一条请求就让 Go 网关进程退出:改成只折叠英文字母

postmortemGo

一个 Go 写的内容检查网关,在客户配上自定义关键词之后开始整进程退出,退出码 2,标准错误里只有一段 index out of range 的崩溃堆栈。关键词匹配先把整段文本转成小写、在这份副本里找到位置,再拿同一个位置回到原文判断词边界;Unicode 小写映射会改变 UTF-8 字节长度,副本和原文的偏移对不上,往前多走一个字节就越界。

修复把大小写折叠限制在 A-Z 这 26 个字母上,保证副本和原文逐字节对应,代价是非英文字母的大小写变体不再互相匹配。同时给每个检测器加崩溃兜底,让一个检测器的缺陷停在它自己身上。

问题详情

网关在 AI 请求发往上游之前检查请求内容,其中一项检查是客户自己配的关键词——内部服务名、项目代号这类字符串,出现在请求里就记一条。

客户配上关键词之后,某些请求会让网关进程整个退出,退出码 2:

panic: runtime error: index out of range [18] with length 17

网关是所有客户共用的一个进程,一条请求打掉它,全部客户的 AI 流量同时中断。触发不需要构造攻击载荷,正常对话内容里出现少数几个特殊字符就够了。最小复现是关键词配 acme、请求内容为 ȺȺȺȺȺȺ acme,其中 Ⱥ 是 U+023A。

同一个缺陷还有一种不崩溃的表现:关键词明明陷在别的词里面,照样被报成一次独立命中。

排查与原因

位置在副本上算,字节在原文上取

修复前的匹配逻辑去掉无关分支后是这样:

lower := strings.ToLower(text) // 全文转小写,得到一份副本
i := strings.Index(lower, sym) // 在副本里找关键词
start, end := i, i+len(sym)
if startsAtBoundary(text, start) && endsAtBoundary(text, end) { // 回原文判边界
	hits++
}

边界判定要看命中位置左右的那个字节,并且必须看原文:

func startsAtBoundary(s string, i int) bool {
	if i == 0 {
		return true
	}
	prev := s[i-1]
	if !isIdentChar(prev) {
		return true
	}
	// 驼峰分词处:小写或数字后面跟大写
	return isLowerOrDigit(prev) && isUpper(s[i])
}

看原文的理由是驼峰命名。NewAcmeGateway 里 w 后面跟着大写 A,这是一个分词点,Acme 算独立出现;incorporate 里 n 后面是小写 c,corp 陷在词中间,不算。转成小写之后大写信息就没了,这条规则也跟着没了。

这段代码因此带着一个没写下来的前提:副本和原文逐字节一一对应。Unicode 不保证这一点。

Unicode 小写映射不保证等长

Go 1.26.7 实测四个字符:

字符码位UTF-8 字节数转小写后字节数
ȺU+023A2ⱥ(U+2C65)3
K(开尔文符号)U+212A3k(U+006B)1
İU+01302i(U+0069)1
ẞU+1E9E3ß(U+00DF)2

副本变长时偏移偏后。ȺȺȺȺȺȺ acme 原文 17 字节,转小写后 23 字节,acme 在副本里的位置是 19。拿 19 回原文取前一个字节,text[18] 已经在 17 字节之外,越界崩溃。

副本变短时偏移偏前,落进多字节字符的中间。4 个开尔文符号加上 xacme 原文共 18 字节,副本 10 字节,acme 在副本里的位置是 6。原文第 6 字节两侧都是开尔文符号 UTF-8 编码的续接字节,续接字节不算标识符字符,边界判定于是认定关键词独立出现,把陷在 xacme 里的 acme 记成一次命中。

同样的写法在别的语言里一样坏。Python 3 和 Node 上测这四个字符,UTF-8 字节数的变化方向和 Go 一致;İ 在它们那里还会从 1 个字符变成 2 个字符(i 加一个组合点),Go 只给出一个 i。同一个字符在不同运行时里变长还是变短都不一样,靠猜没有意义。

一个检测器崩溃带走整个进程

各个检测器跑在各自的协程里(协程是 Go 的并发执行单元)。协程里的 panic,调用方的 recover 接不住:

func main() {
	defer func() {
		if p := recover(); p != nil {
			fmt.Println("接住了:", p) // 不会执行
		}
	}()
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		var s []int
		_ = s[1]
	}()
	wg.Wait()
}

这段程序打印崩溃堆栈并以退出码 2 结束,接住了 一行不会出现。Go 语言规范把 recover 的作用范围限定在同一个协程内。影响面由此确定:一个检测器里的一次越界,后果是全部客户断流。

解决办法

大小写折叠只处理 A-Z

// foldASCII 只把 ASCII 大写字母转成小写,其余字节原样保留。
func foldASCII(s string) string {
	i := 0
	for i < len(s) && (s[i] < 'A' || s[i] > 'Z') {
		i++
	}
	if i == len(s) {
		return s // 没有大写字母,不分配新串
	}
	b := []byte(s)
	for ; i < len(b); i++ {
		if b[i] >= 'A' && b[i] <= 'Z' {
			b[i] += 'a' - 'A'
		}
	}
	return string(b)
}

只改 26 个字节值、其余字节原样复制,长度必然不变,副本和原文逐字节对齐。先扫一遍确认没有大写字母就原样返回,大多数文本省掉一次内存分配。

代价是非英文字母的大小写变体认不出来,Σ 和 σ 不再互相匹配。这里能接受的理由是这类关键词基本是英文标识符或中文代号,中文没有大小写。

业务上确实需要 Unicode 大小写不敏感匹配时有两条路:把定位交给正则,用 (?i) 在原文上直接匹配,偏移天然来自原文;或者在转换时记下每个输出字节对应的原文位置,用这张表把偏移映射回去。

适用范围比大小写更广。全角转半角、去掉变音符号、Unicode 规范化都可能改变长度,「在副本上算偏移、到原文上取字节」这个写法在它们身上同样会坏。

检测器崩溃就地兜住

func (e *Engine) runGuarded(ctx context.Context, capID string, det Detector, units []Unit) (r Result, err error) {
	defer func() {
		if p := recover(); p != nil {
			detail := fmt.Sprintf("%T", p)
			if re, ok := p.(runtime.Error); ok {
				detail = re.Error()
			}
			r, err = Result{}, fmt.Errorf("%w: %s", errDetectorPanic, detail)
		}
	}()
	return e.runOne(ctx, capID, det, units)
}

recover 写在协程里实际执行的那个函数上。崩溃之后按「这个检测器这次失败」记账:其余检测器照常出结果,请求照常放行,失败进结果里的失败清单,监控和告警读这份清单。

错误说明只保留运行时错误自带的文字(越界的下标这类),其他类型的 panic 值只记类型名。panic 值可能来自正在处理的用户文本,而这条错误要写进系统日志。

验证结果

关键词固定为 acme,同一组文本分别用修复前后的匹配跑一遍:

请求文本期望修复前修复后
ȺȺȺȺȺȺ acme命中崩溃:index out of range [18] with length 17命中
ȺȺȺ xacme不命中不命中不命中
4 个 U+212A 加 xacme不命中命中(误报)不命中
4 个 U+212A 加 acme命中命中命中
call acme now命中命中命中
call xacme now不命中不命中不命中
NewAcmeGateway命中命中命中

前四行进了单元测试,钉死变长和变短两个方向;后三行确认常规匹配和驼峰边界没有被这次改动碰坏。把匹配函数换回 strings.ToLower,第一行立刻复现越界。

引擎兜底另有两条测试:构造一个必定越界的检测器,断言引擎收住崩溃、其余检测器照常出结果、这次失败记成崩溃;再用一个携带文本的 panic 值,断言这段文本不会出现在错误说明里。

验证只覆盖了 ASCII 大小写折叠这一种转换。代码里其他「先转换文本再用偏移回查原文」的地方要单独排查;非英文字母的大小写匹配能力确实丢掉了,依赖这一条的需求需要另行设计。

参考资料

喜欢这篇文章?

如果这篇文章帮到了你,可以请我喝杯咖啡,支持我继续写下去。

评论