Skip to content

Commit aaf674e

Browse files
authored
Allow restricting redirect destinations (#97)
The `/redirect-to` endpoint currently acts as an open redirect, which is bad for any go-httpbin instance exposed to the public internet. This allows configuring an allowlist of domains to which traffic can be redirected.
1 parent c7eb5b7 commit aaf674e

File tree

5 files changed

+184
-71
lines changed

5 files changed

+184
-71
lines changed

README.md

+94-53
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,17 @@ A reasonably complete and well-tested golang port of [Kenneth Reitz][kr]'s
1111

1212
## Usage
1313

14+
### Docker
1415

15-
### Configuration
16-
17-
go-httpbin can be configured via either command line arguments or environment
18-
variables (or a combination of the two):
19-
20-
| Argument| Env var | Documentation | Default |
21-
| - | - | - | - |
22-
| `-host` | `HOST` | Host to listen on | "0.0.0.0" |
23-
| `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | |
24-
| `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | |
25-
| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
26-
| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
27-
| `-port` | `PORT` | Port to listen on | 8080 |
28-
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
16+
Docker images are published to [Docker Hub][docker-hub]:
2917

30-
**Note:** Command line arguments take precedence over environment variables.
18+
```bash
19+
# Run http server
20+
$ docker run -P mccutchen/go-httpbin
3121

22+
# Run https server
23+
$ 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
24+
```
3225

3326
### Standalone binary
3427

@@ -48,18 +41,6 @@ $ openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650
4841
$ go-httpbin -host 127.0.0.1 -port 8081 -https-cert-file ./server.crt -https-key-file ./server.key
4942
```
5043

51-
### Docker
52-
53-
Docker images are published to [Docker Hub][docker-hub]:
54-
55-
```bash
56-
# Run http server
57-
$ docker run -P mccutchen/go-httpbin
58-
59-
# Run https server
60-
$ 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
61-
```
62-
6344
### Unit testing helper library
6445

6546
The `github.com/mccutchen/go-httpbin/httpbin/v2` package can also be used as a
@@ -95,16 +76,26 @@ func TestSlowResponse(t *testing.T) {
9576
}
9677
```
9778

79+
### Configuration
9880

99-
## Custom instrumentation
81+
go-httpbin can be configured via either command line arguments or environment
82+
variables (or a combination of the two):
10083

101-
If you're running go-httpbin in your own infrastructure and would like custom
102-
instrumentation (metrics, structured logging, request tracing, etc), you'll
103-
need to wrap this package in your own code and use the included
104-
[Observer][observer] mechanism to instrument requests as necessary.
84+
| Argument| Env var | Documentation | Default |
85+
| - | - | - | - |
86+
| `-allowed-redirect-domains` | `ALLOWED_REDIRECT_DOMAINS` | Comma-separated list of domains the /redirect-to endpoint will allow | |
87+
| `-host` | `HOST` | Host to listen on | "0.0.0.0" |
88+
| `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | |
89+
| `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | |
90+
| `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 |
91+
| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
92+
| `-port` | `PORT` | Port to listen on | 8080 |
93+
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
10594

106-
See [examples/custom-instrumentation][custom-instrumentation] for an example
107-
that instruments every request using DataDog.
95+
**Notes:**
96+
- Command line arguments take precedence over environment variables.
97+
- See [Production considerations] for recommendations around safe configuration
98+
of public instances of go-httpbin
10899

109100

110101
## Installation
@@ -122,6 +113,66 @@ go install github.com/mccutchen/go-httpbin/v2/cmd/go-httpbin
122113
```
123114

124115

116+
## Production considerations
117+
118+
Before deploying an instance of go-httpbin on your own infrastructure on the
119+
public internet, consider tuning it appropriately:
120+
121+
1. **Restrict the domains to which the `/redirect-to` endpoint will send
122+
traffic to avoid the security issues of an open redirect**
123+
124+
Use the `-allowed-redirect-domains` CLI argument or the
125+
`ALLOWED_REDIRECT_DOMAINS` env var to configure an appropriate allowlist.
126+
127+
2. **Tune per-request limits**
128+
129+
Because go-httpbin allows clients send arbitrary data in request bodies and
130+
control the duration some requests (e.g. `/delay/60s`), it's important to
131+
properly tune limits to prevent misbehaving or malicious clients from taking
132+
too many resources.
133+
134+
Use the `-max-body-size`/`MAX_BODY_SIZE` and `-max-duration`/`MAX_DURATION`
135+
CLI arguments or env vars to enforce appropriate limits on each request.
136+
137+
3. **Decide whether to expose real hostnames in the `/hostname` endpoint**
138+
139+
By default, the `/hostname` endpoint serves a dummy hostname value, but it
140+
can be configured to serve the real underlying hostname (according to
141+
`os.Hostname()`) using the `-use-real-hostname` CLI argument or the
142+
`USE_REAL_HOSTNAME` env var to enable this functionality.
143+
144+
Before enabling this, ensure that your hostnames do not reveal too much
145+
about your underlying infrastructure.
146+
147+
4. **Add custom instrumentation**
148+
149+
By default, go-httpbin logs basic information about each request. To add
150+
more detailed instrumentation (metrics, structured logging, request
151+
tracing), you'll need to wrap this package in your own code, which you can
152+
then instrument as you would any net/http server. Some examples:
153+
154+
- [examples/custom-instrumentation] instruments every request using DataDog,
155+
based on the built-in [Observer] mechanism.
156+
157+
- [mccutchen/httpbingo.org] is the code that powers the public instance of
158+
go-httpbin deployed to [httpbingo.org], which adds customized structured
159+
logging using [zerolog] and further hardens the HTTP server against
160+
malicious clients by tuning lower-level timeouts and limits.
161+
162+
## Development
163+
164+
```bash
165+
# local development
166+
make
167+
make test
168+
make testcover
169+
make run
170+
171+
# building & pushing docker images
172+
make image
173+
make imagepush
174+
```
175+
125176
## Motivation & prior art
126177

127178
I've been a longtime user of [Kenneith Reitz][kr]'s original
@@ -148,24 +199,14 @@ Compared to [ahmetb/go-httpbin][ahmet]:
148199
- More complete implementation of endpoints
149200

150201

151-
## Development
152-
153-
```bash
154-
# local development
155-
make
156-
make test
157-
make testcover
158-
make run
159-
160-
# building & pushing docker images
161-
make image
162-
make imagepush
163-
```
164-
165-
[kr]: https://github.com/kennethreitz
166-
[httpbin-org]: https://httpbin.org/
167-
[httpbin-repo]: https://github.com/kennethreitz/httpbin
168202
[ahmet]: https://github.com/ahmetb/go-httpbin
169203
[docker-hub]: https://hub.docker.com/r/mccutchen/go-httpbin/
170-
[observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer
171-
[custom-instrumentation]: ./examples/custom-instrumentation/
204+
[examples/custom-instrumentation]: ./examples/custom-instrumentation/
205+
[httpbin-org]: https://httpbin.org/
206+
[httpbin-repo]: https://github.com/kennethreitz/httpbin
207+
[httpbingo.org]: https://httpbingo.org/
208+
[kr]: https://github.com/kennethreitz
209+
[mccutchen/httpbingo.org]: https://github.com/mccutchen/httpbingo.org
210+
[Observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer
211+
[Production considerations]: #production-considerations
212+
[zerolog]: https://github.com/rs/zerolog

cmd/go-httpbin/main.go

+27-11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"os/signal"
1212
"strconv"
13+
"strings"
1314
"syscall"
1415
"time"
1516

@@ -22,23 +23,25 @@ const (
2223
)
2324

2425
var (
25-
host string
26-
port int
27-
maxBodySize int64
28-
maxDuration time.Duration
29-
httpsCertFile string
30-
httpsKeyFile string
31-
useRealHostname bool
26+
allowedRedirectDomains string
27+
host string
28+
httpsCertFile string
29+
httpsKeyFile string
30+
maxBodySize int64
31+
maxDuration time.Duration
32+
port int
33+
useRealHostname bool
3234
)
3335

3436
func main() {
35-
flag.StringVar(&host, "host", defaultHost, "Host to listen on")
37+
flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
38+
flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
39+
flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
3640
flag.IntVar(&port, "port", defaultPort, "Port to listen on")
41+
flag.StringVar(&allowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow")
42+
flag.StringVar(&host, "host", defaultHost, "Host to listen on")
3743
flag.StringVar(&httpsCertFile, "https-cert-file", "", "HTTPS Server certificate file")
3844
flag.StringVar(&httpsKeyFile, "https-key-file", "", "HTTPS Server private key file")
39-
flag.Int64Var(&maxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes")
40-
flag.DurationVar(&maxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take")
41-
flag.BoolVar(&useRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value")
4245
flag.Parse()
4346

4447
// Command line flags take precedence over environment vars, so we only
@@ -97,6 +100,16 @@ func main() {
97100
useRealHostname = true
98101
}
99102

103+
var allowedRedirectDomainsList []string
104+
if allowedRedirectDomains == "" && os.Getenv("ALLOWED_REDIRECT_DOMAINS") != "" {
105+
allowedRedirectDomains = os.Getenv("ALLOWED_REDIRECT_DOMAINS")
106+
}
107+
for _, domain := range strings.Split(allowedRedirectDomains, ",") {
108+
if strings.TrimSpace(domain) != "" {
109+
allowedRedirectDomainsList = append(allowedRedirectDomainsList, strings.TrimSpace(domain))
110+
}
111+
}
112+
100113
logger := log.New(os.Stderr, "", 0)
101114

102115
// A hacky log helper function to ensure that shutdown messages are
@@ -123,6 +136,9 @@ func main() {
123136
}
124137
opts = append(opts, httpbin.WithHostname(hostname))
125138
}
139+
if len(allowedRedirectDomainsList) > 0 {
140+
opts = append(opts, httpbin.WithAllowedRedirectDomains(allowedRedirectDomainsList))
141+
}
126142
h := httpbin.New(opts...)
127143

128144
listenAddr := net.JoinHostPort(host, strconv.Itoa(port))

httpbin/handlers.go

+17-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"fmt"
99
"net/http"
10+
"net/url"
1011
"strconv"
1112
"strings"
1213
"time"
@@ -375,13 +376,25 @@ func (h *HTTPBin) AbsoluteRedirect(w http.ResponseWriter, r *http.Request) {
375376
func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) {
376377
q := r.URL.Query()
377378

378-
url := q.Get("url")
379-
if url == "" {
379+
inputURL := q.Get("url")
380+
if inputURL == "" {
380381
http.Error(w, "Missing URL", http.StatusBadRequest)
381382
return
382383
}
383384

384-
var err error
385+
u, err := url.Parse(inputURL)
386+
if err != nil {
387+
http.Error(w, "Invalid URL", http.StatusBadRequest)
388+
return
389+
}
390+
391+
if u.IsAbs() && len(h.AllowedRedirectDomains) > 0 {
392+
if _, ok := h.AllowedRedirectDomains[u.Hostname()]; !ok {
393+
http.Error(w, "Forbidden redirect URL. Be careful with this link.", http.StatusForbidden)
394+
return
395+
}
396+
}
397+
385398
statusCode := http.StatusFound
386399
rawStatusCode := q.Get("status_code")
387400
if rawStatusCode != "" {
@@ -392,7 +405,7 @@ func (h *HTTPBin) RedirectTo(w http.ResponseWriter, r *http.Request) {
392405
}
393406
}
394407

395-
w.Header().Set("Location", url)
408+
w.Header().Set("Location", u.String())
396409
w.WriteHeader(statusCode)
397410
}
398411

httpbin/handlers_test.go

+31-3
Original file line numberDiff line numberDiff line change
@@ -1218,9 +1218,11 @@ func TestRedirectTo(t *testing.T) {
12181218
url string
12191219
expectedStatus int
12201220
}{
1221-
{"/redirect-to", http.StatusBadRequest},
1222-
{"/redirect-to?status_code=302", http.StatusBadRequest},
1223-
{"/redirect-to?url=foo&status_code=418", http.StatusBadRequest},
1221+
{"/redirect-to", http.StatusBadRequest}, // missing url
1222+
{"/redirect-to?status_code=302", http.StatusBadRequest}, // missing url
1223+
{"/redirect-to?url=foo&status_code=201", http.StatusBadRequest}, // invalid status code
1224+
{"/redirect-to?url=foo&status_code=418", http.StatusBadRequest}, // invalid status code
1225+
{"/redirect-to?url=http%3A%2F%2Ffoo%25%25bar&status_code=418", http.StatusBadRequest}, // invalid URL
12241226
}
12251227
for _, test := range badTests {
12261228
test := test
@@ -1233,6 +1235,32 @@ func TestRedirectTo(t *testing.T) {
12331235
assertStatusCode(t, w, test.expectedStatus)
12341236
})
12351237
}
1238+
1239+
allowListHandler := New(
1240+
WithAllowedRedirectDomains([]string{"httpbingo.org", "example.org"}),
1241+
WithObserver(StdLogObserver(log.New(io.Discard, "", 0))),
1242+
).Handler()
1243+
1244+
allowListTests := []struct {
1245+
url string
1246+
expectedStatus int
1247+
}{
1248+
{"/redirect-to?url=http://httpbingo.org", http.StatusFound}, // allowlist ok
1249+
{"/redirect-to?url=https://httpbingo.org", http.StatusFound}, // scheme doesn't matter
1250+
{"/redirect-to?url=https://example.org/foo/bar", http.StatusFound}, // paths don't matter
1251+
{"/redirect-to?url=https://foo.example.org/foo/bar", http.StatusForbidden}, // subdomains of allowed domains do not match
1252+
{"/redirect-to?url=https://evil.com", http.StatusForbidden}, // not in allowlist
1253+
}
1254+
for _, test := range allowListTests {
1255+
test := test
1256+
t.Run("allowlist"+test.url, func(t *testing.T) {
1257+
t.Parallel()
1258+
r, _ := http.NewRequest("GET", test.url, nil)
1259+
w := httptest.NewRecorder()
1260+
allowListHandler.ServeHTTP(w, r)
1261+
assertStatusCode(t, w, test.expectedStatus)
1262+
})
1263+
}
12361264
}
12371265

12381266
func TestCookies(t *testing.T) {

httpbin/httpbin.go

+15
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ type HTTPBin struct {
101101
// Default parameter values
102102
DefaultParams DefaultParams
103103

104+
// Set of hosts to which the /redirect-to endpoint will allow redirects
105+
AllowedRedirectDomains map[string]struct{}
106+
104107
// The hostname to expose via /hostname.
105108
hostname string
106109
}
@@ -274,3 +277,15 @@ func WithObserver(o Observer) OptionFunc {
274277
h.Observer = o
275278
}
276279
}
280+
281+
// WithAllowedRedirectDomains limits the domains to which the /redirect-to
282+
// endpoint will redirect traffic.
283+
func WithAllowedRedirectDomains(hosts []string) OptionFunc {
284+
return func(h *HTTPBin) {
285+
hostSet := make(map[string]struct{}, len(hosts))
286+
for _, host := range hosts {
287+
hostSet[host] = struct{}{}
288+
}
289+
h.AllowedRedirectDomains = hostSet
290+
}
291+
}

0 commit comments

Comments
 (0)