Skip to content

Commit 62ec555

Browse files
authored
feat: allow filtering incoming request headers (#139)
1 parent 3761c4a commit 62ec555

9 files changed

+245
-8
lines changed

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ variables (or a combination of the two):
9191
| `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s |
9292
| `-port` | `PORT` | Port to listen on | 8080 |
9393
| `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false |
94+
| `-exclude-headers` | `EXCLUDE_HEADERS` | Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard suffix matching. For example: `"foo,bar,x-fc-*"` | - |
9495

9596
**Notes:**
9697
- Command line arguments take precedence over environment variables.
@@ -159,6 +160,13 @@ public internet, consider tuning it appropriately:
159160
logging using [zerolog] and further hardens the HTTP server against
160161
malicious clients by tuning lower-level timeouts and limits.
161162

163+
5. **Prevent leaking sensitive headers**
164+
165+
By default, go-httpbin will return any headers sent by the client in the response.
166+
But if you want to deploy go-httpbin in some serverless environment, you may want to drop some headers.
167+
You can use the `-exclude-headers` CLI argument or the `EXCLUDE_HEADERS` env var to configure an appropriate allowlist.
168+
For example, Alibaba Cloud Function Compute will [add some headers like `x-fc-*` to the request](https://www.alibabacloud.com/help/en/fc/user-guide/specification-details). if you want to drop these `x-fc-*` headers, you can set `EXCLUDE_HEADERS=x-fc-*`.
169+
162170
## Development
163171

164172
```bash

httpbin/cmd/cmd.go

+6
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
6969
httpbin.WithMaxBodySize(cfg.MaxBodySize),
7070
httpbin.WithMaxDuration(cfg.MaxDuration),
7171
httpbin.WithObserver(httpbin.StdLogObserver(logger)),
72+
httpbin.WithExcludeHeaders(cfg.ExcludeHeaders),
7273
}
7374
if cfg.RealHostname != "" {
7475
opts = append(opts, httpbin.WithHostname(cfg.RealHostname))
@@ -99,6 +100,7 @@ func mainImpl(args []string, getEnv func(string) string, getHostname func() (str
99100
type config struct {
100101
AllowedRedirectDomains []string
101102
ListenHost string
103+
ExcludeHeaders string
102104
ListenPort int
103105
MaxBodySize int64
104106
MaxDuration time.Duration
@@ -140,6 +142,7 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
140142
fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on")
141143
fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file")
142144
fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file")
145+
fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.")
143146

144147
// in order to fully control error output whether CLI arguments or env vars
145148
// are used to configure the app, we need to take control away from the
@@ -189,6 +192,9 @@ func loadConfig(args []string, getEnv func(string) string, getHostname func() (s
189192
if cfg.ListenHost == defaultListenHost && getEnv("HOST") != "" {
190193
cfg.ListenHost = getEnv("HOST")
191194
}
195+
if cfg.ExcludeHeaders == "" && getEnv("EXCLUDE_HEADERS") != "" {
196+
cfg.ExcludeHeaders = getEnv("EXCLUDE_HEADERS")
197+
}
192198
if cfg.ListenPort == defaultListenPort && getEnv("PORT") != "" {
193199
cfg.ListenPort, err = strconv.Atoi(getEnv("PORT"))
194200
if err != nil {

httpbin/cmd/cmd_test.go

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
const usage = `Usage of go-httpbin:
1818
-allowed-redirect-domains string
1919
Comma-separated list of domains the /redirect-to endpoint will allow
20+
-exclude-headers string
21+
Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.
2022
-host string
2123
Host to listen on (default "0.0.0.0")
2224
-https-cert-file string

httpbin/handlers.go

+7-7
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (h *HTTPBin) UTF8(w http.ResponseWriter, r *http.Request) {
4747
func (h *HTTPBin) Get(w http.ResponseWriter, r *http.Request) {
4848
writeJSON(http.StatusOK, w, &noBodyResponse{
4949
Args: r.URL.Query(),
50-
Headers: getRequestHeaders(r),
50+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
5151
Method: r.Method,
5252
Origin: getClientIP(r),
5353
URL: getURL(r).String(),
@@ -74,7 +74,7 @@ func (h *HTTPBin) RequestWithBody(w http.ResponseWriter, r *http.Request) {
7474
Args: r.URL.Query(),
7575
Files: nilValues,
7676
Form: nilValues,
77-
Headers: getRequestHeaders(r),
77+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
7878
Method: r.Method,
7979
Origin: getClientIP(r),
8080
URL: getURL(r).String(),
@@ -96,7 +96,7 @@ func (h *HTTPBin) Gzip(w http.ResponseWriter, r *http.Request) {
9696
)
9797
mustMarshalJSON(gzw, &noBodyResponse{
9898
Args: r.URL.Query(),
99-
Headers: getRequestHeaders(r),
99+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
100100
Method: r.Method,
101101
Origin: getClientIP(r),
102102
Gzipped: true,
@@ -119,7 +119,7 @@ func (h *HTTPBin) Deflate(w http.ResponseWriter, r *http.Request) {
119119
)
120120
mustMarshalJSON(zw, &noBodyResponse{
121121
Args: r.URL.Query(),
122-
Headers: getRequestHeaders(r),
122+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
123123
Method: r.Method,
124124
Origin: getClientIP(r),
125125
Deflated: true,
@@ -151,7 +151,7 @@ func (h *HTTPBin) UserAgent(w http.ResponseWriter, r *http.Request) {
151151
// Headers echoes the incoming request headers
152152
func (h *HTTPBin) Headers(w http.ResponseWriter, r *http.Request) {
153153
writeJSON(http.StatusOK, w, &headersResponse{
154-
Headers: getRequestHeaders(r),
154+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
155155
})
156156
}
157157

@@ -538,7 +538,7 @@ func (h *HTTPBin) Stream(w http.ResponseWriter, r *http.Request) {
538538

539539
resp := &streamResponse{
540540
Args: r.URL.Query(),
541-
Headers: getRequestHeaders(r),
541+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
542542
Origin: getClientIP(r),
543543
URL: getURL(r).String(),
544544
}
@@ -783,7 +783,7 @@ func (h *HTTPBin) ETag(w http.ResponseWriter, r *http.Request) {
783783
var buf bytes.Buffer
784784
mustMarshalJSON(&buf, noBodyResponse{
785785
Args: r.URL.Query(),
786-
Headers: getRequestHeaders(r),
786+
Headers: getRequestHeaders(r, h.excludeHeadersProcessor),
787787
Method: r.Method,
788788
Origin: getClientIP(r),
789789
URL: getURL(r).String(),

httpbin/handlers_test.go

+31
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func TestMain(m *testing.M) {
6161
WithMaxBodySize(maxBodySize),
6262
WithMaxDuration(maxDuration),
6363
WithObserver(StdLogObserver(log.New(io.Discard, "", 0))),
64+
WithExcludeHeaders("x-ignore-*,x-info-this-key"),
6465
)
6566
srv, client = newTestServer(app)
6667
defer srv.Close()
@@ -167,6 +168,28 @@ func TestGet(t *testing.T) {
167168
assert.Equal(t, result.Method, "GET", "method mismatch")
168169
})
169170

171+
t.Run("will ignore specific headers", func(t *testing.T) {
172+
t.Parallel()
173+
174+
params := url.Values{}
175+
params.Set("foo", "foo")
176+
params.Add("bar", "bar1")
177+
params.Add("bar", "bar2")
178+
179+
header := http.Header{}
180+
181+
header.Set("X-Ignore-Foo", "foo")
182+
header.Set("X-Info-Foo", "bar")
183+
header.Set("x-info-this-key", "baz")
184+
185+
result := doGetRequest(t, "/get", params, header)
186+
assert.Equal(t, result.Args.Encode(), params.Encode(), "args mismatch")
187+
assert.Equal(t, result.Method, "GET", "method mismatch")
188+
assertHeaderEqual(t, &result.Headers, "X-Ignore-Foo", "")
189+
assertHeaderEqual(t, &result.Headers, "x-info-this-key", "")
190+
assertHeaderEqual(t, &result.Headers, "X-Info-Foo", "bar")
191+
})
192+
170193
t.Run("only_allows_gets", func(t *testing.T) {
171194
t.Parallel()
172195

@@ -2920,3 +2943,11 @@ func consumeAndCloseBody(resp *http.Response) {
29202943
_, _ = io.Copy(io.Discard, resp.Body)
29212944
resp.Body.Close()
29222945
}
2946+
2947+
func assertHeaderEqual(t *testing.T, header *http.Header, key, want string) {
2948+
t.Helper()
2949+
got := header.Get(key)
2950+
if want != got {
2951+
t.Fatalf("expected header %s=%#v, got %#v", key, want, got)
2952+
}
2953+
}

httpbin/helpers.go

+64-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"mime/multipart"
1414
"net/http"
1515
"net/url"
16+
"regexp"
1617
"strconv"
1718
"strings"
1819
"sync"
@@ -28,12 +29,15 @@ const Base64MaxLen = 2000
2829
// This is necessary to ensure that the incoming Host and Transfer-Encoding
2930
// headers are included, because golang only exposes those values on the
3031
// http.Request struct itself.
31-
func getRequestHeaders(r *http.Request) http.Header {
32+
func getRequestHeaders(r *http.Request, fn headersProcessorFunc) http.Header {
3233
h := r.Header
3334
h.Set("Host", r.Host)
3435
if len(r.TransferEncoding) > 0 {
3536
h.Set("Transfer-Encoding", strings.Join(r.TransferEncoding, ","))
3637
}
38+
if fn != nil {
39+
return fn(h)
40+
}
3741
return h
3842
}
3943

@@ -433,3 +437,62 @@ func (b *base64Helper) Encode() ([]byte, error) {
433437
func (b *base64Helper) Decode() ([]byte, error) {
434438
return base64.URLEncoding.DecodeString(b.data)
435439
}
440+
441+
func wildCardToRegexp(pattern string) string {
442+
components := strings.Split(pattern, "*")
443+
if len(components) == 1 {
444+
// if len is 1, there are no *'s, return exact match pattern
445+
return "^" + pattern + "$"
446+
}
447+
var result strings.Builder
448+
for i, literal := range components {
449+
450+
// Replace * with .*
451+
if i > 0 {
452+
result.WriteString(".*")
453+
}
454+
455+
// Quote any regular expression meta characters in the
456+
// literal text.
457+
result.WriteString(regexp.QuoteMeta(literal))
458+
}
459+
return "^" + result.String() + "$"
460+
}
461+
462+
func createExcludeHeadersProcessor(excludeRegex *regexp.Regexp) headersProcessorFunc {
463+
return func(headers http.Header) http.Header {
464+
result := make(http.Header)
465+
for k, v := range headers {
466+
matched := excludeRegex.Match([]byte(k))
467+
if matched {
468+
continue
469+
}
470+
result[k] = v
471+
}
472+
473+
return result
474+
}
475+
}
476+
477+
func createFullExcludeRegex(excludeHeaders string) *regexp.Regexp {
478+
// comma separated list of headers to exclude from response
479+
tmp := strings.Split(excludeHeaders, ",")
480+
481+
tmpRegexStrings := make([]string, 0)
482+
for _, v := range tmp {
483+
s := strings.TrimSpace(v)
484+
if len(s) == 0 {
485+
continue
486+
}
487+
pattern := wildCardToRegexp(s)
488+
tmpRegexStrings = append(tmpRegexStrings, pattern)
489+
}
490+
491+
if len(tmpRegexStrings) > 0 {
492+
tmpRegexStr := strings.Join(tmpRegexStrings, "|")
493+
result := regexp.MustCompile("(?i)" + "(" + tmpRegexStr + ")")
494+
return result
495+
}
496+
497+
return nil
498+
}

httpbin/helpers_test.go

+109
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"mime/multipart"
99
"net/http"
1010
"net/url"
11+
"regexp"
1112
"testing"
1213
"time"
1314

@@ -276,3 +277,111 @@ func TestParseFileDoesntExist(t *testing.T) {
276277
t.Fatalf("Open(nonexist): error is %T, want *PathError", err)
277278
}
278279
}
280+
281+
func TestWildcardHelpers(t *testing.T) {
282+
tests := []struct {
283+
pattern string
284+
name string
285+
input string
286+
expected bool
287+
}{
288+
{
289+
"info-*",
290+
"basic test",
291+
"info-foo",
292+
true,
293+
},
294+
{
295+
"info-*",
296+
"basic test case insensitive",
297+
"INFO-bar",
298+
true,
299+
},
300+
{
301+
"info-*-foo",
302+
"a single wildcard in the middle of the string",
303+
"INFO-bar-foo",
304+
true,
305+
},
306+
{
307+
"info-*-foo",
308+
"a single wildcard in the middle of the string",
309+
"INFO-bar-baz",
310+
false,
311+
},
312+
{
313+
"info-*-foo-*-bar",
314+
"multiple wildcards in the string",
315+
"info-aaa-foo--bar",
316+
true,
317+
},
318+
{
319+
"info-*-foo-*-bar",
320+
"multiple wildcards in the string",
321+
"info-aaa-foo-a-bar",
322+
true,
323+
},
324+
{
325+
"info-*-foo-*-bar",
326+
"multiple wildcards in the string",
327+
"info-aaa-foo--bar123",
328+
false,
329+
},
330+
}
331+
332+
for _, test := range tests {
333+
t.Run(test.name, func(t *testing.T) {
334+
tmpRegexStr := wildCardToRegexp(test.pattern)
335+
regex := regexp.MustCompile("(?i)" + "(" + tmpRegexStr + ")")
336+
matched := regex.Match([]byte(test.input))
337+
assert.Equal(t, matched, test.expected, "incorrect match")
338+
})
339+
}
340+
}
341+
342+
func TestCreateFullExcludeRegex(t *testing.T) {
343+
// tolerate unused comma
344+
excludeHeaders := "x-ignore-*,x-info-this-key,,"
345+
regex := createFullExcludeRegex(excludeHeaders)
346+
tests := []struct {
347+
name string
348+
input string
349+
expected bool
350+
}{
351+
{
352+
"basic test",
353+
"x-ignore-foo",
354+
true,
355+
},
356+
{
357+
"basic test case insensitive",
358+
"X-IGNORE-bar",
359+
true,
360+
},
361+
{
362+
"basic test 3",
363+
"x-info-this-key",
364+
true,
365+
},
366+
{
367+
"basic test 4",
368+
"foo-bar",
369+
false,
370+
},
371+
{
372+
"basic test 5",
373+
"x-info-this-key-foo",
374+
false,
375+
},
376+
}
377+
378+
for _, test := range tests {
379+
t.Run(test.name, func(t *testing.T) {
380+
matched := regex.Match([]byte(test.input))
381+
assert.Equal(t, matched, test.expected, "incorrect match")
382+
})
383+
}
384+
385+
nilReturn := createFullExcludeRegex("")
386+
assert.Equal(t, nilReturn, nil, "incorrect match")
387+
}

0 commit comments

Comments
 (0)