Skip to content

Commit 0decfd1

Browse files
authored
Merge commit from fork
* fix: prevent reflected XSS in `/response-headers` endpoint * link to security policy from README * decompose/refactor a bit * apply same fix to /base64 endpoint * fmt * switch to allowlist of safe content types, w/ config option for backwards compat * readme styling
1 parent 9bcfcda commit 0decfd1

File tree

9 files changed

+338
-6
lines changed

9 files changed

+338
-6
lines changed

README.md

+14
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ variables (or a combination of the two):
110110
| `-srv-read-timeout` | `SRV_READ_TIMEOUT` | Value to use for the http.Server's ReadTimeout option | 5s |
111111
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
112112

113+
#### ⚠️ **HERE BE DRAGONS** ⚠️
114+
115+
These configuration options are dangerous and/or deprecated and should be
116+
avoided unless backwards compatibility is absolutely required.
117+
118+
| Argument| Env var | Documentation | Default |
119+
| - | - | - | - |
120+
| `-unsafe-allow-dangerous-responses` | `UNSAFE_ALLOW_DANGEROUS_RESPONSES` | Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks) | false |
121+
113122
**Notes:**
114123
- Command line arguments take precedence over environment variables.
115124
- See [Production considerations] for recommendations around safe configuration
@@ -201,6 +210,10 @@ public internet, consider tuning it appropriately:
201210

202211
See [DEVELOPMENT.md][].
203212

213+
## Security
214+
215+
See [SECURITY.md][].
216+
204217
## Motivation & prior art
205218

