CORS support (#73)
This adds per-listener CORS support. Logic is ported from Vault, along with a fully new test suite.
This commit is contained in:
1 parent
66978d637a
commit
fafdcbbfa4
9 files changed
+423
-37
No files matched your search
@@ -0,0 +1,220 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/hashicorp/watchtower/internal/cmd/config"
|
||||
)
|
||||
|
||||
const corsTestConfig = `
|
||||
disable_mlock = true
|
||||
|
||||
telemetry {
|
||||
prometheus_retention_time = "24h"
|
||||
disable_hostname = true
|
||||
}
|
||||
|
||||
kms "aead" {
|
||||
purpose = "controller"
|
||||
aead_type = "aes-gcm"
|
||||
key = "09iqFxRJNYsl/b8CQxjnGw=="
|
||||
}
|
||||
|
||||
kms "aead" {
|
||||
purpose = "worker-auth"
|
||||
aead_type = "aes-gcm"
|
||||
key = "09iqFxRJNYsl/b8CQxjnGw=="
|
||||
}
|
||||
|
||||
listener "tcp" {
|
||||
purpose = "api"
|
||||
tls_disable = true
|
||||
cors_enabled = false
|
||||
}
|
||||
|
||||
listener "tcp" {
|
||||
purpose = "api"
|
||||
tls_disable = true
|
||||
cors_enabled = true
|
||||
cors_allowed_origins = []
|
||||
}
|
||||
|
||||
listener "tcp" {
|
||||
purpose = "api"
|
||||
tls_disable = true
|
||||
cors_enabled = true
|
||||
cors_allowed_origins = ["foobar.com", "barfoo.com"]
|
||||
}
|
||||
|
||||
listener "tcp" {
|
||||
purpose = "api"
|
||||
tls_disable = true
|
||||
cors_enabled = true
|
||||
cors_allowed_origins = ["*"]
|
||||
cors_allowed_headers = ["x-foobar"]
|
||||
}
|
||||
`
|
||||
|
||||
func TestHandler_CORS(t *testing.T) {
|
||||
cfg, err := config.Parse(corsTestConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc := NewTestController(t, &TestControllerOpts{
|
||||
Config: cfg,
|
||||
DefaultOrgId: "o_1234567890",
|
||||
})
|
||||
defer tc.Shutdown()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
origin string
|
||||
code int
|
||||
acrmHeader string
|
||||
allowedHeader string
|
||||
listenerNum int
|
||||
}{
|
||||
{
|
||||
"disabled no origin",
|
||||
http.MethodPost,
|
||||
"",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
},
|
||||
{
|
||||
"disabled with origin",
|
||||
http.MethodPost,
|
||||
"foobar.com",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
},
|
||||
{
|
||||
"enabled with no allowed origins and no origin defined",
|
||||
http.MethodPost,
|
||||
"",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
2,
|
||||
},
|
||||
{
|
||||
"enabled with no allowed origins and origin defined",
|
||||
http.MethodPost,
|
||||
"foobar.com",
|
||||
http.StatusForbidden,
|
||||
"",
|
||||
"",
|
||||
2,
|
||||
},
|
||||
{
|
||||
"enabled with allowed origins and no origin defined",
|
||||
http.MethodPost,
|
||||
"",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
3,
|
||||
},
|
||||
{
|
||||
"enabled with allowed origins and bad origin defined",
|
||||
http.MethodPost,
|
||||
"flubber.com",
|
||||
http.StatusForbidden,
|
||||
"",
|
||||
"",
|
||||
3,
|
||||
},
|
||||
{
|
||||
"enabled with allowed origins and good origin defined",
|
||||
http.MethodPost,
|
||||
"barfoo.com",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
3,
|
||||
},
|
||||
{
|
||||
"enabled with wildcard origins and no origin defined",
|
||||
http.MethodPost,
|
||||
"",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
4,
|
||||
},
|
||||
{
|
||||
"enabled with wildcard origins and origin defined",
|
||||
http.MethodPost,
|
||||
"flubber.com",
|
||||
http.StatusOK,
|
||||
"",
|
||||
"",
|
||||
4,
|
||||
},
|
||||
{
|
||||
"wildcard origins with method list and good method",
|
||||
http.MethodOptions,
|
||||
"flubber.com",
|
||||
http.StatusNoContent,
|
||||
"DELETE",
|
||||
"",
|
||||
4,
|
||||
},
|
||||
{
|
||||
"wildcard origins with method list and bad method",
|
||||
http.MethodOptions,
|
||||
"flubber.com",
|
||||
http.StatusMethodNotAllowed,
|
||||
"BADSTUFF",
|
||||
"X-Foobar",
|
||||
4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a client with the right address
|
||||
client := tc.Client()
|
||||
client.SetAddr(tc.ApiAddrs()[c.listenerNum-1])
|
||||
|
||||
// Create the request
|
||||
req, err := client.NewRequest(tc.Context(), c.method, "orgs/o_1234567890/projects", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Append headers
|
||||
if c.origin != "" {
|
||||
req.Header.Add("Origin", c.origin)
|
||||
}
|
||||
if c.acrmHeader != "" {
|
||||
req.Header.Add("Access-Control-Request-Method", c.acrmHeader)
|
||||
}
|
||||
|
||||
// Run the request, do basic checks
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c.code, resp.HttpResponse().StatusCode)
|
||||
|
||||
// If options and we expect it to be successful, run some checks
|
||||
if req.Method == http.MethodOptions && c.code == http.StatusNoContent {
|
||||
assert.Equal(t, fmt.Sprintf("%s, %s, %s, %s, %s", http.MethodDelete, http.MethodGet, http.MethodOptions, http.MethodPost, http.MethodPatch), resp.HttpResponse().Header.Get("Access-Control-Allow-Methods"))
|
||||
assert.Equal(t, fmt.Sprintf("%s, %s, %s, %s", "Content-Type", "X-Requested-With", "Authorization", "X-Foobar"), resp.HttpResponse().Header.Get("Access-Control-Allow-Headers"))
|
||||
assert.Equal(t, "300", resp.HttpResponse().Header.Get("Access-Control-Max-Age"))
|
||||
}
|
||||
|
||||
// If origin was set and we expect it to be successful, run some more checks
|
||||
if c.origin != "" && c.code == http.StatusOK && c.listenerNum > 1 {
|
||||
assert.Equal(t, c.origin, resp.HttpResponse().Header.Get("Access-Control-Allow-Origin"))
|
||||
assert.Equal(t, "Origin", resp.HttpResponse().Header.Get("Vary"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/runtime"
|
||||
"github.com/hashicorp/vault/internalshared/configutil"
|
||||
"github.com/hashicorp/vault/sdk/helper/strutil"
|
||||
"github.com/hashicorp/watchtower/api"
|
||||
"github.com/hashicorp/watchtower/globals"
|
||||
"github.com/hashicorp/watchtower/internal/gen/controller/api/services"
|
||||
"github.com/hashicorp/watchtower/internal/servers/controller/handlers/host_catalogs"
|
||||
@@ -29,9 +32,10 @@ func (c *Controller) handler(props HandlerProperties) http.Handler {
|
||||
|
||||
mux.Handle("/v1/", handleGrpcGateway(c))
|
||||
|
||||
genericWrappedHandler := wrapGenericHandler(mux, c, props)
|
||||
corsWrappedHandler := wrapHandlerWithCors(mux, props)
|
||||
commonWrappedHandler := wrapHandlerWithCommonFuncs(corsWrappedHandler, c, props)
|
||||
|
||||
return genericWrappedHandler
|
||||
return commonWrappedHandler
|
||||
}
|
||||
|
||||
func handleGrpcGateway(c *Controller) http.Handler {
|
||||
@@ -47,7 +51,7 @@ func handleGrpcGateway(c *Controller) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
func wrapGenericHandler(h http.Handler, c *Controller, props HandlerProperties) http.Handler {
|
||||
func wrapHandlerWithCommonFuncs(h http.Handler, c *Controller, props HandlerProperties) http.Handler {
|
||||
var maxRequestDuration time.Duration
|
||||
var maxRequestSize int64
|
||||
if props.ListenerConfig != nil {
|
||||
@@ -94,6 +98,86 @@ func wrapGenericHandler(h http.Handler, c *Controller, props HandlerProperties)
|
||||
})
|
||||
}
|
||||
|
||||
func wrapHandlerWithCors(h http.Handler, props HandlerProperties) http.Handler {
|
||||
allowedMethods := []string{
|
||||
http.MethodDelete,
|
||||
http.MethodGet,
|
||||
http.MethodOptions,
|
||||
http.MethodPost,
|
||||
http.MethodPatch,
|
||||
}
|
||||
|
||||
allowedOrigins := props.ListenerConfig.CorsAllowedOrigins
|
||||
|
||||
allowedHeaders := append([]string{
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Authorization",
|
||||
}, props.ListenerConfig.CorsAllowedHeaders...)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if !props.ListenerConfig.CorsEnabled {
|
||||
h.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
origin := req.Header.Get("Origin")
|
||||
|
||||
if origin == "" {
|
||||
// Serve directly
|
||||
h.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Check origin
|
||||
var valid bool
|
||||
switch {
|
||||
case len(allowedOrigins) == 0:
|
||||
// not valid
|
||||
|
||||
case len(allowedOrigins) == 1 && allowedOrigins[0] == "*":
|
||||
valid = true
|
||||
|
||||
default:
|
||||
valid = strutil.StrListContains(allowedOrigins, origin)
|
||||
}
|
||||
if !valid {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
err := &api.Error{
|
||||
Status: api.Int(http.StatusForbidden),
|
||||
Code: api.String("origin forbidden"),
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.Encode(err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Method == http.MethodOptions &&
|
||||
!strutil.StrListContains(allowedMethods, req.Header.Get("Access-Control-Request-Method")) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
|
||||
// Apply headers for preflight requests
|
||||
if req.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(allowedMethods, ", "))
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(allowedHeaders, ", "))
|
||||
w.Header().Set("Access-Control-Max-Age", "300")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, req)
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
func WrapForwardedForHandler(h http.Handler, authorizedAddrs []*sockaddr.SockAddrMarshaler, rejectNotPresent, rejectNonAuthz bool, hopSkips int) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -48,3 +48,4 @@ func TestHandleGrpcGateway(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type TestController struct {
|
||||
b *base.Server
|
||||
c *Controller
|
||||
t *testing.T
|
||||
addr string // The address the Controller API is listening on
|
||||
addrs []string // The address the Controller API is listening on
|
||||
client *api.Client
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -42,27 +42,23 @@ func (tc *TestController) Cancel() {
|
||||
tc.cancel()
|
||||
}
|
||||
|
||||
func (tc *TestController) ApiAddress() string {
|
||||
if tc.addr != "" {
|
||||
return tc.addr
|
||||
}
|
||||
var apiLn *base.ServerListener
|
||||
for _, listener := range tc.b.Listeners {
|
||||
if listener.Config.Purpose[0] == "api" {
|
||||
apiLn = listener
|
||||
break
|
||||
}
|
||||
}
|
||||
if apiLn == nil {
|
||||
tc.t.Fatal("could not find api listener")
|
||||
func (tc *TestController) ApiAddrs() []string {
|
||||
if tc.addrs != nil {
|
||||
return tc.addrs
|
||||
}
|
||||
|
||||
tcpAddr, ok := apiLn.Mux.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
tc.t.Fatal("could not parse address as a TCP addr")
|
||||
for _, listener := range tc.b.Listeners {
|
||||
if listener.Config.Purpose[0] == "api" {
|
||||
tcpAddr, ok := listener.Mux.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
tc.t.Fatal("could not parse address as a TCP addr")
|
||||
}
|
||||
addr := fmt.Sprintf("http://%s:%d", tcpAddr.IP.String(), tcpAddr.Port)
|
||||
tc.addrs = append(tc.addrs, addr)
|
||||
}
|
||||
}
|
||||
tc.addr = fmt.Sprintf("http://%s:%d", tcpAddr.IP.String(), tcpAddr.Port)
|
||||
return tc.addr
|
||||
|
||||
return tc.addrs
|
||||
}
|
||||
|
||||
func (tc *TestController) buildClient() {
|
||||
@@ -70,7 +66,11 @@ func (tc *TestController) buildClient() {
|
||||
if err != nil {
|
||||
tc.t.Fatal(fmt.Errorf("error creating client: %w", err))
|
||||
}
|
||||
if err := client.SetAddr(tc.ApiAddress()); err != nil {
|
||||
apiAddrs := tc.ApiAddrs()
|
||||
if len(apiAddrs) == 0 {
|
||||
tc.t.Fatal("no API addresses found")
|
||||
}
|
||||
if err := client.SetAddr(apiAddrs[0]); err != nil {
|
||||
tc.t.Fatal(fmt.Errorf("error setting client address: %w", err))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user