From f7d58438b08c1ff613ab2f93b5aaf01c6d692319 Mon Sep 17 00:00:00 2001 From: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:39:37 +0100 Subject: [PATCH] admin: fix host allow-list comparison to be case-insensitive (#7993) The admin host check compared r.Host against the allowed URL's Host with byte-for-byte equality. url.Parse does not normalize host case, so an allowed 'http://Example.com:2019' rejected a client Host of 'example.com:2019' with 'host not allowed', and the same happened for an uppercase variant of a lowercase entry like localhost. The Origin check got this treatment in 7973 already, the DNS rebinding Host check right next to it did not. Both read the same allowedOrigins list, so this applies the same strings.EqualFold treatment there. Regression test covers both fold directions, the default localhost entry and the negative case that must keep failing --- admin.go | 4 +- admin_checkhost_case_test.go | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 admin_checkhost_case_test.go diff --git a/admin.go b/admin.go index b367975de..b8b4cebf9 100644 --- a/admin.go +++ b/admin.go @@ -940,7 +940,9 @@ func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err er // rebinding attacks. func (h adminHandler) checkHost(r *http.Request) error { allowed := slices.ContainsFunc(h.allowedOrigins, func(u *url.URL) bool { - return r.Host == u.Host + // Host comparison is case-insensitive per RFC 3986 §3.2.2, same as + // the Origin check below; url.Parse does not normalize host case. + return strings.EqualFold(r.Host, u.Host) }) if !allowed { return APIError{ diff --git a/admin_checkhost_case_test.go b/admin_checkhost_case_test.go new file mode 100644 index 000000000..98204e336 --- /dev/null +++ b/admin_checkhost_case_test.go @@ -0,0 +1,83 @@ +// 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 caddy + +import ( + "net/http/httptest" + "net/url" + "testing" +) + +// TestAdminHandlerCheckHostCaseInsensitive is the regression test for the +// Host allow-list comparison being case-sensitive. Per RFC 3986 §3.2.2, +// host names are case-insensitive, and url.Parse does not normalize host +// case, so the comparison must fold case. This mirrors the fix applied to +// originAllowed, where an allowed 'http://Example.com:8080' rejected a +// client Host of 'example.com:8080'. +func TestAdminHandlerCheckHostCaseInsensitive(t *testing.T) { + mustParse := func(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("invalid URL %q: %v", raw, err) + } + return u + } + + cases := []struct { + name string + allowed *url.URL + host string + wantErr bool + }{ + { + name: "allow-list uppercase, request Host lowercase", + allowed: mustParse("http://Example.com:2019"), + host: "example.com:2019", + wantErr: false, + }, + { + name: "allow-list lowercase, request Host uppercase", + allowed: mustParse("http://example.com:2019"), + host: "EXAMPLE.com:2019", + wantErr: false, + }, + { + name: "default localhost entry, request Host uppercase", + allowed: mustParse("http://localhost:2019"), + host: "LOCALHOST:2019", + wantErr: false, + }, + { + name: "different host is still rejected", + allowed: mustParse("http://example.com:2019"), + host: "evil.example.org:2019", + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := adminHandler{allowedOrigins: []*url.URL{tc.allowed}} + r := httptest.NewRequest("GET", "http://"+tc.host+"/config/", nil) + err := h.checkHost(r) + if tc.wantErr && err == nil { + t.Errorf("checkHost(%q) with allowed %v = nil, want error", tc.host, tc.allowed) + } + if !tc.wantErr && err != nil { + t.Errorf("checkHost(%q) with allowed %v = %v, want nil", tc.host, tc.allowed, err) + } + }) + } +}