206219
I've been a longtime user of [Kenneith Reitz][kr]'s original
@@ -240,4 +253,5 @@ Compared to [ahmetb/go-httpbin][ahmet]:
240253
[mccutchen/httpbingo.org]: https://github.com/mccutchen/httpbingo.org
241254
[Observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer
242255
[Production considerations]: #production-considerations
256+
[SECURITY.md]: ./SECURITY.md
243257
[zerolog]: https://github.com/rs/zerolog

httpbin/cmd/cmd.go

+24-4
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []
9191
if len(cfg.AllowedRedirectDomains) > 0 {
9292
opts = append(opts, httpbin.WithAllowedRedirectDomains(cfg.AllowedRedirectDomains))
9393
}
94+
if cfg.UnsafeAllowDangerousResponses {
95+
opts = append(opts, httpbin.WithUnsafeAllowDangerousResponses())
96+
}
9497
app := httpbin.New(opts...)
9598

9699
srv := &http.Server{
@@ -128,6 +131,14 @@ type config struct {
128131
SrvReadHeaderTimeout time.Duration
129132
SrvReadTimeout time.Duration
130133

134+
// If true, endpoints that allow clients to specify a response
135+
// Conntent-Type will NOT escape HTML entities in the response body, which
136+
// can enable (e.g.) reflected XSS attacks.
137+
//
138+
// This configuration is only supported for backwards compatibility if
139+
// absolutely necessary.
140+
UnsafeAllowDangerousResponses bool
141+
131142
// temporary placeholders for arguments that need extra processing
132143
rawAllowedRedirectDomains string
133144
rawUseRealHostname bool
@@ -169,6 +180,10 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
169180
fs.DurationVar(&cfg.SrvReadHeaderTimeout, "srv-read-header-timeout", defaultSrvReadHeaderTimeout, "Value to use for the http.Server's ReadHeaderTimeout option")
170181
fs.DurationVar(&cfg.SrvReadTimeout, "srv-read-timeout", defaultSrvReadTimeout, "Value to use for the http.Server's ReadTimeout option")
171182

183+
// Here be dragons! This flag is only for backwards compatibility and
184+
// should not be used in production.
185+
fs.BoolVar(&cfg.UnsafeAllowDangerousResponses, "unsafe-allow-dangerous-responses", false, "Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks)")
186+
172187
// in order to fully control error output whether CLI arguments or env vars
173188
// are used to configure the app, we need to take control away from the
174189
// flag-set, which by defaults prints errors automatically.
@@ -258,10 +273,7 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
258273
return nil, configErr(`invalid log format %q, must be "text" or "json"`, cfg.LogFormat)
259274
}
260275

261-
// useRealHostname will be true if either the `-use-real-hostname`
262-
// arg is given on the command line or if the USE_REAL_HOSTNAME env var
263-
// is one of "1" or "true".
264-
if useRealHostnameEnv := getEnvVal("USE_REAL_HOSTNAME"); useRealHostnameEnv == "1" || useRealHostnameEnv == "true" {
276+
if getEnvBool(getEnvVal("USE_REAL_HOSTNAME")) {
265277
cfg.rawUseRealHostname = true
266278
}
267279
if cfg.rawUseRealHostname {
@@ -301,6 +313,10 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
301313
}
302314
}
303315

316+
if getEnvBool(getEnvVal("UNSAFE_ALLOW_DANGEROUS_RESPONSES")) {
317+
cfg.UnsafeAllowDangerousResponses = true
318+
}
319+
304320
// reset temporary fields to their zero values
305321
cfg.rawAllowedRedirectDomains = ""
306322
cfg.rawUseRealHostname = false
@@ -319,6 +335,10 @@ func loadConfig(args []string, getEnvVal func(string) string, getEnviron func()
319335
return cfg, nil
320336
}
321337

338+
func getEnvBool(val string) bool {
339+
return val == "1" || val == "true"
340+
}
341+
322342
func listenAndServeGracefully(srv *http.Server, cfg *config, logger *slog.Logger) error {
323343
doneCh := make(chan error, 1)
324344

httpbin/cmd/cmd_test.go

+50
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ const usage = `Usage of go-httpbin:
4646
Value to use for the http.Server's ReadHeaderTimeout option (default 1s)
4747
-srv-read-timeout duration
4848
Value to use for the http.Server's ReadTimeout option (default 5s)
49+
-unsafe-allow-dangerous-responses
50+
Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks)
4951
-use-real-hostname
5052
Expose value of os.Hostname() in the /hostname endpoint instead of dummy value
5153
`
@@ -475,6 +477,54 @@ func TestLoadConfig(t *testing.T) {
475477
SrvReadTimeout: 1234 * time.Second,
476478
}),
477479
},
480+
481+
// unsafe-allow-dangerous-responses
482+
"ok -unsafe-allow-dangerous-responses": {
483+
args: []string{"-unsafe-allow-dangerous-responses"},
484+
wantCfg: mergedConfig(defaultCfg, &config{
485+
UnsafeAllowDangerousResponses: true,
486+
}),
487+
},
488+
"ok -unsafe-allow-dangerous-responses=1": {
489+
args: []string{"-unsafe-allow-dangerous-responses", "1"},
490+
wantCfg: mergedConfig(defaultCfg, &config{
491+
UnsafeAllowDangerousResponses: true,
492+
}),
493+
},
494+
"ok -unsafe-allow-dangerous-responses=true": {
495+
args: []string{"-unsafe-allow-dangerous-responses", "true"},
496+
wantCfg: mergedConfig(defaultCfg, &config{
497+
UnsafeAllowDangerousResponses: true,
498+
}),
499+
},
500+
// any value for the argument is interpreted as true
501+
"ok -unsafe-allow-dangerous-responses=0": {
502+
args: []string{"-unsafe-allow-dangerous-responses", "0"},
503+
wantCfg: mergedConfig(defaultCfg, &config{
504+
UnsafeAllowDangerousResponses: true,
505+
}),
506+
},
507+
"ok UNSAFE_ALLOW_DANGEROUS_RESPONSES=1": {
508+
env: map[string]string{"UNSAFE_ALLOW_DANGEROUS_RESPONSES": "1"},
509+
wantCfg: mergedConfig(defaultCfg, &config{
510+
UnsafeAllowDangerousResponses: true,
511+
}),
512+
},
513+
"ok UNSAFE_ALLOW_DANGEROUS_RESPONSES=true": {
514+
env: map[string]string{"UNSAFE_ALLOW_DANGEROUS_RESPONSES": "true"},
515+
wantCfg: mergedConfig(defaultCfg, &config{
516+
UnsafeAllowDangerousResponses: true,
517+
}),
518+
},
519+
// case sensitive
520+
"ok UNSAFE_ALLOW_DANGEROUS_RESPONSES=TRUE": {
521+
env: map[string]string{"UNSAFE_ALLOW_DANGEROUS_RESPONSES": "TRUE"},
522+
wantCfg: defaultCfg,
523+
},
524+
"ok UNSAFE_ALLOW_DANGEROUS_RESPONSES=false": {
525+
env: map[string]string{"UNSAFE_ALLOW_DANGEROUS_RESPONSES": "false"},
526+
wantCfg: defaultCfg,
527+
},
478528
}
479529

