diff --git a/README.md b/README.md index 0b0586301d1165f92b29841a053188ccc8596f9c..1a2d59acbf7c1129e185ce34a8ca45fc0ca425be 100644 --- a/README.md +++ b/README.md @@ -11,24 +11,17 @@ A reasonably complete and well-tested golang port of [Kenneth Reitz][kr]'s ## Usage +### Docker -### Configuration - -go-httpbin can be configured via either command line arguments or environment -variables (or a combination of the two): - -| Argument| Env var | Documentation | Default | -| - | - | - | - | -| `-host` | `HOST` | Host to listen on | "0.0.0.0" | -| `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | | -| `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | | -| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 | -| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s | -| `-port` | `PORT` | Port to listen on | 8080 | -| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false | +Docker images are published to [Docker Hub][docker-hub]: -**Note:** Command line arguments take precedence over environment variables. +```bash +# Run http server +$ docker run -P mccutchen/go-httpbin +# Run https server +$ docker run -e HTTPS_CERT_FILE='/tmp/server.crt' -e HTTPS_KEY_FILE='/tmp/server.key' -p 8080:8080 -v /tmp:/tmp mccutchen/go-httpbin +``` ### Standalone binary @@ -48,18 +41,6 @@ $ openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650 $ go-httpbin -host 127.0.0.1 -port 8081 -https-cert-file ./server.crt -https-key-file ./server.key ``` -### Docker - -Docker images are published to [Docker Hub][docker-hub]: - -```bash -# Run http server -$ docker run -P mccutchen/go-httpbin - -# Run https server -$ docker run -e HTTPS_CERT_FILE='/tmp/server.crt' -e HTTPS_KEY_FILE='/tmp/server.key' -p 8080:8080 -v /tmp:/tmp mccutchen/go-httpbin -``` - ### Unit testing helper library The `github.com/mccutchen/go-httpbin/httpbin/v2` package can also be used as a @@ -95,16 +76,26 @@ func TestSlowResponse(t *testing.T) { } ``` +### Configuration -## Custom instrumentation +go-httpbin can be configured via either command line arguments or environment +variables (or a combination of the two): -If you're running go-httpbin in your own infrastructure and would like custom -instrumentation (metrics, structured logging, request tracing, etc), you'll -need to wrap this package in your own code and use the included -[Observer][observer] mechanism to instrument requests as necessary. +| Argument| Env var | Documentation | Default | +| - | - | - | - | +| `-allowed-redirect-domains` | `ALLOWED_REDIRECT_DOMAINS` | Comma-separated list of domains the /redirect-to endpoint will allow | | +| `-host` | `HOST` | Host to listen on | "0.0.0.0" | +| `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | | +| `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | | +| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 | +| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s | +| `-port` | `PORT` | Port to listen on | 8080 | +| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false | -See [examples/custom-instrumentation][custom-instrumentation] for an example -that instruments every request using DataDog. +**Notes:** +- Command line arguments take precedence over environment variables. +- See [Production considerations] for recommendations around safe configuration + of public instances of go-httpbin ## Installation @@ -122,6 +113,66 @@ go install github.com/mccutchen/go-httpbin/v2/cmd/go-httpbin ``` +## Production considerations + +Before deploying an instance of go-httpbin on your own infrastructure on the +public internet, consider tuning it appropriately: + +1. **Restrict the domains to which the `/redirect-to` endpoint will send + traffic to avoid the security issues of an open redirect** + + Use the `-allowed-redirect-domains` CLI argument or the + `ALLOWED_REDIRECT_DOMAINS` env var to configure an appropriate allowlist. + +2. **Tune per-request limits** + + Because go-httpbin allows clients send arbitrary data in request bodies and + control the duration some requests (e.g. `/delay/60s`), it's important to + properly tune limits to prevent misbehaving or malicious clients from taking + too many resources. + + Use the `-max-body-size`/`MAX_BODY_SIZE` and `-max-duration`/`MAX_DURATION` + CLI arguments or env vars to enforce appropriate limits on each request. + +3. **Decide whether to expose real hostnames in the `/hostname` endpoint** + + By default, the `/hostname` endpoint serves a dummy hostname value, but it + can be configured to serve the real underlying hostname (according to + `os.Hostname()`) using the `-use-real-hostname` CLI argument or the + `USE_REAL_HOSTNAME` env var to enable this functionality. + + Before enabling this, ensure that your hostnames do not reveal too much + about your underlying infrastructure. + +4. **Add custom instrumentation** + + By default, go-httpbin logs basic information about each request. To add + more detailed instrumentation (metrics, structured logging, request + tracing), you'll need to wrap this package in your own code, which you can + then instrument as you would any net/http server. Some examples: + + - [examples/custom-instrumentation] instruments every request using DataDog, + based on the built-in [Observer] mechanism. + + - [mccutchen/httpbingo.org] is the code that powers the public instance of + go-httpbin deployed to [httpbingo.org], which adds customized structured + logging using [zerolog] and further hardens the HTTP server against + malicious clients by tuning lower-level timeouts and limits. + +## Development + +```bash +# local development +make +make test +make testcover +make run + +# building & pushing docker images +make image +make imagepush +``` + ## Motivation & prior art I've been a longtime user of [Kenneith Reitz][kr]'s original @@ -148,24 +199,14 @@ Compared to [ahmetb/go-httpbin][ahmet]: - More complete implementation of endpoints -## Development - -```bash -# local development -make -make test -make testcover -make run - -# building & pushing docker images -make image -make imagepush -``` - -[kr]: https://github.com/kennethreitz -[httpbin-org]: https://httpbin.org/ -[httpbin-repo]: https://github.com/kennethreitz/httpbin [ahmet]: https://github.com/ahmetb/go-httpbin [docker-hub]: https://hub.docker.com/r/mccutchen/go-httpbin/ -[observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer -[custom-instrumentation]: ./examples/custom-instrumentation/ +[examples/custom-instrumentation]: ./examples/custom-instrumentation/ +[httpbin-org]: https://httpbin.org/ +[httpbin-repo]: https://github.com/kennethreitz/httpbin +[httpbingo.org]: https://httpbingo.org/ +[kr]: https://github.com/kennethreitz +[mccutchen/httpbingo.org]: https://github.com/mccutchen/httpbingo.org +[Observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer +[Production considerations]: #production-considerations +[zerolog]: https://github.com/rs/zerolog diff --git a/cmd/go-httpbin/main.go b/cmd/go-httpbin/main.go index b9196f4e91c5a7eebad43d91ddccd1b7c39ab2c5..cb188be4ff7d0be55b2c8619e72fb4a7c209e3ea 100644 --- a/cmd/go-httpbin/main.go +++ b/cmd/go-httpbin/main.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "time" @@ -22,23 +23,25 @@ const ( ) var ( - host string - port int - maxBodySize int64 - maxDuration time.Duration - httpsCertFile string - httpsKeyFile string - useRealHostname bool + allowedRedirectDomains string + host string + httpsCertFile string + httpsKeyFile string + maxBodySize int64 + maxDuration time.Duration + port int + useRealHostname bool ) func main() { - flag.StringVar(&host, "host", defaultHost, "Host to listen on") + flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value") + flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take") + flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes") flag.IntVar(&port, "port", defaultPort, "Port to listen on") + flag.StringVar(&allowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow") + flag.StringVar(&host, "host", defaultHost, "Host to listen on") flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file") flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file") - flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes") - flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take") - flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value") flag.Parse() // Command line flags take precedence over environment vars, so we only @@ -97,6 +100,16 @@ func main() { useRealHostname = true } + var allowedRedirectDomainsList []string + if allowedRedirectDomains == "" && os.Getenv("ALLOWED_REDIRECT_DOMAINS") != "" { + allowedRedirectDomains = os.Getenv("ALLOWED_REDIRECT_DOMAINS") + } + for _, domain := range strings.Split(allowedRedirectDomains, ",") { + if strings.TrimSpace(domain) != "" { + allowedRedirectDomainsList = append(allowedRedirectDomainsList, strings.TrimSpace(domain)) + } + } + logger := log.New(os.Stderr, "", 0) // A hacky log helper function to ensure that shutdown messages are @@ -123,6 +136,9 @@ func main() { } opts = append(opts, httpbin.WithHostname(hostname)) } + if len(allowedRedirectDomainsList) > 0 { + opts = append(opts, httpbin.WithAllowedRedirectDomains(allowedRedirectDomainsList)) + } h := httpbin.New(opts...) listenAddr := net.JoinHostPort(host, strconv.Itoa(port)) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index a401570d0bf36100d2113968792d70a03d165721..d2ac4067644282fc2419fb117a19f087c84b34a6 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" "time" @@ -375,13 +376,25 @@ func (h *HTTPBin) AbsoluteRedirect(w http.ResponseWriter, r *http.Request) { func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - url := q.Get("url") - if url == "" { + inputURL := q.Get("url") + if inputURL == "" { http.Error(w, "Missing URL", http.StatusBadRequest) return } - var err error + u, err := url.Parse(inputURL) + if err != nil { + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } + + if u.IsAbs() && len(h.AllowedRedirectDomains) > 0 { + if _, ok := h.AllowedRedirectDomains[u.Hostname()]; !ok { + http.Error(w, "Forbidden redirect URL. Be careful with this link.", http.StatusForbidden) + return + } + } + statusCode := http.StatusFound rawStatusCode := q.Get("status_code") if rawStatusCode != "" { @@ -392,7 +405,7 @@ func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Location", url) + w.Header().Set("Location", u.String()) w.WriteHeader(statusCode) } diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index a5637ae14ae3628200df3112dc83ea139c921633..3b24fcd6e9caed562b1607b7232df6bd0907428e 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -1218,9 +1218,11 @@ func TestRedirectTo(t *testing.T) { url string expectedStatus int }{ - {"/redirect-to", http.StatusBadRequest}, - {"/redirect-to?status_code=302", http.StatusBadRequest}, - {"/redirect-to?url=foo&status_code=418", http.StatusBadRequest}, + {"/redirect-to", http.StatusBadRequest}, // missing url + {"/redirect-to?status_code=302", http.StatusBadRequest}, // missing url + {"/redirect-to?url=foo&status_code=201", http.StatusBadRequest}, // invalid status code + {"/redirect-to?url=foo&status_code=418", http.StatusBadRequest}, // invalid status code + {"/redirect-to?url=http%3A%2F%2Ffoo%25%25bar&status_code=418", http.StatusBadRequest}, // invalid URL } for _, test := range badTests { test := test @@ -1233,6 +1235,32 @@ func TestRedirectTo(t *testing.T) { assertStatusCode(t, w, test.expectedStatus) }) } + + allowListHandler := New( + WithAllowedRedirectDomains([]string{"httpbingo.org", "example.org"}), + WithObserver(StdLogObserver(log.New(io.Discard, "", 0))), + ).Handler() + + allowListTests := []struct { + url string + expectedStatus int + }{ + {"/redirect-to?url=http://httpbingo.org", http.StatusFound}, // allowlist ok + {"/redirect-to?url=https://httpbingo.org", http.StatusFound}, // scheme doesn't matter + {"/redirect-to?url=https://example.org/foo/bar", http.StatusFound}, // paths don't matter + {"/redirect-to?url=https://foo.example.org/foo/bar", http.StatusForbidden}, // subdomains of allowed domains do not match + {"/redirect-to?url=https://evil.com", http.StatusForbidden}, // not in allowlist + } + for _, test := range allowListTests { + test := test + t.Run("allowlist"+test.url, func(t *testing.T) { + t.Parallel() + r, _ := http.NewRequest("GET", test.url, nil) + w := httptest.NewRecorder() + allowListHandler.ServeHTTP(w, r) + assertStatusCode(t, w, test.expectedStatus) + }) + } } func TestCookies(t *testing.T) { diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go index f7d9c41b1003fa0c243a396dd2a1602a95bcc2f8..4d5c85e7267e9dcfd9f5651618c51067afff27ce 100644 --- a/httpbin/httpbin.go +++ b/httpbin/httpbin.go @@ -101,6 +101,9 @@ type HTTPBin struct { // Default parameter values DefaultParams DefaultParams + // Set of hosts to which the /redirect-to endpoint will allow redirects + AllowedRedirectDomains map[string]struct{} + // The hostname to expose via /hostname. hostname string } @@ -274,3 +277,15 @@ func WithObserver(o Observer) OptionFunc { h.Observer = o } } + +// WithAllowedRedirectDomains limits the domains to which the /redirect-to +// endpoint will redirect traffic. +func WithAllowedRedirectDomains(hosts []string) OptionFunc { + return func(h *HTTPBin) { + hostSet := make(map[string]struct{}, len(hosts)) + for _, host := range hosts { + hostSet[host] = struct{}{} + } + h.AllowedRedirectDomains = hostSet + } +}