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

Reply via email to