480530
for name, tc := range testCases {

httpbin/handlers.go

+30-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/json"
88
"errors"
99
"fmt"
10+
"html"
1011
"io"
1112
"net/http"
1213
"net/http/httputil"
@@ -332,17 +333,40 @@ func (h *HTTPBin) Unstable(w http.ResponseWriter, r *http.Request) {
332333
w.WriteHeader(status)
333334
}
334335

335-
// ResponseHeaders responds with a map of header values
336+
// ResponseHeaders sets every incoming query parameter as a response header and
337+
// returns the headers serialized as JSON.
338+
//
339+
// If the Content-Type query parameter is given and set to a "dangerous" value
340+
// (i.e. one that might be rendered as HTML in a web browser), the keys and
341+
// values in the JSON response body will be escaped.
336342
func (h *HTTPBin) ResponseHeaders(w http.ResponseWriter, r *http.Request) {
337343
args := r.URL.Query()
344+
contentType := args.Get("Content-Type")
345+
346+
// response headers are not escaped, regardless of content type
338347
for k, vs := range args {
339348
for _, v := range vs {
340349
w.Header().Add(k, v)
341350
}
342351
}
343-
if contentType := w.Header().Get("Content-Type"); contentType == "" {
352+
// only set our own content type if one was not already set based on
353+
// incoming request params
354+
if contentType == "" {
344355
w.Header().Set("Content-Type", jsonContentType)
345356
}
357+
358+
// if response content type is dangrous, escape keys and values before
359+
// serializing response body
360+
if h.mustEscapeResponse(contentType) {
361+
tmp := make(url.Values, len(args))
362+
for k, vs := range args {
363+
for _, v := range vs {
364+
tmp.Add(html.EscapeString(k), html.EscapeString(v))
365+
}
366+
}
367+
args = tmp
368+
}
369+
346370
mustMarshalJSON(w, args)
347371
}
348372

@@ -1103,6 +1127,10 @@ func (h *HTTPBin) Base64(w http.ResponseWriter, r *http.Request) {
11031127
if ct == "" {
11041128
ct = textContentType
11051129
}
1130+
// prevent XSS and other client side vulns if the content type is dangerous
1131+
if h.mustEscapeResponse(ct) {
1132+
result = []byte(html.EscapeString(string(result)))
1133+
}
11061134
writeResponse(w, http.StatusOK, ct, result)
11071135
}
11081136

httpbin/handlers_test.go

+93
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/base64"
1010
"encoding/json"
1111
"fmt"
12+
"html"
1213
"io"
1314
"log/slog"
1415
"mime"
@@ -1256,6 +1257,92 @@ func TestResponseHeaders(t *testing.T) {
12561257
assert.StatusCode(t, resp, http.StatusOK)
12571258
assert.ContentType(t, resp, contentType)
12581259
})
1260+
1261+
t.Run("escaping HTML content", func(t *testing.T) {
1262+
dangerousString := "<img/src/onerror=alert('xss')>"
1263+
1264+
for _, tc := range []struct {
1265+
contentType string
1266+
shouldEscape bool
1267+
}{
1268+
// a tiny number of content types are considered safe and do not
1269+
// require escaping (see isDangerousContentType)
1270+
{"application/json; charset=utf8", false},
1271+
{"text/plain", false},
1272+
{"application/octet-string", false},
1273+
1274+
// everything else requires escaping
1275+
{"", true},
1276+
{"application/xml", true},
1277+
{"image/png", true},
1278+
{"text/html; charset=utf8", true},
1279+
{"text/html", true},
1280+
} {
1281+
tc := tc
1282+
t.Run(tc.contentType, func(t *testing.T) {
1283+
t.Parallel()
1284+
1285+
params := url.Values{}
1286+
if tc.contentType != "" {
1287+
params.Set("Content-Type", tc.contentType)
1288+
}
1289+
// need to ensure dangerous strings are escaped as both keys
1290+
// and values
1291+
params.Set("xss", dangerousString)
1292+
params.Set(dangerousString, "xss")
1293+
1294+
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/response-headers?%s", srv.URL, params.Encode()), nil)
1295+
resp := must.DoReq(t, client, req)
1296+
1297+
assert.StatusCode(t, resp, http.StatusOK)
1298+
if tc.contentType != "" {
1299+
assert.ContentType(t, resp, tc.contentType)
1300+
} else {
1301+
assert.ContentType(t, resp, jsonContentType)
1302+
}
1303+
1304+
gotParams := must.Unmarshal[url.Values](t, resp.Body)
1305+
for key, wantVals := range params {
1306+
if tc.shouldEscape {
1307+
key = html.EscapeString(key)
1308+
}
1309+
gotVals := gotParams[key]
1310+
assert.Equal(t, len(gotVals), len(wantVals), "unexpected number of values for key %q (escaped=%v)", key, tc.shouldEscape)
1311+
for i, wantVal := range wantVals {
1312+
gotVal := gotVals[i]
1313+
if tc.shouldEscape {
1314+
assert.Equal(t, gotVal, html.EscapeString(wantVal), "expected HTML-escaped value")
1315+
} else {
1316+
assert.Equal(t, gotVal, wantVal, "expected unescaped value")
1317+
}
1318+
}
1319+
}
1320+
})
1321+
}
1322+
})
1323+
1324+
t.Run("dangerously not escaping responses", func(t *testing.T) {
1325+
t.Parallel()
1326+
1327+
app := createApp(WithUnsafeAllowDangerousResponses())
1328+
srv := httptest.NewServer(app)
1329+
defer srv.Close()
1330+
1331+
dangerousString := "<img/src/onerror=alert('xss')>"
1332+
1333+
params := url.Values{}
1334+
params.Set("Content-Type", "text/html")
1335+
params.Set("xss", dangerousString)
1336+
1337+
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/response-headers?%s", srv.URL, params.Encode()), nil)
1338+
resp := must.DoReq(t, client, req)
1339+
1340+
assert.StatusCode(t, resp, http.StatusOK)
1341+
assert.ContentType(t, resp, "text/html")
1342+
1343+
// dangerous string is not escaped
1344+
assert.BodyContains(t, resp, dangerousString)
1345+
})
12591346
}
12601347

