GitHub Advisoryadvisory · Go
GHSA-mqxv-9rm6-w8qc Ech0: ParseAcceptLanguage `_` separator bypass enables ~70x CPU amplification via Accept-Language header in i18n.Middleware
HIGH
Official page ↗
- Ecosystem
- Go
- Packages
- github.com/lin-snow/ech0
- Severity
- High
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
- Weaknesses
- CWE-770, CWE-772
- Reviewed
- 2026-07-14
Affected packages
• Go github.com/lin-snow/ech0: < 5.0.1 (no fix yet)
Description
Summary
Ech0's i18n middleware runs on every HTTP request and constructs a fresh `*goi18n.Localizer` from the raw `Accept-Language` header without imposing any size or shape filter. `goi18n.NewLocalizer` calls `golang.org/x/text/language.ParseAcceptLanguage` on the value internally. The underlying parser has quadratic-time behaviour on long lists of malformed language tags. The CVE-2022-32149 guard that golang.org/x/text added in v0.3.8 caps the number of `-` characters in the input at 1000, but it does not cap `_` characters even though the parser's internal scanner aliases `_` to `-` before parsing. A single unauthenticated GET request with an `Accept-Language` header built out of `_` separators burns about 1.5 seconds of server CPU on the host running Ech0; ten concurrent attackers saturate a ten-core box for the duration of the attack while consuming ~10 MiB/s of upstream bandwidth.
Affected versions
`github.com/lin-snow/Ech0` v4.8.2 and (per code inspection of `main`) earlier 4.x versions that wire the `internal/i18n.Middleware()` gin middleware on the global router without imposing their own size limit on `Accept-Language`. Verified on:
• the official `ghcr.io/lin-snow/ech0:latest` Docker image at v4.8.2 (E2E below)
• `main` at commit `451c7c10eb1f23f7525c163e83f8b39f46d5aad0` by reading `internal/i18n/i18n.go` (the middleware and `setLocaleContext` call site are unchanged)
Privilege required
Unauthenticated. The `i18n.Middleware` runs for every HTTP request including the public landing page, the public comments feed, and the unauthenticated `/api/echo/page` endpoint.
Vulnerable code
`internal/i18n/i18n.go` (blob SHA `451c7c10eb1f23f7525c163e83f8b39f46d5aad0`), the gin middleware `Middleware()` at lines 202-213:
```go
func Middleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
explicit := explicitLocaleFromRequest(ctx)
acceptLanguage := strings.TrimSpace(ctx.GetHeader("Accept-Language"))
locale := systemDefaultLocale()
if explicit != "" {
locale = ResolveLocale(explicit, acceptLanguage)
}
setLocaleContext(ctx, locale, acceptLanguage)
ctx.Next()
}
}
```
`setLocaleContext` at line 191 then calls `NewLocalizer(normalized, acceptLanguage)`:
```go
func setLocaleContext(ctx *gin.Context, locale, acceptLanguage string) {
if ctx == nil {
return
}
normalized := ResolveLocale(locale)
localizer := NewLocalizer(normalized, acceptLanguage)
ctx.Set(ContextLocaleKey, normalized)
ctx.Set(ContextLocalizerKey, localizer)
ctx.Header("Content-Language", normalized)
}
```
`NewLocalizer` is a thin wrapper around `goi18n.NewLocalizer`, which internally calls `language.ParseAcceptLanguage(lang)` for every passed string in its `parseTags` helper (see `github.com/nicksnyder/go-i18n/[email protected]/i18n/localizer.go:42-50`). So the unfiltered `acceptLanguage` reaches `language.ParseAcceptLanguage` on every request.
`ctx.GetHeader("Accept-Language")` is the unfiltered HTTP header. Go's default `net/http` `MaxHeaderBytes` is `1 setLocaleContext -> NewLocalizer).
Target: http://127.0.0.1:18300/ payload=1048576 B
warm-up header=0 B '_'=0 '-'=0 status=200 t=7.692458ms
--- measur…