Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`.

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.
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 17 additions & 4 deletions app/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -328,6 +331,16 @@ func (s *Service) findMatchingMappers(srvName string) []URLMapper {
return cachedMapper
}

// 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(strings.TrimSuffix(mapperServer, "."), host) {
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) {
Expand All @@ -336,8 +349,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(strings.TrimSuffix(mapperServer[1:], ".")) // strip the '*'
if strings.HasSuffix(strings.ToLower(host), domainPattern) {
s.cacheMappers(srvName, mapper)
return mapper
}
Expand Down
105 changes: 105 additions & 0 deletions app/discovery/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,111 @@ 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},
{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
},
}
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"},
{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) {
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 {
Expand Down
Loading