12611348
func TestRedirects(t *testing.T) {
@@ -2992,6 +3079,12 @@ func TestBase64(t *testing.T) {
29923079
`{"server": "go-httpbin"}` + "\n",
29933080
"application/json",
29943081
},
3082+
{
3083+
// XSS prevention w/ dangerous content type
3084+
"/base64/PGltZy9zcmMvb25lcnJvcj1hbGVydCgneHNzJyk+?content-type=text/html",
3085+
html.EscapeString("<img/src/onerror=alert('xss')>"),
3086+
"text/html",
3087+
},
29953088
}
29963089

29973090
for _, test := range okTests {

httpbin/helpers.go

+24
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"io"
1212
"math/rand"
13+
"mime"
1314
"mime/multipart"
1415
"net/http"
1516
"net/url"
@@ -587,3 +588,26 @@ func encodeServerTimings(timings []serverTiming) string {
587588
}
588589
return strings.Join(entries, ", ")
589590
}
591+
592+
// The following content types are considered safe enough to skip HTML-escaping
593+
// response bodies.
594+
//
595+
// See [1] for an example of the wide variety of unsafe content types, which
596+
// varies by browser vendor and could change in the future.
597+
//
598+
// [1]: https://github.com/BlackFan/content-type-research/blob/4e4347254/XSS.md
599+
var safeContentTypes = map[string]bool{
600+
"text/plain": true,
601+
"application/json": true,
602+
"application/octet-string": true,
603+
}
604+
605+
// isDangerousContentType determines whether the given Content-Type header
606+
// value could be unsafe (e.g. at risk of XSS) when rendered by a web browser.
607+
func isDangerousContentType(ct string) bool {
608+
mediatype, _, err := mime.ParseMediaType(ct)
609+
if err != nil {
610+
return true
611+
}
612+
return !safeContentTypes[mediatype]
613+
}

0 commit comments

Comments
 (0)