Repository: clouditor/clouditor
Pull request #477 (2022-02-02 17:57:38)

Add support for CORS in the REST API
This PR adds support for CORS in the REST API

codecov[bot]: # [Codecov](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor) Report
> Merging [#477](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor) (4285c81) into [main](https://codecov.io/gh/clouditor/clouditor/commit/ef02d75a70ddb4d77d118f8e7689f534dc7681ef?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor) (ef02d75) will **decrease** coverage by `0.83%`.
> The diff coverage is `19.67%`.

[![Impacted file tree graph](https://codecov.io/gh/clouditor/clouditor/pull/477/graphs/tree.svg?width=650&height=150&src=pr&token=syrXsw5LTX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor)](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor)

```diff
@@            Coverage Diff             @@
##             main     #477      +/-   ##
==========================================
- Coverage   76.36%   75.52%   -0.84%     
==========================================
  Files          44       44              
  Lines        3795     3853      +58     
==========================================
+ Hits         2898     2910      +12     
- Misses        766      812      +46     
  Partials      131      131              
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `75.52% <19.67%> (-0.84%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor) | Coverage Δ | |
|---|---|---|
| [rest/rest.go](https://codecov.io/gh/clouditor/clouditor/pull/477/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor#diff-cmVzdC9yZXN0Lmdv) | `8.69% <12.50%> (+4.34%)` | :arrow_up: |
| [cmd/engine/engine.go](https://codecov.io/gh/clouditor/clouditor/pull/477/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor#diff-Y21kL2VuZ2luZS9lbmdpbmUuZ28=) | `23.52% <46.15%> (+2.25%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor). Last update [ef02d75...4285c81](https://codecov.io/gh/clouditor/clouditor/pull/477?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clouditor).


Patch:
diff --git a/cmd/engine/engine.go b/cmd/engine/engine.go
index 7d91c99d..121d90d3 100644
--- a/cmd/engine/engine.go
+++ b/cmd/engine/engine.go
@@ -61,18 +61,21 @@ import (
 )
 
 const (
-	APIDefaultUserFlag     = "api-default-user"
-	APIDefaultPasswordFlag = "api-default-password"
-	APISecretFlag          = "api-secret"
-	APIgRPCPortFlag        = "api-grpc-port"
-	APIHTTPPortFlag        = "api-http-port"
-	DBUserNameFlag         = "db-user-name"
-	DBPasswordFlag         = "db-password"
-	DBHostFlag             = "db-host"
-	DBNameFlag             = "db-name"
-	DBPortFlag             = "db-port"
-	DBInMemoryFlag         = "db-in-memory"
-	CreateDefaultTarget    = "target-default-create"
+	APIDefaultUserFlag         = "api-default-user"
+	APIDefaultPasswordFlag     = "api-default-password"
+	APISecretFlag              = "api-secret"
+	APIgRPCPortFlag            = "api-grpc-port"
+	APIHTTPPortFlag            = "api-http-port"
+	APICORSAllowedOriginsFlags = "api-cors-allowed-origins"
+	APICORSAllowedHeadersFlags = "api-cors-allowed-headers"
+	APICORSAllowedMethodsFlags = "api-cors-allowed-methods"
+	DBUserNameFlag             = "db-user-name"
+	DBPasswordFlag             = "db-password"
+	DBHostFlag                 = "db-host"
+	DBNameFlag                 = "db-name"
+	DBPortFlag                 = "db-port"
+	DBInMemoryFlag             = "db-in-memory"
+	CreateDefaultTarget        = "target-default-create"
 
 	DefaultAPIDefaultUser      = "clouditor"
 	DefaultAPIDefaultPassword  = "clouditor"
@@ -116,6 +119,9 @@ func init() {
 	engineCmd.Flags().String(APISecretFlag, DefaultAPISecret, "Specifies the secret used by API tokens")
 	engineCmd.Flags().Int16(APIgRPCPortFlag, DefaultAPIgRPCPort, "Specifies the port used for the gRPC API")
 	engineCmd.Flags().Int16(APIHTTPPortFlag, DefaultAPIHTTPPort, "Specifies the port used for the HTTP API")
+	engineCmd.Flags().StringArray(APICORSAllowedOriginsFlags, rest.DefaultAllowedOrigins, "Specifies the origins allowed in CORS")
+	engineCmd.Flags().StringArray(APICORSAllowedHeadersFlags, rest.DefaultAllowedHeaders, "Specifies the headers allowed in CORS")
+	engineCmd.Flags().StringArray(APICORSAllowedMethodsFlags, rest.DefaultAllowedMethods, "Specifies the methods allowed in CORS")
 	engineCmd.Flags().String(DBUserNameFlag, DefaultDBUserName, "Provides user name of database")
 	engineCmd.Flags().String(DBPasswordFlag, DefaultDBPassword, "Provides password of database")
 	engineCmd.Flags().String(DBHostFlag, DefaultDBHost, "Provides address of database")
@@ -129,6 +135,9 @@ func init() {
 	_ = viper.BindPFlag(APISecretFlag, engineCmd.Flags().Lookup(APISecretFlag))
 	_ = viper.BindPFlag(APIgRPCPortFlag, engineCmd.Flags().Lookup(APIgRPCPortFlag))
 	_ = viper.BindPFlag(APIHTTPPortFlag, engineCmd.Flags().Lookup(APIHTTPPortFlag))
+	_ = viper.BindPFlag(APICORSAllowedOriginsFlags, engineCmd.Flags().Lookup(APICORSAllowedOriginsFlags))
+	_ = viper.BindPFlag(APICORSAllowedHeadersFlags, engineCmd.Flags().Lookup(APICORSAllowedHeadersFlags))
+	_ = viper.BindPFlag(APICORSAllowedMethodsFlags, engineCmd.Flags().Lookup(APICORSAllowedMethodsFlags))
 	_ = viper.BindPFlag(DBUserNameFlag, engineCmd.Flags().Lookup(DBUserNameFlag))
 	_ = viper.BindPFlag(DBPasswordFlag, engineCmd.Flags().Lookup(DBPasswordFlag))
 	_ = viper.BindPFlag(DBHostFlag, engineCmd.Flags().Lookup(DBHostFlag))
@@ -233,7 +242,13 @@ func doCmd(_ *cobra.Command, _ []string) (err error) {
 
 	// start the gRPC-HTTP gateway
 	go func() {
-		err = rest.RunServer(context.Background(), grpcPort, httpPort)
+		err = rest.RunServer(context.Background(),
+			grpcPort,
+			httpPort,
+			rest.WithAllowedOrigins(viper.GetStringSlice(APICORSAllowedOriginsFlags)),
+			rest.WithAllowedHeaders(viper.GetStringSlice(APICORSAllowedHeadersFlags)),
+			rest.WithAllowedMethods(viper.GetStringSlice(APICORSAllowedMethodsFlags)),
+		)
 		if errors.Is(err, http.ErrServerClosed) {
 			// ToDo(oxisto): deepsource anti-pattern: calls to os.Exit only in main() or init() functions
 			os.Exit(0)
diff --git a/rest/rest.go b/rest/rest.go
index e5d0ee2f..0b970ea4 100644
--- a/rest/rest.go
+++ b/rest/rest.go
@@ -1,4 +1,4 @@
-// Copyright 2016-2020 Fraunhofer AISEC
+// Copyright 2016-2022 Fraunhofer AISEC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -27,10 +27,13 @@ package rest
 
 import (
 	"context"
+	"errors"
 	"fmt"
+	"net"
 	"net/http"
 	"os"
 	"os/signal"
+	"strings"
 	"time"
 
 	"clouditor.io/clouditor/api/evidence"
@@ -45,16 +48,91 @@ import (
 	"google.golang.org/grpc/credentials/insecure"
 )
 
-var log *logrus.Entry
+var (
+	log *logrus.Entry
+
+	// srv holds the global http.Server of our REST API.
+	srv *http.Server
+
+	// sock holds the listener socket of our REST API.
+	sock net.Listener
+
+	ready = make(chan bool)
+
+	// cors holds the global CORS configuration.
+	cors *corsConfig
+)
+
+// corsConfig holds all necessary configuration options for Cross-Origin Resource Sharing of our REST API.
+type corsConfig struct {
+	// allowedOrigins contains a list of the allowed origins
+	allowedOrigins []string
+
+	// allowedHeaders contains a list of the allowed headers
+	allowedHeaders []string
+
+	// allowedMethods contains a list of the allowed methods
+	allowedMethods []string
+}
+
+// CORSConfigOption represents functional-style options to modify the CORS configuration in RunServer.
+type CORSConfigOption func(*corsConfig)
+
+var (
+	// DefaultAllowedOrigins contains a nil slice, as per default, no origins are allowed.
+	DefaultAllowedOrigins []string = nil
+
+	// DefaultAllowedHeaders contains sensible defaults for the Access-Control-Allow-Headers header.
+	// Please adjust accordingly in production using WithAllowedHeaders.
+	DefaultAllowedHeaders = []string{"Content-Type", "Accept", "Authorization"}
+
+	// DefaultAllowedMethods contains sensible defaults for the Access-Control-Allow-Methods header.
+	// Please adjust accordingly in production using WithAllowedMethods.
+	DefaultAllowedMethods = []string{"GET", "POST", "PUT", "DELETE"}
+)
+
+// WithAllowedOrigins is an option to supply allowed origins in CORS.
+func WithAllowedOrigins(origins []string) CORSConfigOption {
+	return func(cc *corsConfig) {
+		cc.allowedOrigins = origins
+	}
+}
+
+// WithAllowedHeaders is an option to supply allowed headers in CORS.
+func WithAllowedHeaders(headers []string) CORSConfigOption {
+	return func(cc *corsConfig) {
+		cc.allowedHeaders = headers
+	}
+}
+
+// WithAllowedMethods is an option to supply allowed methods in CORS.
+func WithAllowedMethods(methods []string) CORSConfigOption {
+	return func(cc *corsConfig) {
+		cc.allowedMethods = methods
+	}
+}
 
 func init() {
 	log = logrus.WithField("component", "rest")
+
+	// initialize the CORS config with restrictive default values, e.g. no origin allowed
+	cors = &corsConfig{
+		allowedOrigins: DefaultAllowedOrigins,
+		allowedHeaders: DefaultAllowedHeaders,
+		allowedMethods: DefaultAllowedMethods,
+	}
 }
 
-func RunServer(ctx context.Context, grpcPort int, httpPort int) error {
+// RunServer starts our REST API. The REST API is a reverse proxy using grpc-gateway that
+// exposes certain gRPC calls as RESTful HTTP methods.
+func RunServer(ctx context.Context, grpcPort int, httpPort int, corsOpts ...CORSConfigOption) (err error) {
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
+	for _, o := range corsOpts {
+		o(cors)
+	}
+
 	mux := runtime.NewServeMux()
 
 	opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
@@ -79,9 +157,9 @@ func RunServer(ctx context.Context, grpcPort int, httpPort int) error {
 		return fmt.Errorf("failed to connect to evidence gRPC service %w", err)
 	}
 
-	srv := &http.Server{
+	srv = &http.Server{
 		Addr:    fmt.Sprintf(":%d", httpPort),
-		Handler: mux,
+		Handler: handleCORS(mux),
 	}
 
 	// graceful shutdown
@@ -93,6 +171,8 @@ func RunServer(ctx context.Context, grpcPort int, httpPort int) error {
 			break
 		}
 
+		ready <- false
+
 		_, cancel := context.WithTimeout(ctx, 5*time.Second)
 		defer cancel()
 
@@ -103,5 +183,61 @@ func RunServer(ctx context.Context, grpcPort int, httpPort int) error {
 
 	log.Printf("Starting REST gateway on :%d", httpPort)
 
-	return srv.ListenAndServe()
+	sock, err = net.Listen("tcp", srv.Addr)
+	if err != nil {
+		return err
+	}
+
+	ready <- true
+
+	return srv.Serve(sock)
+}
+
+// GetServerPort returns the actual port used by the REST API
+func GetServerPort() (int, error) {
+	if sock == nil {
+		return 0, errors.New("server socket is empty")
+	}
+
+	tcpAddr, ok := sock.Addr().(*net.TCPAddr)
+
+	if !ok {
+		return 0, errors.New("server socket address is not a TCP address")
+	}
+
+	return tcpAddr.Port, nil
+}
+
+// handleCORS adds an appropriate http.HandlerFunc to an existing http.Handler to configure
+// Cross-Origin Resource Sharing (CORS) according to our global configuration.
+func handleCORS(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// Check, if we allow this specific origin
+		origin := r.Header.Get("Origin")
+		if originAllowed(origin) {
+			// Set the appropriate access control header
+			w.Header().Set("Access-Control-Allow-Origin", origin)
+			w.Header().Add("Vary", "Origin")
+
+			// Additionally, we need to handle preflight (OPTIONS) requests to specify allowed headers and methods
+			if r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
+				w.Header().Set("Access-Control-Allow-Headers", strings.Join(cors.allowedHeaders, ","))
+				w.Header().Set("Access-Control-Allow-Methods", strings.Join(cors.allowedMethods, ","))
+				return
+			}
+		}
+
+		h.ServeHTTP(w, r)
+	})
+}
+
+// originAllowed checks if the supplied origin is allowed according to our global CORS configuration.
+func originAllowed(origin string) bool {
+	for _, v := range cors.allowedOrigins {
+		if origin == v {
+			return true
+		}
+	}
+
+	return false
 }


Test patch:
diff --git a/rest/rest_test.go b/rest/rest_test.go
new file mode 100644
index 00000000..68e2421d
--- /dev/null
+++ b/rest/rest_test.go
@@ -0,0 +1,204 @@
+// Copyright 2022 Fraunhofer AISEC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//           $$\                           $$\ $$\   $$\
+//           $$ |                          $$ |\__|  $$ |
+//  $$$$$$$\ $$ | $$$$$$\  $$\   $$\  $$$$$$$ |$$\ $$$$$$\    $$$$$$\   $$$$$$\
+// $$  _____|$$ |$$  __$$\ $$ |  $$ |$$  __$$ |$$ |\_$$  _|  $$  __$$\ $$  __$$\
+// $$ /      $$ |$$ /  $$ |$$ |  $$ |$$ /  $$ |$$ |  $$ |    $$ /  $$ |$$ | \__|
+// $$ |      $$ |$$ |  $$ |$$ |  $$ |$$ |  $$ |$$ |  $$ |$$\ $$ |  $$ |$$ |
+// \$$$$$$\  $$ |\$$$$$   |\$$$$$   |\$$$$$$  |$$ |  \$$$   |\$$$$$   |$$ |
+//  \_______|\__| \______/  \______/  \_______|\__|   \____/  \______/ \__|
+//
+// This file is part of Clouditor Community Edition.
+
+package rest
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"clouditor.io/clouditor/persistence"
+	service_auth "clouditor.io/clouditor/service/auth"
+	"github.com/stretchr/testify/assert"
+	"google.golang.org/grpc"
+)
+
+var (
+	origins = []string{"clouditor.io", "localhost"}
+	methods = []string{"GET", "POST"}
+	headers = DefaultAllowedHeaders
+)
+
+func TestMain(m *testing.M) {
+	var (
+		err    error
+		server *grpc.Server
+		sock   net.Listener
+	)
+
+	// A small embedded DB is needed for the server
+	err = persistence.InitDB(true, "", 0)
+	if err != nil {
+		panic(err)
+	}
+
+	// Start at least an authentication server, so that we have something to forward
+	sock, server, err = service_auth.StartDedicatedAuthServer(":0")
+	if err != nil {
+		panic(err)
+	}
+
+	go func() {
+		err := RunServer(
+			context.Background(),
+			sock.Addr().(*net.TCPAddr).Port,
+			0,
+			WithAllowedOrigins(origins),
+			WithAllowedMethods(methods),
+			WithAllowedHeaders(headers),
+		)
+		if err != nil {
+			panic(err)
+		}
+	}()
+
+	defer sock.Close()
+	defer server.Stop()
+	defer os.Exit(m.Run())
+}
+
+func TestCORS(t *testing.T) {
+	// wait until server is ready to serve
+	select {
+	case <-ready:
+		break
+	case <-time.After(10 * time.Second):
+		log.Println("Timeout while waiting for REST API")
+	}
+
+	assert.NotNil(t, sock)
+
+	port, err := GetServerPort()
+
+	assert.Nil(t, err)
+	assert.NotEqual(t, 0, port)
+
+	type args struct {
+		origin    string
+		method    string
+		preflight bool
+	}
+
+	tests := []struct {
+		name       string
+		args       args
+		statusCode int
+		headers    map[string]string
+	}{
+		{
+			name: "Preflight request from valid origin",
+			args: args{
+				origin:    "clouditor.io",
+				method:    "POST",
+				preflight: true,
+			},
+			statusCode: 200,
+			headers: map[string]string{
+				"Access-Control-Allow-Origin":  "clouditor.io",
+				"Access-Control-Allow-Headers": strings.Join(headers, ","),
+				"Access-Control-Allow-Methods": strings.Join(methods, ","),
+			},
+		},
+		{
+			name: "Actual request from valid origin",
+			args: args{
+				origin:    "clouditor.io",
+				method:    "POST",
+				preflight: false,
+			},
+			statusCode: 401, // because we are not supplying an actual login request
+			headers: map[string]string{
+				"Access-Control-Allow-Origin":  "clouditor.io",
+				"Access-Control-Allow-Headers": "", // should only be part of preflight, not the actual request
+				"Access-Control-Allow-Methods": "", // should only be part of preflight, not the actual request
+			},
+		},
+		{
+			name: "Preflight request from valid origin",
+			args: args{
+				origin:    "clouditor.com",
+				method:    "POST",
+				preflight: true,
+			},
+			statusCode: 501,
+			headers: map[string]string{
+				"Access-Control-Allow-Origin": "", // should not leak any origin
+			},
+		},
+		{
+			name: "Actual request from invalid origin",
+			args: args{
+				origin:    "clouditor.com",
+				method:    "POST",
+				preflight: false,
+			},
+			statusCode: 401, // because we are not supplying an actual login request
+			headers: map[string]string{
+				"Access-Control-Allow-Origin": "", // should not leak any origin
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client := &http.Client{}
+
+			var method string
+			if tt.args.preflight {
+				method = "OPTIONS"
+			} else {
+				method = tt.args.method
+			}
+
+			req, err := http.NewRequest(method, fmt.Sprintf("http://localhost:%d/v1/auth/login", port), nil)
+			assert.Nil(t, err)
+			assert.NotNil(t, req)
+
+			req.Header.Add("Origin", tt.args.origin)
+
+			if tt.args.preflight {
+				req.Header.Add("Access-Control-Request-Method", tt.args.method)
+			}
+
+			resp, err := client.Do(req)
+
+			assert.Equal(t, tt.statusCode, resp.StatusCode)
+
+			assert.Nil(t, err)
+			assert.NotNil(t, resp)
+
+			for key, value := range tt.headers {
+				assert.Equal(t, value, resp.Header.Get(key))
+			}
+		})
+	}
+
+}