Following up with the signed debdiff for this, since the MR itself
can't show a clean "mergeable" status.
Quick explanation for that: debian/sid moved to caddy 2.11.4 back in
July, a completely different upstream release than what this backport
targets (bookworm's still-shipped 2.6.2-5), so GitLab's merge-conflict
check will always show red there regardless of the patches themselves.
There's also no debian/bookworm-security branch on this repo yet
(unlike git-lfs/libgit2), so there's nowhere to actually retarget the
MR to get a clean mergeable state.
Signed debdiff attached (caddy_2.6.2-5+deb12u1.debdiff.asc), covering
all 8 CVEs, verified against a fresh pristine checkout (quilt applies
the full series with no failures, changelog and patches match exactly
what's on the MR branch).
MR for reference:
https://salsa.debian.org/go-team/packages/caddy/-/merge_requests/3
Ivo
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
diff --git a/debian/changelog b/debian/changelog
index 7379156c..77bfb2a8 100644
- --- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,43 @@
+caddy (2.6.2-5+deb12u1) bookworm-security; urgency=high
+
+ * Non-maintainer upload by the LTS Security Team.
+ * Fix CVE-2026-27585: a literal backslash in a placeholder value used
+ in an @file glob match wasn't escaped by globSafeRepl, letting an
+ attacker-controlled value reach the glob matcher unescaped on
+ platforms where backslash isn't the path separator.
+ * Fix CVE-2026-27587: the path matcher's percent-escape-sequence
+ branch built its comparison string from the raw request path
+ instead of a lowercased copy, silently reintroducing
+ case-sensitivity the matcher is documented not to have.
+ * Fix CVE-2026-27588: the Host matcher's binary-search fast path for
+ large host lists (>100 entries) compared the raw request host
+ instead of a lowercased copy, the same case-sensitivity gap as
+ above but in the large-list path only.
+ * Fix CVE-2026-27589: the local admin API's /load endpoint could be
+ reached from a browser via a cross-origin request using
+ fetch(..., {mode: "no-cors"}), which never triggers a CORS
+ preflight; now rejected unless Origin/Sec-Fetch-Site indicate a
+ same-origin request.
+ * Fix CVE-2026-27590: splitPos() computed the FastCGI
+ SCRIPT_NAME/PATH_INFO split index on a strings.ToLower() copy of
+ the request path, then used that byte index against the
+ *original* path; Unicode case-folding can change a character's
+ byte length, corrupting the split for non-ASCII paths.
+ * Fix CVE-2026-45692: the admin /config API's array-index parser
+ accepted non-canonical decimal spellings (leading zero, leading
+ '+', trailing whitespace) that could resolve differently than the
+ canonical form used elsewhere, opening a config-index confusion.
+ * Fix CVE-2026-52845: strip client-supplied HTTP headers whose name
+ becomes invalid only after underscore-to-hyphen normalization by a
+ downstream FastCGI/CGI target, before any handler (including
+ forward_auth's copy_headers) sees the request.
+ * Fix CVE-2026-52846: the stripHTML template function's single-flag
+ tag tracker mishandled a nested '<' inside an already-open tag,
+ flushing suppressed content to the output; replaced with a
+ depth/quote-aware state machine matching upstream.
+
+ -- Ivo Marino <[email protected]> Tue, 15 Sep 2026 08:40:00 +0000
+
caddy (2.6.2-5) unstable; urgency=high
* d/caddy.install: Fix location for systemd files (Closes: #1034221)
diff --git a/debian/patches/CVE-2026-27585.patch
b/debian/patches/CVE-2026-27585.patch
new file mode 100644
index 00000000..d22b79fb
- --- /dev/null
+++ b/debian/patches/CVE-2026-27585.patch
@@ -0,0 +1,23 @@
+From: Ivo Marino <[email protected]>
+Origin: upstream,
https://github.com/caddyserver/caddy/commit/a2825c5dd952769f139a16448dc1ca1be61b6058
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-4xrr-hq4w-6vf4
+Last-Update: 2026-09-15
+Description: Sanitize backslashes in the file matcher's glob-escaping replacer
+ globSafeRepl escapes glob metacharacters (*, [, ?) in placeholder
+ values before they're used in a glob pattern, but never escaped a
+ literal backslash - on any platform where backslash isn't the path
+ separator, this let a value containing a backslash re-introduce glob
+ syntax the escaping was meant to prevent, bypassing path-related
+ security protections. One-line upstream fix, applies cleanly.
+Index: caddy-2.6.2/modules/caddyhttp/fileserver/matcher.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/fileserver/matcher.go 2026-09-15
05:30:50.537835818 +0000
++++ caddy-2.6.2/modules/caddyhttp/fileserver/matcher.go 2026-09-15
05:33:25.845603275 +0000
+@@ -661,6 +661,7 @@
+ "*", "\\*",
+ "[", "\\[",
+ "?", "\\?",
++ "\\", "\\\\",
+ )
+
+ const (
diff --git a/debian/patches/CVE-2026-27587.patch
b/debian/patches/CVE-2026-27587.patch
new file mode 100644
index 00000000..89ced279
- --- /dev/null
+++ b/debian/patches/CVE-2026-27587.patch
@@ -0,0 +1,37 @@
+From: Ivo Marino <[email protected]>
+Origin: backport,
https://github.com/caddyserver/caddy/commit/bd374ca9d72e296c9361aee76924b6540f22f0c0
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-g7pc-pc7g-h8jh
+Last-Update: 2026-09-15
+Description: Fix MatchPath escape-sequence branch skipping case normalization
+ The Path matcher is documented as case-insensitive, but when the match
+ pattern contains percent-escape sequences (%xx) it built its
+ comparison string from the raw request path characters without
+ lowercasing them, only ever lowercasing the escape sequences
+ themselves. An attacker could bypass path-based routing and any
+ access controls attached to that route by changing the casing of the
+ request path. Applied cleanly - bookworm's version of this function
+ (down to an identical trailing-'=' typo in a nearby comment) matches
+ the pre-fix upstream code exactly.
+Index: caddy-2.6.2/modules/caddyhttp/matchers.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/matchers.go
++++ caddy-2.6.2/modules/caddyhttp/matchers.go
+@@ -498,6 +510,7 @@
+ }
+
+ func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath
string) bool {
++ escapedPath = strings.ToLower(escapedPath)
+ // We would just compare the pattern against r.URL.Path,
+ // but the pattern contains %, indicating that we should
+ // compare at least some part of the path in raw/escaped
+@@ -598,8 +611,8 @@
+ // we can now treat rawpath globs (%*) as regular globs (*)
+ matchPath = strings.ReplaceAll(matchPath, "%*", "*")
+
+- // ignore error here because we can't handle it anyway=
+- matches, _ := path.Match(matchPath, sb.String())
++ // ignore error here because we can't handle it anyway
++ matches, _ := path.Match(matchPath, strings.ToLower(sb.String()))
+ return matches
+ }
+
diff --git a/debian/patches/CVE-2026-27588.patch
b/debian/patches/CVE-2026-27588.patch
new file mode 100644
index 00000000..5065f491
- --- /dev/null
+++ b/debian/patches/CVE-2026-27588.patch
@@ -0,0 +1,59 @@
+From: Ivo Marino <[email protected]>
+Origin: backport,
https://github.com/caddyserver/caddy/commit/eec32a0bb5a11651c6a7b04ce82dc50610f2b27e
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-x76f-jf84-rqj8
+Last-Update: 2026-09-15
+Description: Fix MatchHost case-sensitivity for large host lists (>100 entries)
+ The documented behavior of the Host matcher is case-insensitive, and
+ the small-list path already achieves this via strings.EqualFold. The
+ large-list fast path (a binary search, used once a config has more
+ than 100 hosts) compared raw values directly, though: Provision()
+ only lowercased a local variable used for its own duplicate-entry
+ check, never writing the lowercased form back into the matcher's own
+ slice, and Match() compared the fast path against the raw request
+ Host header. An attacker could bypass host-based routing and any
+ access controls attached to that route by changing the casing of the
+ Host header. Note: bookworm's version of MatchHost predates upstream's
+ later IDNA/ASCII-conversion refactor (added between 2.10.2 and
+ 2.11.0), so this fix is hand-adapted to the current, simpler
+ structure rather than a direct cherry-pick of the upstream commit -
+ the underlying case-normalization defect and its fix are the same.
+Index: caddy-2.6.2/modules/caddyhttp/matchers.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/matchers.go
++++ caddy-2.6.2/modules/caddyhttp/matchers.go
+@@ -248,6 +248,17 @@
+ return fmt.Errorf("host at index %d is repeated at
index %d: %s", firstI, i, h)
+ }
+ seen[h] = i
++ // Store the lowercased form back so the large-list fast path
below
++ // (an exact-match binary search over m) compares like-for-like.
++ // Host matching is documented as case-insensitive, and the slow
++ // (non-large-list) path already handles that via
++ // strings.EqualFold, so lowercasing stored values here is safe
++ // for fuzzy/wildcard entries too. Without this, the fast path's
++ // exact comparison used the original, mixed-case values while
++ // Match() below compared against the raw (also mixed-case)
++ // request host - functionally case-sensitive despite the
++ // documented case-insensitive behavior. (CVE-2026-27588)
++ m[i] = h
+ }
+
+ if m.large() {
+@@ -284,14 +295,15 @@
+ }
+
+ if m.large() {
++ reqHostLower := strings.ToLower(reqHost)
+ // fast path: locate exact match using binary search (about
100-1000x faster for large lists)
+ pos := sort.Search(len(m), func(i int) bool {
+ if m.fuzzy(m[i]) {
+ return false
+ }
+- return m[i] >= reqHost
++ return m[i] >= reqHostLower
+ })
+- if pos < len(m) && m[pos] == reqHost {
++ if pos < len(m) && m[pos] == reqHostLower {
+ return true
+ }
+ }
diff --git a/debian/patches/CVE-2026-27589.patch
b/debian/patches/CVE-2026-27589.patch
new file mode 100644
index 00000000..42f06e01
- --- /dev/null
+++ b/debian/patches/CVE-2026-27589.patch
@@ -0,0 +1,164 @@
+From: Ivo Marino <[email protected]>
+Origin: backport,
https://github.com/caddyserver/caddy/security/advisories/GHSA-879p-475x-rqh2
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-879p-475x-rqh2
+Last-Update: 2026-09-15
+Description: Fix cross-origin config application via the local admin API's
/load
+ A request made with fetch(..., {mode: "no-cors"}) never triggers a CORS
+ preflight and its response is opaque to the calling page, but the
+ request itself is still sent and processed - if the admin config
+ doesn't explicitly turn on enforce_origin, nothing rejected it, so a
+ malicious page loaded locally could still successfully call e.g.
+ POST /load and replace the entire running configuration. Detects and
+ blocks the no-cors/null-Origin browser signals directly, and treats
+ their mere presence as reason enough to run the normal origin check,
+ closing the gap without requiring every admin config to opt in.
+ .
+ This backport omits the upstream commit's other, unrelated changes
+ (a testCertMagicStorageOverride test hook, an unrelated JSON-decode
+ error-message improvement) that landed in the same release but aren't
+ part of this CVE - kept to just the security-relevant change per the
+ usual LTS minimal-diff practice. Verified with a new regression test
+ covering all four cases (plain request still reaches the handler;
+ no-cors, null-Origin, and a real disallowed Origin are all rejected).
+Index: caddy-2.6.2/admin.go
+===================================================================
+--- caddy-2.6.2.orig/admin.go
++++ caddy-2.6.2/admin.go
+@@ -755,11 +755,44 @@
+ }
+ }
+
++ // common mitigations in browser contexts (CVE-2026-27589: a request
made
++ // with fetch(..., {mode: "no-cors"}) never triggers CORS preflight and
++ // its response is opaque to the page, but the request itself is still
++ // sent and processed here - if enforceOrigin isn't explicitly turned
on,
++ // nothing before this point rejects it. Detecting and blocking the
++ // no-cors/null-Origin browser signals directly, and treating their mere
++ // presence as reason enough to run the normal origin check below,
closes
++ // that gap without requiring every admin config to opt in.)
+ if strings.Contains(r.Header.Get("Upgrade"), "websocket") {
+ // I've never been able demonstrate a vulnerability myself, but
apparently
+ // WebSocket connections originating from browsers aren't
subject to CORS
+ // restrictions, so we'll just be on the safe side
+- h.handleError(w, r, fmt.Errorf("websocket connections aren't
allowed"))
++ h.handleError(w, r, APIError{
++ HTTPStatus: http.StatusBadRequest,
++ Err: errors.New("websocket connections aren't
allowed"),
++ Message: "WebSocket connections aren't allowed.",
++ })
++ return
++ }
++ if strings.Contains(r.Header.Get("Sec-Fetch-Mode"), "no-cors") {
++ // turns out web pages can just disable the same-origin policy
(!???!?)
++ // but at least browsers let us know that's the case, holy heck
++ h.handleError(w, r, APIError{
++ HTTPStatus: http.StatusBadRequest,
++ Err: errors.New("client attempted to make
request by disabling same-origin policy using no-cors mode"),
++ Message: "Disabling same-origin restrictions is not
allowed.",
++ })
++ return
++ }
++ if r.Header.Get("Origin") == "null" {
++ // bug in Firefox in certain cross-origin situations (yikes?)
++ // (not strictly a security vuln on its own, but it's red
flaggy,
++ // since it seems to manifest in cross-origin contexts)
++ h.handleError(w, r, APIError{
++ HTTPStatus: http.StatusBadRequest,
++ Err: errors.New("invalid origin 'null'"),
++ Message: "Buggy browser is sending null Origin
header.",
++ })
+ return
+ }
+
+@@ -772,7 +805,9 @@
+ }
+ }
+
+- if h.enforceOrigin {
++ _, hasOriginHeader := r.Header["Origin"]
++ _, hasSecHeader := r.Header["Sec-Fetch-Mode"]
++ if h.enforceOrigin || hasOriginHeader || hasSecHeader {
+ // cross-site mitigation
+ origin, err := h.checkOrigin(r)
+ if err != nil {
+Index: caddy-2.6.2/admin_test.go
+===================================================================
+--- caddy-2.6.2.orig/admin_test.go
++++ caddy-2.6.2/admin_test.go
+@@ -17,6 +17,7 @@
+ import (
+ "encoding/json"
+ "fmt"
++ "net/http/httptest"
+ "net/http"
+ "reflect"
+ "sync"
+@@ -193,3 +194,64 @@
+ Load(testCfg, true)
+ }
+ }
++
++// TestAdminServeHTTPBrowserOriginMitigations covers CVE-2026-27589: a
++// cross-origin fetch() with mode:"no-cors" never triggers a CORS preflight
++// and its response is opaque to the calling page, but the request itself is
++// still sent and, before this fix, would reach h.mux.ServeHTTP() unless the
++// admin config explicitly set enforce_origin - meaning a malicious page
++// could still successfully call e.g. POST /load with the default config.
++func TestAdminServeHTTPBrowserOriginMitigations(t *testing.T) {
++ mux := http.NewServeMux()
++ reached := false
++ mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { reached
= true })
++
++ newHandler := func() adminHandler {
++ reached = false
++ return adminHandler{mux: mux} // enforceOrigin left false, the
vulnerable default
++ }
++
++ for _, test := range []struct {
++ name string
++ reqHeaders map[string]string
++ wantStatus int
++ wantReach bool
++ }{
++ {
++ name: "plain request with no browser signals
reaches the mux",
++ reqHeaders: nil,
++ wantStatus: http.StatusOK,
++ wantReach: true,
++ },
++ {
++ name: "Sec-Fetch-Mode: no-cors is rejected
outright",
++ reqHeaders: map[string]string{"Sec-Fetch-Mode":
"no-cors"},
++ wantStatus: http.StatusBadRequest,
++ wantReach: false,
++ },
++ {
++ name: "Origin: null is rejected outright",
++ reqHeaders: map[string]string{"Origin": "null"},
++ wantStatus: http.StatusBadRequest,
++ wantReach: false,
++ },
++ {
++ name: "a real Origin header with no matching
allowed origin is rejected by the now-implicit origin check",
++ reqHeaders: map[string]string{"Origin":
"https://evil.example.com"},
++ wantStatus: http.StatusForbidden,
++ wantReach: false,
++ },
++ } {
++ t.Run(test.name, func(t *testing.T) {
++ h := newHandler()
++ req := httptest.NewRequest(http.MethodPost, "/load",
nil)
++ for k, v := range test.reqHeaders {
++ req.Header.Set(k, v)
++ }
++ rec := httptest.NewRecorder()
++ h.serveHTTP(rec, req)
++ if rec.Code != test.wantStatus {
++ t.Errorf("expected status %d, got %d",
test.wantStatus, rec.Code)
++ }
++ if reached != test.wantReach {
++ t.Errorf("expected mux reached=%v, got %v",
test.wantReach, reached)
++ }
++ })
++ }
++}
diff --git a/debian/patches/CVE-2026-27590.patch
b/debian/patches/CVE-2026-27590.patch
new file mode 100644
index 00000000..499702ce
- --- /dev/null
+++ b/debian/patches/CVE-2026-27590.patch
@@ -0,0 +1,259 @@
+From: Ivo Marino <[email protected]>
+Origin: backport,
https://github.com/caddyserver/caddy/commit/fb324331f40782ac7a48d83f591c2bb7615d7eed
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-5r3v-vc8m-m96g
+Last-Update: 2026-09-15
+Description: Fix Unicode-unsafe FastCGI split_path index computation
+ splitPos() computed the split index on a strings.ToLower() copy of
+ the whole request path, then used that byte index to slice the
+ *original* path. That is unsafe for Unicode: ToLower() can change a
+ character's UTF-8 byte length (some code points shrink or expand when
+ case-folded - e.g. the Kelvin sign U+212A is 3 bytes in UTF-8 but
+ lowercases to plain ASCII "k", 1 byte). An index found in the
+ shorter/longer lowercased copy does not necessarily land on the same
+ byte offset in the original string, so Caddy could derive an
+ incorrect SCRIPT_NAME/SCRIPT_FILENAME and PATH_INFO, potentially
+ causing a request containing ".php" to execute a different on-disk
+ file than the one intended.
+ .
+ Rewritten to compare byte-by-byte directly against the real path (no
+ separate lowercased copy to desynchronize from), ASCII-folding only
+ the single byte under comparison; falls back to a real Unicode-aware,
+ case-insensitive search (golang.org/x/text/search) only when the
+ request path itself contains a non-ASCII byte. t.SplitPath entries
+ are validated as ASCII-only and normalized to lowercase once in
+ Provision(), matching upstream's approach.
+ .
+ This is the only one of the caddy CVEs backported this round that
+ needs a new Go dependency at build time - checked first that it's
+ actually available: golang-golang-x-text-dev is already a Build-
+ Depends for this package, and the version in bookworm (0.7.0-1)
+ already provides the search/language subpackages this fix needs, so
+ no Build-Depends version bump was necessary.
+ .
+ Verified with two new regression tests: one directly reproducing the
+ Kelvin-sign corruption (confirmed it actually fails against the
+ unpatched code - splitPos returned 6 instead of the correct 8 - before
+ confirming it passes with the fix), and one confirming the plain-ASCII
+ case-insensitive fast path (the common case) is unaffected.
+Index: caddy-2.6.2/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
++++ caddy-2.6.2/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+@@ -16,6 +16,7 @@
+
+ import (
+ "crypto/tls"
++ "errors"
+ "fmt"
+ "net"
+ "net/http"
+@@ -23,6 +24,10 @@
+ "strconv"
+ "strings"
+ "time"
++ "unicode/utf8"
++
++ "golang.org/x/text/language"
++ "golang.org/x/text/search"
+
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
+@@ -33,6 +38,11 @@
+ "github.com/caddyserver/caddy/v2"
+ )
+
++// ErrInvalidSplitPath is returned when a configured split_path entry
++// contains non-ASCII characters (see CVE-2026-27590 - splitPos below
++// relies on t.SplitPath entries already being lowercase ASCII).
++var ErrInvalidSplitPath = errors.New("split path contains non-ASCII
characters")
++
+ var noopLogger = zap.NewNop()
+
+ func init() {
+@@ -109,6 +119,27 @@
+ t.DialTimeout = caddy.Duration(3 * time.Second)
+ }
+
++ // Split paths can only contain ASCII characters; comparison is
++ // case-insensitive, so normalize to lowercase once here rather than
++ // on every request (see CVE-2026-27590 and splitPos below).
++ var b strings.Builder
++ for i, split := range t.SplitPath {
++ b.Grow(len(split))
++ for j := 0; j < len(split); j++ {
++ c := split[j]
++ if c >= utf8.RuneSelf {
++ return ErrInvalidSplitPath
++ }
++ if 'A' <= c && c <= 'Z' {
++ b.WriteByte(c + 'a' - 'A')
++ } else {
++ b.WriteByte(c)
++ }
++ }
++ t.SplitPath[i] = b.String()
++ b.Reset()
++ }
++
+ return nil
+ }
+
+@@ -372,8 +403,28 @@
+ return env, nil
+ }
+
++var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
++
+ // splitPos returns the index where path should
+ // be split based on t.SplitPath.
++//
++// CVE-2026-27590: the previous implementation computed the split index on
++// a strings.ToLower() copy of the whole path and then used that byte index
++// to slice the *original* path. That is unsafe for Unicode: ToLower() can
++// change a character's UTF-8 byte length (e.g. some code points expand
++// when lowercased), so an index found in the lowercased copy does not
++// necessarily land on the same byte offset in the original string,
++// letting Caddy derive an incorrect SCRIPT_NAME/PATH_INFO split and
++// potentially execute a different on-disk file than intended.
++//
++// This version compares byte-by-byte directly against the real path (no
++// separate lowercased copy to desynchronize from), ASCII-folding only the
++// single byte under comparison. t.SplitPath entries are already
++// lowercase ASCII (validated and normalized in Provision()), so this
++// fast path only needs to fold the request path's own case. If the
++// request path itself contains a non-ASCII byte, ASCII-only comparison
++// can't safely case-fold it, so this falls back to a real Unicode-aware,
++// case-insensitive search for the rest of that split attempt.
+ func (t Transport) splitPos(path string) int {
+ // TODO: from v1...
+ // if httpserver.CaseSensitivePath {
+@@ -383,10 +434,43 @@
+ return 0
+ }
+
+- lowerPath := strings.ToLower(path)
++ pathLen := len(path)
++
+ for _, split := range t.SplitPath {
+- if idx := strings.Index(lowerPath, strings.ToLower(split)); idx
> -1 {
+- return idx + len(split)
++ splitLen := len(split)
++
++ for i := 0; i < pathLen; i++ {
++ if path[i] >= utf8.RuneSelf {
++ if _, end :=
splitSearchNonASCII.IndexString(path, split); end > -1 {
++ return end
++ }
++ break
++ }
++
++ if i+splitLen > pathLen {
++ continue
++ }
++
++ match := true
++ for j := 0; j < splitLen; j++ {
++ c := path[i+j]
++ if c >= utf8.RuneSelf {
++ if _, end :=
splitSearchNonASCII.IndexString(path, split); end > -1 {
++ return end
++ }
++ break
++ }
++ if 'A' <= c && c <= 'Z' {
++ c += 'a' - 'A'
++ }
++ if c != split[j] {
++ match = false
++ break
++ }
++ }
++ if match {
++ return i + splitLen
++ }
+ }
+ }
+ return -1
+Index: caddy-2.6.2/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+===================================================================
+--- /dev/null
++++ caddy-2.6.2/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+@@ -0,0 +1,78 @@
++// Copyright 2015 Matthew Holt and The Caddy Authors
++//
++// Licensed under the Apache License, Version 2.0 (the "License");
++// you may not use this file except in compliance with the License.
++// You may obtain a copy of the License at
++//
++// http://www.apache.org/licenses/LICENSE-2.0
++//
++// Unless required by applicable law or agreed to in writing, software
++// distributed under the License is distributed on an "AS IS" BASIS,
++// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++// See the License for the specific language governing permissions and
++// limitations under the License.
++
++package fastcgi
++
++import (
++ "testing"
++
++ "github.com/caddyserver/caddy/v2"
++)
++
++// TestSplitPosUnicodeCaseFolding covers CVE-2026-27590. The old
++// implementation computed the split index on a strings.ToLower() copy of
++// the whole path, then sliced the *original* path at that byte index.
++// That is unsafe whenever lowercasing changes a character's UTF-8 byte
++// length - the Kelvin sign U+212A ("K") is 3 bytes in UTF-8 but
++// lowercases to the plain ASCII "k" (1 byte), shrinking the lowercased
++// copy by 2 bytes relative to the original for every occurrence before
++// the split point.
++func TestSplitPosUnicodeCaseFolding(t *testing.T) {
++ tr := Transport{SplitPath: []string{".php"}}
++ if err := tr.Provision(caddy.Context{}); err != nil {
++ t.Fatalf("Provision: %v", err)
++ }
++
++ // "/K.php/info" - Kelvin sign, then ".php/info".
++ // Byte layout: '/' (1) + KELVIN SIGN (3 bytes: 0xE2 0x84 0xAA) +
".php/info" (9 bytes).
++ reqPath := "/K.php/info"
++
++ pos := tr.splitPos(reqPath)
++
++ wantPos := len("/K.php") // correct split: right after ".php"
++ if pos != wantPos {
++ t.Fatalf("splitPos(%q) = %d, want %d (SCRIPT_NAME/PATH_INFO
would be wrong otherwise)", reqPath, pos, wantPos)
++ }
++
++ scriptName := reqPath[:pos]
++ pathInfo := reqPath[pos:]
++ if scriptName != "/K.php" {
++ t.Errorf("SCRIPT_NAME = %q, want %q", scriptName, "/K.php")
++ }
++ if pathInfo != "/info" {
++ t.Errorf("PATH_INFO = %q, want %q", pathInfo, "/info")
++ }
++}
++
++// TestSplitPosASCIICaseInsensitive is a sanity check that the plain-ASCII,
++// case-insensitive fast path (the common case) still works as before.
++func TestSplitPosASCIICaseInsensitive(t *testing.T) {
++ tr := Transport{SplitPath: []string{".php"}}
++ if err := tr.Provision(caddy.Context{}); err != nil {
++ t.Fatalf("Provision: %v", err)
++ }
++
++ for _, test := range []struct {
++ path string
++ want int
++ }{
++ {path: "/index.php/foo", want: len("/index.php")},
++ {path: "/index.PHP/foo", want: len("/index.PHP")},
++ {path: "/no-match-here", want: -1},
++ } {
++ if got := tr.splitPos(test.path); got != test.want {
++ t.Errorf("splitPos(%q) = %d, want %d", test.path, got,
test.want)
++ }
++ }
++}
diff --git a/debian/patches/CVE-2026-45692.patch
b/debian/patches/CVE-2026-45692.patch
new file mode 100644
index 00000000..b06c798d
- --- /dev/null
+++ b/debian/patches/CVE-2026-45692.patch
@@ -0,0 +1,145 @@
+From: Ivo Marino <[email protected]>
+Origin: backport, https://github.com/caddyserver/caddy/pull/7592
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-x5w9-xh9r-mvfc
+Last-Update: 2026-09-15
+Description: Reject non-canonical array-index strings in the admin /config API
+ unsyncedConfigAccess() parsed array indices with plain strconv.Atoi(),
+ which accepts several non-canonical decimal spellings of the same
+ integer (a leading zero, a leading '+', trailing whitespace). Two
+ different callers checking authorization for one index spelling and
+ then traversing using a different (but Atoi-equivalent) spelling of
+ the same logical index could disagree about which config-tree element
+ a request actually refers to, letting a path authorized for one
+ config object resolve to a different one during traversal.
+ parseCanonicalArrayIndex() only accepts an index string that already
+ is its own canonical decimal form (round-trips through strconv.Itoa
+ unchanged).
+ .
+ This backport is scoped to just the array-index parsing fix - the
+ upstream PR this originates from also includes a separate,
+ unrelated fix for a different, not-in-scope CVE
+ (GHSA-gx7w-56w6-g48x, prefix-based path matching on PKI admin
+ endpoints) and an unrelated internal refactor (newAdminHandler's
+ signature, router provisioning order, a different header-logging
+ type). None of that is needed for this CVE and is kept out per the
+ usual LTS minimal-diff practice.
+Index: caddy-2.6.2/admin.go
+===================================================================
+--- caddy-2.6.2.orig/admin.go
++++ caddy-2.6.2/admin.go
+@@ -1059,6 +1059,28 @@
+ return nil
+ }
+
++// parseCanonicalArrayIndex parses idx as an array index, rejecting any
++// string that isn't the canonical decimal representation of the resulting
++// int (e.g. "01", "+1", "1 ", or extra leading zeros) - strconv.Atoi alone
++// accepts some of these non-canonical forms, and the traversal logic below
++// treats mismatched-but-equivalent index strings as pointing at different
++// config-tree locations depending on which representation the config
++// currently uses (see CVE-2026-45692). Rejecting anything that doesn't
++// round-trip exactly closes that ambiguity.
++func parseCanonicalArrayIndex(idx string) (int, error) {
++ if idx == "" {
++ return 0, fmt.Errorf("empty index")
++ }
++ i, err := strconv.Atoi(idx)
++ if err != nil {
++ return 0, err
++ }
++ if strconv.Itoa(i) != idx {
++ return 0, fmt.Errorf("non-canonical array index")
++ }
++ return i, nil
++}
++
+ // unsyncedConfigAccess traverses into the current config and performs
+ // the operation at path according to method, using body and out as
+ // needed. This is a low-level, unsynchronized function; most callers
+@@ -1117,7 +1139,7 @@
+ var idx int
+ if method != http.MethodPost {
+ idxStr := parts[len(parts)-1]
+- idx, err = strconv.Atoi(idxStr)
++ idx, err =
parseCanonicalArrayIndex(idxStr)
+ if err != nil {
+ return fmt.Errorf("[%s] invalid
array index '%s': %v",
+ path, idxStr, err)
+@@ -1209,7 +1231,7 @@
+ }
+
+ case []any:
+- partInt, err := strconv.Atoi(part)
++ partInt, err := parseCanonicalArrayIndex(part)
+ if err != nil {
+ return fmt.Errorf("[/%s] invalid array index
'%s': %v",
+ strings.Join(parts[:i+1], "/"), part,
err)
+Index: caddy-2.6.2/admin_test.go
+===================================================================
+--- caddy-2.6.2.orig/admin_test.go
++++ caddy-2.6.2/admin_test.go
+@@ -17,8 +17,9 @@
+ import (
+ "encoding/json"
+ "fmt"
+- "net/http/httptest"
++ "io"
+ "net/http"
++ "net/http/httptest"
+ "reflect"
+ "sync"
+ "testing"
+@@ -130,6 +131,45 @@
+ }
+ }
+
++// TestUnsyncedConfigAccessRejectsNonCanonicalArrayIndex covers
++// CVE-2026-45692: strconv.Atoi accepts non-canonical decimal strings (a
++// leading zero, a leading '+', trailing whitespace) that don't round-trip
++// back to the same string. Two different index spellings that Atoi both
++// parse as the same int let the array-index authorization layer and the
++// config traversal layer disagree about which element a request actually
++// refers to. parseCanonicalArrayIndex rejects any index string that isn't
++// already in its own canonical form.
++func TestUnsyncedConfigAccessRejectsNonCanonicalArrayIndex(t *testing.T) {
++ // self-contained: (re)initializes the raw config at this path directly,
++ // same technique the first case of TestUnsyncedConfigAccess uses, so
++ // this doesn't depend on running before/after that test.
++ err := unsyncedConfigAccess("POST", rawConfigKey, []byte(`{"list":
["a", "b", "c"]}`), nil)
++ if err != nil {
++ t.Fatalf("setting up base config: %v", err)
++ }
++
++ for _, test := range []struct {
++ name string
++ index string
++ shouldErr bool
++ }{
++ {name: "canonical index is accepted", index: "1", shouldErr:
false},
++ {name: "leading zero is rejected", index: "01", shouldErr:
true},
++ {name: "leading plus is rejected", index: "+1", shouldErr:
true},
++ {name: "trailing space is rejected", index: "1 ", shouldErr:
true},
++ } {
++ t.Run(test.name, func(t *testing.T) {
++ err := unsyncedConfigAccess("GET",
rawConfigKey+"/list/"+test.index, nil, io.Discard)
++ if test.shouldErr && err == nil {
++ t.Errorf("expected an error for index %q, got
none", test.index)
++ }
++ if !test.shouldErr && err != nil {
++ t.Errorf("expected no error for index %q, got:
%v", test.index, err)
++ }
++ })
++ }
++}
++
+ // TestLoadConcurrent exercises Load under concurrent conditions
+ // and is most useful under test with `-race` enabled.
+ func TestLoadConcurrent(t *testing.T) {
+@@ -259,3 +299,7 @@
+ }
+ if reached != test.wantReach {
+ t.Errorf("expected mux reached=%v, got %v",
test.wantReach, reached)
++ }
++ })
++ }
++}
diff --git a/debian/patches/CVE-2026-52845.patch
b/debian/patches/CVE-2026-52845.patch
new file mode 100644
index 00000000..1cfd7549
- --- /dev/null
+++ b/debian/patches/CVE-2026-52845.patch
@@ -0,0 +1,142 @@
+From: Ivo Marino <[email protected]>
+Origin: vendor
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-f59h-q822-g45g
+Last-Update: 2026-09-15
+Description: Strip underscore-named headers before any handler sees the request
+ forward_auth's copy_headers directive deletes the exact, hyphenated
+ client-supplied identity header before copying the trusted value from
+ the auth gateway's response. If the request later goes through
+ php_fastcgi, headers get normalized into CGI-style variables by
+ replacing '-' with '_' - so a client that sends an underscore alias
+ of the same logical header name (e.g. "Remote_User" instead of
+ "Remote-User") survives copy_headers' delete step untouched, and
+ becomes indistinguishable from the trusted header once inside the
+ backend.
+ .
+ The real upstream fix for this bundles it together with an unrelated,
+ separate CVE (dot-header handling, GHSA-49wc-4hcv-v58q, not affecting
+ bookworm's version) inside a large new opt-in filtering feature
+ (ExpectedUnderscoreHeaders/ExpectedDotHeaders) that doesn't exist in
+ bookworm's 2.6.2 at all. Backporting that whole feature would go well
+ beyond the minimal-diff practice expected of an LTS security update.
+ This patch instead adds just upstream's own *default* behavior (used
+ when no allowlist is configured): unconditionally strip any header
+ containing an underscore, as early as possible in the request
+ pipeline, before any handler (forward_auth, fastcgi, reverse_proxy,
+ etc.) ever sees it. There is no equivalent allowlist to opt back in
+ with in this backport - a deliberate simplification, not an
+ oversight, matching the same disclosed-simplification approach
+ already used for a couple of the libgit2 LTS patches in this same
+ round of updates.
+ .
+ The new stripUnderscoreHeaders() method is a straight extraction of
+ the same loop upstream runs inline in serveHTTP() (see the "no
+ allowlist configured" branch) - not independently invented logic - to
+ keep it directly unit-testable without needing to stand up an entire
+ running Server.
+Index: caddy-2.6.2/modules/caddyhttp/server.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/server.go
++++ caddy-2.6.2/modules/caddyhttp/server.go
+@@ -216,6 +216,8 @@
+ repl := caddy.NewReplacer()
+ r = PrepareRequest(r, repl, w, s)
+
++ s.stripUnderscoreHeaders(r)
++
+ // encode the request for logging purposes before
+ // it enters any handler chain; this is necessary
+ // to capture the original request in case it gets
+@@ -342,6 +344,29 @@
+ }
+ }
+
++// stripUnderscoreHeaders drops any request header whose name contains an
++// underscore. Once a backend such as FastCGI/CGI/FrankenPHP rewrites `-` to
++// `_` when building CGI-style variable names, a client-supplied underscore
++// alias for a hyphenated header name collides with the legitimate
++// hyphenated header inside that backend. This lets a client-controlled
++// header survive forward_auth's copy_headers deletion step (which only
++// deletes the exact, hyphenated header name) and then be treated as the
++// trusted value the auth gateway set, once php_fastcgi folds both onto the
++// same variable (CVE-2026-52845). Called as early as possible in
++// ServeHTTP, before any handler (forward_auth, fastcgi, reverse_proxy,
++// etc.) ever sees the request.
++func (s *Server) stripUnderscoreHeaders(r *http.Request) {
++ for k := range r.Header {
++ if strings.ContainsRune(k, '_') {
++ delete(r.Header, k)
++
++ if c := s.logger.Check(zapcore.DebugLevel, "dropping
header containing underscore"); c != nil {
++ c.Write(zap.String("header", k))
++ }
++ }
++ }
++}
++
+ // wrapPrimaryRoute wraps stack (a compiled middleware handler chain)
+ // in s.enforcementHandler which performs crucial security checks, etc.
+ func (s *Server) wrapPrimaryRoute(stack Handler) Handler {
+Index: caddy-2.6.2/modules/caddyhttp/server_test.go
+===================================================================
+--- /dev/null
++++ caddy-2.6.2/modules/caddyhttp/server_test.go
+@@ -0,0 +1,58 @@
++// Copyright 2015 Matthew Holt and The Caddy Authors
++//
++// Licensed under the Apache License, Version 2.0 (the "License");
++// you may not use this file except in compliance with the License.
++// You may obtain a copy of the License at
++//
++// http://www.apache.org/licenses/LICENSE-2.0
++//
++// Unless required by applicable law or agreed to in writing, software
++// distributed under the License is distributed on an "AS IS" BASIS,
++// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++// See the License for the specific language governing permissions and
++// limitations under the License.
++
++package caddyhttp
++
++import (
++ "net/http"
++ "net/http/httptest"
++ "testing"
++
++ "go.uber.org/zap"
++)
++
++// TestStripUnderscoreHeaders covers CVE-2026-52845: forward_auth's
++// copy_headers only deletes the exact, hyphenated client-supplied identity
++// header before copying the trusted value from the auth gateway. If the
++// request later goes through php_fastcgi, headers get normalized into CGI
++// variables by replacing '-' with '_', so a client-sent underscore alias of
++// the same logical header name (e.g. "Remote_User" instead of
++// "Remote-User") survives copy_headers' delete step untouched and becomes
++// indistinguishable from the trusted header once inside the backend.
++// stripUnderscoreHeaders removes any header containing an underscore as
++// early as possible, before any handler sees the request, closing that gap.
++func TestStripUnderscoreHeaders(t *testing.T) {
++ s := &Server{logger: zap.NewNop()}
++
++ req := httptest.NewRequest(http.MethodGet, "/", nil)
++ req.Header.Set("Remote-User", "legit-value-from-auth-gateway")
++ req.Header.Set("Remote_User", "attacker-supplied-alias")
++ req.Header.Set("X-Forwarded-For", "1.2.3.4")
++ req.Header.Set("Content-Type", "application/json")
++
++ s.stripUnderscoreHeaders(req)
++
++ if got := req.Header.Get("Remote_User"); got != "" {
++ t.Errorf("expected underscore-named header to be stripped,
still got %q", got)
++ }
++ if got := req.Header.Get("Remote-User"); got !=
"legit-value-from-auth-gateway" {
++ t.Errorf("expected legitimate hyphenated header to survive, got
%q", got)
++ }
++ if got := req.Header.Get("X-Forwarded-For"); got != "1.2.3.4" {
++ t.Errorf("expected unrelated header to survive, got %q", got)
++ }
++ if got := req.Header.Get("Content-Type"); got != "application/json" {
++ t.Errorf("expected unrelated header to survive, got %q", got)
++ }
++}
diff --git a/debian/patches/CVE-2026-52846.patch
b/debian/patches/CVE-2026-52846.patch
new file mode 100644
index 00000000..ec7efb45
- --- /dev/null
+++ b/debian/patches/CVE-2026-52846.patch
@@ -0,0 +1,138 @@
+From: Ivo Marino <[email protected]>
+Origin: backport,
https://github.com/caddyserver/caddy/commit/e2eee6a7fce366321294c9c2a79f3146891dcbdf
+Bug:
https://github.com/caddyserver/caddy/security/advisories/GHSA-vcc4-2c75-vc9v
+Last-Update: 2026-09-15
+Description: Fix stripHTML template function bypass via malformed HTML
+ The naive single-flag "inTag" tracker treated a second, nested '<' as
+ a "false start" that flushed the still-open tag's contents to the
+ output instead of keeping them suppressed - e.g.
+ "<<>img src=x onerror=alert()>" bypassed tag stripping entirely,
+ risking client-side XSS wherever stripHTML's output is later rendered
+ as HTML. Rewritten to track nesting depth (and quoted-attribute state)
+ instead of a single flag, matching the upstream fix applied cleanly
+ (bookworm's version of this function was otherwise identical to the
+ pre-fix upstream code). Verified against the real upstream test
+ cases, including the exact CVE proof-of-concept string.
+Index: caddy-2.6.2/modules/caddyhttp/templates/tplcontext.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/templates/tplcontext.go
++++ caddy-2.6.2/modules/caddyhttp/templates/tplcontext.go
+@@ -269,35 +269,40 @@
+ return host, nil
+ }
+
+-// funcStripHTML returns s without HTML tags. It is fairly naive
+-// but works with most valid HTML inputs.
++// funcStripHTML returns s without HTML tags. Similar to PHP's strip_tags()
++//
++// CVE-2026-52846: the previous naive single-flag "inTag" tracker could be
++// bypassed with malformed HTML such as "<<>img src=x onerror=...>" - a
++// second, nested '<' was treated as a "false start" that flushed the
++// still-open tag's contents to the output instead of keeping them
++// suppressed. This version tracks nesting depth instead of a single flag,
++// so any text between an outermost '<' and its matching '>' is dropped
++// regardless of additional '<'/'>' characters nested inside.
+ func (TemplateContext) funcStripHTML(s string) string {
+ var buf bytes.Buffer
+- var inTag, inQuotes bool
+- var tagStart int
+- for i, ch := range s {
+- if inTag {
+- if ch == '>' && !inQuotes {
+- inTag = false
+- } else if ch == '<' && !inQuotes {
+- // false start
+- buf.WriteString(s[tagStart:i])
+- tagStart = i
+- } else if ch == '"' {
+- inQuotes = !inQuotes
++ depth := 0
++ var quoteChar rune
++ for _, ch := range s {
++ switch {
++ case depth > 0 && quoteChar == 0 && (ch == '"' || ch == '\''):
++ // entering a quoted attribute value
++ quoteChar = ch
++ case depth > 0 && ch == quoteChar:
++ // leaving a quoted attribute value
++ quoteChar = 0
++ case ch == '<' && quoteChar == 0:
++ depth++
++ case ch == '>' && quoteChar == 0:
++ if depth > 0 {
++ depth--
++ } else {
++ buf.WriteRune(ch) // stray '>' with no opening
'<', keep it
++ }
++ default:
++ if depth == 0 {
++ buf.WriteRune(ch)
+ }
+- continue
+- }
+- if ch == '<' {
+- inTag = true
+- tagStart = i
+- continue
+ }
+- buf.WriteRune(ch)
+- }
+- if inTag {
+- // false start
+- buf.WriteString(s[tagStart:])
+ }
+ return buf.String()
+ }
+Index: caddy-2.6.2/modules/caddyhttp/templates/tplcontext_test.go
+===================================================================
+--- caddy-2.6.2.orig/modules/caddyhttp/templates/tplcontext_test.go
++++ caddy-2.6.2/modules/caddyhttp/templates/tplcontext_test.go
+@@ -422,14 +422,44 @@
+ expect: `h1`,
+ },
+ {
+- // tags not closed
++ // unclosed tag - trailing text must be stripped, not
emitted
+ input: `<h1`,
+- expect: `<h1`,
++ expect: ``,
+ },
+ {
+- // false start
++ // false start - second '<' increments depth, single
'>' only closes one level
+ input: `<h1<b>hi`,
+- expect: `<h1hi`,
++ expect: ``,
++ },
++ {
++ // XSS bypass via double opening bracket
(CVE-2026-52846)
++ input: `<<>img src=x onerror=alert('XSS')>`,
++ expect: ``,
++ },
++ {
++ // stacked angle brackets (PHP strip_tags parity)
++ input: `<<<<<>>>>><b>hello</b>`,
++ expect: `hello`,
++ },
++ {
++ // unclosed tag strips trailing text
++ input: `hello <world`,
++ expect: `hello `,
++ },
++ {
++ // '>' inside double-quoted attribute must not close
tag early
++ input: `<a href="foo>bar">text</a>`,
++ expect: `text`,
++ },
++ {
++ // '>' inside single-quoted attribute must not close
tag early
++ input: `<a href='foo>bar'>text</a>`,
++ expect: `text`,
++ },
++ {
++ // stray '>' with no opening '<' is preserved
++ input: `stray > bracket`,
++ expect: `stray > bracket`,
+ },
+ } {
+ actual := tplContext.funcStripHTML(test.input)
diff --git a/debian/patches/series b/debian/patches/series
index ad3b8841..df5b0826 100644
- --- a/debian/patches/series
+++ b/debian/patches/series
@@ -4,3 +4,11 @@
0004-Add-short-description-for-caddy-command.patch
0005-Remove-obsolote-subcommands.patch
0006-Add-variable-for-reproducible-manpage-time-stamps.patch
+CVE-2026-27585.patch
+CVE-2026-27587.patch
+CVE-2026-27588.patch
+CVE-2026-27589.patch
+CVE-2026-27590.patch
+CVE-2026-45692.patch
+CVE-2026-52845.patch
+CVE-2026-52846.patch
-----BEGIN PGP SIGNATURE-----
iHUEARYKAB0WIQS82VxZRu76KBuHHJUjLGgvIGfW/wUCaqudTgAKCRAjLGgvIGfW
/1b4AP9YLpel79mk73pRp3Z6eYBcC0dgrz/hX2FDqH8/cmK5WQD/dlGXOB+Hw3Uu
NicrbyYRiJpzWJA1J7QeEoevyY0jhwM=
=LR8J
-----END PGP SIGNATURE-----