From 5ee2b23d607873b79aa3965e52288b20e918e1cf Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 31 Oct 2026 02:00:57 -0500 Subject: [PATCH 1/2] fix: match hosts case-insensitively and static prefixes on escaped paths exact host lookup was case-sensitive, so ADMIN.example.com could skip an admin.example.com rule with an allowlist and match a broader wildcard or regex rule for the same upstream. A server that equals the host ignoring case now wins before any wildcard or regex server, and the *.example.com suffix check ignores case. Regex servers and the host passed to $host are unchanged. static routes compared the escaped request path with the plain assets root, so a protected "/private docs/" route never matched /private%20docs/x and a broader route served the file. The root is now escaped before the comparison; file serving still uses the plain root. README notes cover host case, escaped route paths, (?i) for case-insensitive upstreams, and reading X-Forwarded-For from the right. --- README.md | 4 ++ app/discovery/discovery.go | 20 +++++-- app/discovery/discovery_test.go | 92 +++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c9d8acdd..4408efcd 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Reproxy is a simple edge HTTP(s) server / reverse proxy supporting various provi Server (host) can be set as FQDN, i.e. `s.example.com`, `*` (catch all) or a regex. Exact match takes priority, so if there are two rules with servers `example.com` and `example\.(com|org)`, request to `example.com/some/url` will match the former. Requested url can be regex, for example `^/api/(.*)` and destination url may have regex matched groups in, i.e. `http://d.example.com:8080/$1`. For the example above `http://s.example.com/api/something?foo=bar` will be proxied to `http://d.example.com:8080/something?foo=bar`. +Exact and `*.example.com` server names match the host case-insensitively, so `Admin.Example.com` hits an `admin.example.com` rule. Regex servers match as written, so add `(?i)` when case should not matter. Route regexes match the escaped request path, where a space is `%20` and `ü` is `%C3%BC`, while static asset prefixes can be written as plain text. Route regexes are case-sensitive: when a protected route sits in front of an upstream that treats paths case-insensitively, add `(?i)` to that route. + For convenience, requests with the trailing `/` and without regex groups expanded to `/(.*)`, and destinations in those cases expanded to `/$1`. I.e. `/api/` -> `http://127.0.0.1/service` will be translated to `^/api/(.*)` -> `http://127.0.0.1/service/$1`. Before route matching, reproxy rejects with 400 a request whose decoded path contains a `.` or `..` segment, repeated slashes (`//`), a `;` or a backslash. A single trailing slash is allowed. @@ -273,6 +275,8 @@ When reproxy runs behind another proxy, a CDN or a load balancer, pass its addre The fronting proxy must append the address it received the request from to `X-Forwarded-For`, for example nginx with `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`, and must overwrite or remove client-supplied `Forwarded`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Port`, because reproxy passes those through from it as is. An incoming `X-Real-IP` is never used, so a proxy that sets only `X-Real-IP` has to be configured to set `X-Forwarded-For` too. Every host inside a trusted range can report any client address, so keep the list to the actual proxies. +Backends that read `X-Forwarded-For` themselves should do it the same way: from the right, skipping only the addresses of proxies they trust and stopping at the first untrusted one. The left-most entry may be client-supplied and must not be trusted on position alone. `X-Real-IP` can be trusted only on requests that came through reproxy. + Behind Cloudflare, list [Cloudflare's IP ranges](https://www.cloudflare.com/ips/) in `--trusted-proxy`. Without it, backends see Cloudflare's address in `X-Real-IP`, the `X-Forwarded-Proto` and `X-Forwarded-Port` set by Cloudflare are dropped (see [#172](https://github.com/umputun/reproxy/issues/172)), and per-user throttling groups clients by Cloudflare peer address instead of visitor address. The opposite function, setting outgoing header(s) supported as well. It can be useful in many cases, for example enforcing some custom CORS rules, security related headers and so on. This can be done with `--header` parameter (can be repeated) or env `HEADER`. For example, this is how it can be done with the docker compose: diff --git a/app/discovery/discovery.go b/app/discovery/discovery.go index 586942dd..e9ea50e8 100644 --- a/app/discovery/discovery.go +++ b/app/discovery/discovery.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "net/http" + "net/url" "regexp" "sort" "strconv" @@ -256,11 +257,13 @@ func (s *Service) Match(srv, src string) (res Matches) { res.Routes = append(res.Routes, MatchedRoute{Destination: dest, Alive: m.IsAlive(), Mapper: m}) } case MTStatic: - wr := m.AssetsWebRoot + // src is the escaped request path, so compare it with the escaped form of the root + root := (&url.URL{Path: m.AssetsWebRoot}).EscapedPath() + wr := root if wr != "/" { wr += "/" } - if src == m.AssetsWebRoot || strings.HasPrefix(src, wr) { + if src == root || strings.HasPrefix(src, wr) { // a default-server assets route must not override a concrete server match found in // an earlier bucket; return the concrete match instead of appending the catch-all // assets (this branch returns directly and would otherwise skip the dedup below) @@ -328,6 +331,15 @@ func (s *Service) findMatchingMappers(srvName string) []URLMapper { return cachedMapper } + // host names are case-insensitive, so a differently cased host must still hit its exact server + // before any wildcard or regex server can claim it + for mapperServer, mapper := range s.mappers { + if !isDefaultServer(mapperServer) && strings.EqualFold(mapperServer, srvName) { + s.cacheMappers(srvName, mapper) + return mapper + } + } + for mapperServer, mapper := range s.mappers { // * and "" should not be treated as regex and require exact match (above) if isDefaultServer(mapperServer) { @@ -336,8 +348,8 @@ func (s *Service) findMatchingMappers(srvName string) []URLMapper { // handle *.example.com simple patterns if strings.HasPrefix(mapperServer, "*.") { - domainPattern := mapperServer[1:] // strip the '*' - if strings.HasSuffix(srvName, domainPattern) { + domainPattern := strings.ToLower(mapperServer[1:]) // strip the '*' + if strings.HasSuffix(strings.ToLower(srvName), domainPattern) { s.cacheMappers(srvName, mapper) return mapper } diff --git a/app/discovery/discovery_test.go b/app/discovery/discovery_test.go index 142a01fc..2152b6c0 100644 --- a/app/discovery/discovery_test.go +++ b/app/discovery/discovery_test.go @@ -401,6 +401,98 @@ func TestService_MatchServerRegex(t *testing.T) { } } +func TestService_MatchServerCase(t *testing.T) { + mockProvider := &ProviderMock{ + EventsFunc: func(ctx context.Context) <-chan ProviderID { + res := make(chan ProviderID, 1) + res <- PIFile + return res + }, + ListFunc: func() ([]URLMapper, error) { + return []URLMapper{ + {Server: "admin.example.com", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8001/$1", MatchType: MTProxy}, + {Server: "*.example.com", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8002/$1", MatchType: MTProxy}, + {Server: "Mixed.Example.org", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8003/$1", MatchType: MTProxy}, + {Server: `(?i)^([a-z0-9-]+)\.example\.net$`, SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://${host.1}:8004/$1", MatchType: MTProxy}, + {Server: "admin.example.net", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8005/$1", MatchType: MTProxy}, + }, nil + }, + } + svc := NewService([]Provider{mockProvider}, time.Millisecond*100) + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + err := svc.Run(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + + tbl := []struct { + name, server, dest string + }{ + {name: "exact host", server: "admin.example.com", dest: "http://127.0.0.1:8001/x"}, + {name: "exact host in upper case", server: "ADMIN.example.com", dest: "http://127.0.0.1:8001/x"}, + {name: "exact host in mixed case, repeated", server: "ADMIN.example.com", dest: "http://127.0.0.1:8001/x"}, + {name: "exact host in mixed case", server: "Admin.Example.Com", dest: "http://127.0.0.1:8001/x"}, + {name: "wildcard host in upper case", server: "other.EXAMPLE.com", dest: "http://127.0.0.1:8002/x"}, + {name: "mixed case config key", server: "mixed.example.org", dest: "http://127.0.0.1:8003/x"}, + {name: "exact host wins over case-insensitive regex", server: "ADMIN.example.net", dest: "http://127.0.0.1:8005/x"}, + {name: "regex capture keeps request case", server: "APP.example.net", dest: "http://APP:8004/x"}, + } + for _, tt := range tbl { + t.Run(tt.name, func(t *testing.T) { + res := svc.Match(tt.server, "/x") + require.Len(t, res.Routes, 1, res.Routes) + assert.Equal(t, tt.dest, res.Routes[0].Destination) + }) + } +} + +func TestService_MatchStaticEscapedPrefix(t *testing.T) { + mockProvider := &ProviderMock{ + EventsFunc: func(ctx context.Context) <-chan ProviderID { + res := make(chan ProviderID, 1) + res <- PIFile + return res + }, + ListFunc: func() ([]URLMapper, error) { + return []URLMapper{ + {Server: "*", SrcMatch: *regexp.MustCompile("/private docs/"), Dst: "/srv/private", MatchType: MTStatic, + AssetsWebRoot: "/private docs", AssetsLocation: "/srv/private"}, + {Server: "*", SrcMatch: *regexp.MustCompile("/über/"), Dst: "/srv/uber", MatchType: MTStatic, + AssetsWebRoot: "/über", AssetsLocation: "/srv/uber"}, + {Server: "*", SrcMatch: *regexp.MustCompile("/"), Dst: "/srv/www", MatchType: MTStatic, + AssetsWebRoot: "/", AssetsLocation: "/srv/www"}, + }, nil + }, + } + svc := NewService([]Provider{mockProvider}, time.Millisecond*100) + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + err := svc.Run(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + + tbl := []struct { + name, src, dest string + }{ + {name: "escaped root", src: "/private%20docs", dest: "/private docs:/srv/private/:norm"}, + {name: "escaped child", src: "/private%20docs/secret.txt", dest: "/private docs:/srv/private/:norm"}, + {name: "adjacent prefix", src: "/private%20docsx/secret.txt", dest: "/:/srv/www/:norm"}, + {name: "escaped non-ascii child", src: "/%C3%BCber/x", dest: "/über:/srv/uber/:norm"}, + {name: "public path", src: "/public/x", dest: "/:/srv/www/:norm"}, + } + for _, tt := range tbl { + t.Run(tt.name, func(t *testing.T) { + res := svc.Match("example.com", tt.src) + require.Len(t, res.Routes, 1, res.Routes) + assert.Equal(t, MTStatic, res.MatchType) + assert.Equal(t, tt.dest, res.Routes[0].Destination) + }) + } +} + func TestService_MatchConcurrent(t *testing.T) { mockProvider := &ProviderMock{ EventsFunc: func(ctx context.Context) <-chan ProviderID { From f9acff973abc3bb06d5d34a3348d291c3b61495b Mon Sep 17 00:00:00 2001 From: Umputun Date: Sat, 31 Oct 2026 02:13:02 -0500 Subject: [PATCH 2/2] fix: treat a trailing dot in host and server names as the same host host lookup did not treat `admin.example.com` and `admin.example.com.` as equivalent. A dotted request could lose exact-rule precedence to another regex, while a dotted config key could send the plain spelling to a catch-all. Either could bypass the intended rule's auth or allowlist when the broader rule reached the same backend. Exact and *.example.com server names now ignore one trailing dot on either side. Regex servers are unchanged. --- README.md | 2 +- app/discovery/discovery.go | 11 ++++++----- app/discovery/discovery_test.go | 13 +++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4408efcd..2b16fe23 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Reproxy is a simple edge HTTP(s) server / reverse proxy supporting various provi Server (host) can be set as FQDN, i.e. `s.example.com`, `*` (catch all) or a regex. Exact match takes priority, so if there are two rules with servers `example.com` and `example\.(com|org)`, request to `example.com/some/url` will match the former. Requested url can be regex, for example `^/api/(.*)` and destination url may have regex matched groups in, i.e. `http://d.example.com:8080/$1`. For the example above `http://s.example.com/api/something?foo=bar` will be proxied to `http://d.example.com:8080/something?foo=bar`. -Exact and `*.example.com` server names match the host case-insensitively, so `Admin.Example.com` hits an `admin.example.com` rule. Regex servers match as written, so add `(?i)` when case should not matter. Route regexes match the escaped request path, where a space is `%20` and `ü` is `%C3%BC`, while static asset prefixes can be written as plain text. Route regexes are case-sensitive: when a protected route sits in front of an upstream that treats paths case-insensitively, add `(?i)` to that route. +Exact and `*.example.com` server names match the host case-insensitively and ignore one trailing dot, so `Admin.Example.com` and `admin.example.com.` hit an `admin.example.com` rule. Regex servers match as written, so add `(?i)` when case should not matter. Route regexes match the escaped request path, where a space is `%20` and `ü` is `%C3%BC`, while static asset prefixes can be written as plain text. Route regexes are case-sensitive: when a protected route sits in front of an upstream that treats paths case-insensitively, add `(?i)` to that route. For convenience, requests with the trailing `/` and without regex groups expanded to `/(.*)`, and destinations in those cases expanded to `/$1`. I.e. `/api/` -> `http://127.0.0.1/service` will be translated to `^/api/(.*)` -> `http://127.0.0.1/service/$1`. diff --git a/app/discovery/discovery.go b/app/discovery/discovery.go index e9ea50e8..1543d2e5 100644 --- a/app/discovery/discovery.go +++ b/app/discovery/discovery.go @@ -331,10 +331,11 @@ func (s *Service) findMatchingMappers(srvName string) []URLMapper { return cachedMapper } - // host names are case-insensitive, so a differently cased host must still hit its exact server - // before any wildcard or regex server can claim it + // host names are case-insensitive and "example.com." is the same host as "example.com", so such a + // spelling must still hit its exact server before any wildcard or regex server can claim it + host := strings.TrimSuffix(srvName, ".") for mapperServer, mapper := range s.mappers { - if !isDefaultServer(mapperServer) && strings.EqualFold(mapperServer, srvName) { + if !isDefaultServer(mapperServer) && strings.EqualFold(strings.TrimSuffix(mapperServer, "."), host) { s.cacheMappers(srvName, mapper) return mapper } @@ -348,8 +349,8 @@ func (s *Service) findMatchingMappers(srvName string) []URLMapper { // handle *.example.com simple patterns if strings.HasPrefix(mapperServer, "*.") { - domainPattern := strings.ToLower(mapperServer[1:]) // strip the '*' - if strings.HasSuffix(strings.ToLower(srvName), domainPattern) { + domainPattern := strings.ToLower(strings.TrimSuffix(mapperServer[1:], ".")) // strip the '*' + if strings.HasSuffix(strings.ToLower(host), domainPattern) { s.cacheMappers(srvName, mapper) return mapper } diff --git a/app/discovery/discovery_test.go b/app/discovery/discovery_test.go index 2152b6c0..7760e999 100644 --- a/app/discovery/discovery_test.go +++ b/app/discovery/discovery_test.go @@ -420,6 +420,12 @@ func TestService_MatchServerCase(t *testing.T) { Dst: "http://${host.1}:8004/$1", MatchType: MTProxy}, {Server: "admin.example.net", SrcMatch: *regexp.MustCompile("^/(.*)$"), Dst: "http://127.0.0.1:8005/$1", MatchType: MTProxy}, + {Server: "dotted.example.org.", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8006/$1", MatchType: MTProxy}, + {Server: "*.wild.example.org.", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8007/$1", MatchType: MTProxy}, + {Server: "*", SrcMatch: *regexp.MustCompile("^/(.*)$"), + Dst: "http://127.0.0.1:8009/$1", MatchType: MTProxy}, }, nil }, } @@ -440,6 +446,13 @@ func TestService_MatchServerCase(t *testing.T) { {name: "mixed case config key", server: "mixed.example.org", dest: "http://127.0.0.1:8003/x"}, {name: "exact host wins over case-insensitive regex", server: "ADMIN.example.net", dest: "http://127.0.0.1:8005/x"}, {name: "regex capture keeps request case", server: "APP.example.net", dest: "http://APP:8004/x"}, + {name: "exact host with trailing dot", server: "admin.example.com.", dest: "http://127.0.0.1:8001/x"}, + {name: "dotted config key, plain host", server: "dotted.example.org", dest: "http://127.0.0.1:8006/x"}, + {name: "dotted config key, dotted host", server: "dotted.example.org.", dest: "http://127.0.0.1:8006/x"}, + {name: "wildcard with trailing dot host", server: "other.example.com.", dest: "http://127.0.0.1:8002/x"}, + {name: "dotted wildcard key, plain host", server: "a.wild.example.org", dest: "http://127.0.0.1:8007/x"}, + {name: "dotted wildcard key, dotted host", server: "a.wild.example.org.", dest: "http://127.0.0.1:8007/x"}, + {name: "unknown host falls back to catch-all", server: "unknown.test", dest: "http://127.0.0.1:8009/x"}, } for _, tt := range tbl { t.Run(tt.name, func(t *testing.T) {