CVE-2022-39340 (published 2022-10-25T17:15Z)

OpenFGA is an authorization/permission engine. Prior to version 0.2.4, the `streamed-list-objects` endpoint was not validating the authorization header, resulting in disclosure of objects in the store. Users `openfga/openfga` versions 0.2.3 and prior who are exposing the OpenFGA service to the internet are vulnerable. Version 0.2.4 contains a patch for this issue.

CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-862
cwe_name: Missing Authorization
description: The product does not perform an authorization check when an actor attempts to access a resource or perform an action.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are not applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/openfga/openfga
commit_details: repo_url: https://github.com/openfga/openfga
author: Maria Ines Parnisari
author_date: 2022-10-21 09:19:48-07:00
msg: Merge pull request from GHSA-95x7-mh78-7w2r

* fix: add interceptors for streaming endpoints

* add tests

* remove unnecessary code
repository: repo_url: https://github.com/openfga/openfga
repo_name: openfga/openfga
description: A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar
repo_language: Go
owner: openfga
file_changes: filename: service.go
change_type: ModificationType.MODIFY
diff: @@ -359,11 +359,16 @@ func BuildService(config *Config, logger logger.Logger) (*service, error) {
 		return nil, errors.Errorf("failed to initialize authenticator: %v", err)
 	}
 
-	interceptors := []grpc.UnaryServerInterceptor{
+	unaryServerInterceptors := []grpc.UnaryServerInterceptor{
 		grpc_auth.UnaryServerInterceptor(middleware.AuthFunc(authenticator)),
 		middleware.NewErrorLoggingInterceptor(logger),
 	}
 
+	streamingServerInterceptors := []grpc.StreamServerInterceptor{
+		grpc_auth.StreamServerInterceptor(middleware.AuthFunc(authenticator)),
+		middleware.NewStreamingErrorLoggingInterceptor(logger),
+	}
+
 	grpcHostAddr, grpcHostPort, err := net.SplitHostPort(config.GRPC.Addr)
 	if err != nil {
 		return nil, errors.Errorf("`grpc.addr` config must be in the form [host]:port")
@@ -415,7 +420,8 @@ func BuildService(config *Config, logger logger.Logger) (*service, error) {
 		ChangelogHorizonOffset: config.ChangelogHorizonOffset,
 		ListObjectsDeadline:    config.ListObjectsDeadline,
 		ListObjectsMaxResults:  config.ListObjectsMaxResults,
-		UnaryInterceptors:      interceptors,
+		UnaryInterceptors:      unaryServerInterceptors,
+		StreamingInterceptors:  streamingServerInterceptors,
 		MuxOptions:             nil,
 	})
 	if err != nil {

programming_language: Go
filename: service_test.go
change_type: ModificationType.MODIFY
diff: @@ -7,6 +7,7 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509/pkix"
+	"encoding/json"
 	"encoding/pem"
 	"fmt"
 	"io"
@@ -24,6 +25,7 @@ import (
 	"github.com/hashicorp/go-retryablehttp"
 	"github.com/openfga/openfga/pkg/logger"
 	"github.com/openfga/openfga/server/authn/mocks"
+	serverErrors "github.com/openfga/openfga/server/errors"
 	"github.com/stretchr/testify/require"
 	"github.com/tidwall/gjson"
 	openfgapb "go.buf.build/openfga/go/openfga/api/openfga/v1"
@@ -33,6 +35,7 @@ import (
 	"google.golang.org/grpc/credentials"
 	"google.golang.org/grpc/credentials/insecure"
 	healthv1pb "google.golang.org/grpc/health/grpc_health_v1"
+	"google.golang.org/protobuf/encoding/protojson"
 )
 
 func TestMain(m *testing.M) {
@@ -205,9 +208,10 @@ func createCertsAndKeys(t *testing.T) certHandle {
 }
 
 type authTest struct {
-	_name         string
-	authHeader    string
-	expectedError string
+	_name                 string
+	authHeader            string
+	expectedErrorResponse *serverErrors.ErrorResponse
+	expectedStatusCode    int
 }
 
 func TestBuildServiceWithNoAuth(t *testing.T) {
@@ -251,48 +255,39 @@ func TestBuildServiceWithPresharedKeyAuthentication(t *testing.T) {
 	ensureServiceUp(t, service, nil, true)
 
 	tests := []authTest{{
-		_name:         "Header with incorrect key fails",
-		authHeader:    "Bearer incorrectkey",
-		expectedError: "unauthenticated",
+		_name:      "Header with incorrect key fails",
+		authHeader: "Bearer incorrectkey",
+		expectedErrorResponse: &serverErrors.ErrorResponse{
+			Code:    "unauthenticated",
+			Message: "unauthenticated",
+		},
+		expectedStatusCode: 401,
 	}, {
-		_name:         "Missing header fails",
-		authHeader:    "",
-		expectedError: "missing bearer token",
+		_name:      "Missing header fails",
+		authHeader: "",
+		expectedErrorResponse: &serverErrors.ErrorResponse{
+			Code:    "bearer_token_missing",
+			Message: "missing bearer token",
+		},
+		expectedStatusCode: 401,
 	}, {
-		_name:         "Correct key one succeeds",
-		authHeader:    "Bearer KEYONE",
-		expectedError: "",
+		_name:              "Correct key one succeeds",
+		authHeader:         fmt.Sprintf("Bearer %s", config.Authn.AuthnPresharedKeyConfig.Keys[0]),
+		expectedStatusCode: 200,
 	}, {
-		_name:         "Correct key two succeeds",
-		authHeader:    "Bearer KEYTWO",
-		expectedError: "",
+		_name:              "Correct key two succeeds",
+		authHeader:         fmt.Sprintf("Bearer %s", config.Authn.AuthnPresharedKeyConfig.Keys[1]),
+		expectedStatusCode: 200,
 	}}
 
 	retryClient := retryablehttp.NewClient()
 	for _, test := range tests {
 		t.Run(test._name, func(t *testing.T) {
-			payload := strings.NewReader(`{"name": "some-store-name"}`)
-			req, err := retryablehttp.NewRequest("POST", fmt.Sprintf("http://localhost:%d/stores", service.GetHTTPAddrPort().Port()), payload)
-			require.NoError(t, err, "Failed to construct request")
-			req.Header.Set("content-type", "application/json")
-			req.Header.Set("authorization", test.authHeader)
-
-			res, err := retryClient.Do(req)
-			require.NoError(t, err, "Failed to execute request")
-
-			defer res.Body.Close()
-			body, err := io.ReadAll(res.Body)
-			require.NoError(t, err, "Failed to read response")
-
-			stringBody := string(body)
-
-			if test.expectedError == "" && strings.Contains(stringBody, "code") {
-				t.Fatalf("Expected no error but got '%v'", stringBody)
-			}
+			tryGetStores(t, test, service, retryClient)
+		})
 
-			if !strings.Contains(stringBody, test.expectedError) && test.expectedError != "" {
-				t.Fatalf("Expected '%v' to contain '%v'", stringBody, test.expectedError)
-			}
+		t.Run(test._name+"/streaming", func(t *testing.T) {
+			tryStreamingListObjects(t, test, service, retryClient, config.Authn.AuthnPresharedKeyConfig.Keys[0])
 		})
 	}
 
@@ -301,6 +296,75 @@ func TestBuildServiceWithPresharedKeyAuthentication(t *testing.T) {
 	require.NoError(t, g.Wait())
 }
 
+func tryStreamingListObjects(t *testing.T, test authTest, service *service, retryClient *retryablehttp.Client, validToken string) {
+	// create a store
+	createStorePayload := strings.NewReader(`{"name": "some-store-name"}`)
+	req, err := retryablehttp.NewRequest("POST", fmt.Sprintf("http://localhost:%d/stores", service.GetHTTPAddrPort().Port()), createStorePayload)
+	require.NoError(t, err, "Failed to construct create store request")
+	req.Header.Set("content-type", "application/json")
+	req.Header.Set("authorization", fmt.Sprintf("Bearer %s", validToken))
+	res, err := retryClient.Do(req)
+	require.NoError(t, err, "Failed to execute create store request")
+	defer res.Body.Close()
+	body, err := io.ReadAll(res.Body)
+	require.NoError(t, err, "Failed to read create store response")
+	var createStoreResponse openfgapb.CreateStoreResponse
+	err = protojson.Unmarshal(body, &createStoreResponse)
+	require.NoError(t, err, "Failed to unmarshal create store response")
+
+	// create an authorization model
+	authModelPayload := strings.NewReader(`{"type_definitions":[{"type":"document","relations":{"owner":{"this":{}}}}]}`)
+	req, err = retryablehttp.NewRequest("POST", fmt.Sprintf("http://localhost:%d/stores/%s/authorization-models", service.GetHTTPAddrPort().Port(), createStoreResponse.Id), authModelPayload)
+	require.NoError(t, err, "Failed to construct create authorization model request")
+	req.Header.Set("content-type", "application/json")
+	req.Header.Set("authorization", fmt.Sprintf("Bearer %s", validToken))
+	_, err = retryClient.Do(req)
+	require.NoError(t, err, "Failed to execute create authorization model request")
+
+	// call one streaming endpoint
+	listObjectsPayload := strings.NewReader(`{"type": "document", "user": "anne", "relation": "owner"}`)
+	req, err = retryablehttp.NewRequest("POST", fmt.Sprintf("http://localhost:%d/stores/%s/streamed-list-objects", service.GetHTTPAddrPort().Port(), createStoreResponse.Id), listObjectsPayload)
+	require.NoError(t, err, "Failed to construct request")
+	req.Header.Set("content-type", "application/json")
+	req.Header.Set("authorization", test.authHeader)
+
+	res, err = retryClient.Do(req)
+	require.Equal(t, test.expectedStatusCode, res.StatusCode)
+	require.NoError(t, err, "Failed to execute streaming request")
+
+	defer res.Body.Close()
+	body, err = io.ReadAll(res.Body)
+	require.NoError(t, err, "Failed to read response")
+
+	if test.expectedErrorResponse != nil {
+		require.Contains(t, string(body), fmt.Sprintf(",\"message\":\"%s\"", test.expectedErrorResponse.Message))
+	}
+}
+
+func tryGetStores(t *testing.T, test authTest, service *service, retryClient *retryablehttp.Client) {
+	req, err := retryablehttp.NewRequest("GET", fmt.Sprintf("http://localhost:%d/stores", service.GetHTTPAddrPort().Port()), nil)
+	require.NoError(t, err, "Failed to construct request")
+	req.Header.Set("content-type", "application/json")
+	req.Header.Set("authorization", test.authHeader)
+
+	res, err := retryClient.Do(req)
+	require.NoError(t, err, "Failed to execute request")
+	require.Equal(t, test.expectedStatusCode, res.StatusCode)
+
+	defer res.Body.Close()
+	body, err := io.ReadAll(res.Body)
+	require.NoError(t, err, "Failed to read response")
+
+	if test.expectedErrorResponse != nil {
+		var actualErrorResponse serverErrors.ErrorResponse
+		err = json.Unmarshal(body, &actualErrorResponse)
+
+		require.NoError(t, err, "Failed to unmarshal response")
+
+		require.Equal(t, test.expectedErrorResponse, &actualErrorResponse)
+	}
+}
+
 func TestHTTPServerWithCORS(t *testing.T) {
 	config, err := DefaultConfigWithRandomPorts()
 	require.NoError(t, err)
@@ -436,43 +500,35 @@ func TestBuildServerWithOIDCAuthentication(t *testing.T) {
 	require.NoError(t, err)
 
 	tests := []authTest{{
-		_name:         "Header with invalid token fails",
-		authHeader:    "Bearer incorrecttoken",
-		expectedError: "invalid bearer token",
+		_name:      "Header with invalid token fails",
+		authHeader: "Bearer incorrecttoken",
+		expectedErrorResponse: &serverErrors.ErrorResponse{
+			Code:    "auth_failed_invalid_bearer_token",
+			Message: "invalid bearer token",
+		},
+		expectedStatusCode: 401,
 	}, {
-		_name:         "Missing header fails",
-		authHeader:    "",
-		expectedError: "missing bearer token",
+		_name:      "Missing header fails",
+		authHeader: "",
+		expectedErrorResponse: &serverErrors.ErrorResponse{
+			Code:    "bearer_token_missing",
+			Message: "missing bearer token",
+		},
+		expectedStatusCode: 401,
 	}, {
-		_name:         "Correct token succeeds",
-		authHeader:    "Bearer " + trustedToken,
-		expectedError: "",
+		_name:              "Correct token succeeds",
+		authHeader:         "Bearer " + trustedToken,
+		expectedStatusCode: 200,
 	}}
 
 	retryClient := retryablehttp.NewClient()
 	for _, test := range tests {
 		t.Run(test._name, func(t *testing.T) {
-			payload := strings.NewReader(`{"name": "some-store-name"}`)
-			req, err := retryablehttp.NewRequest("POST", fmt.Sprintf("http://localhost:%d/stores", service.GetHTTPAddrPort().Port()), payload)
-			require.NoError(t, err, "Failed to construct request")
-			req.Header.Set("content-type", "application/json")
-			req.Header.Set("authorization", test.authHeader)
-
-			res, err := retryClient.Do(req)
-			require.NoError(t, err, "Failed to execute request")
-
-			defer res.Body.Close()
-			body, err := io.ReadAll(res.Body)
-			require.NoError(t, err, "Failed to read response")
-
-			stringBody := string(body)
-			if test.expectedError == "" && strings.Contains(stringBody, "code") {
-				t.Fatalf("Expected no error but got %v", stringBody)
-			}
+			tryGetStores(t, test, service, retryClient)
+		})
 
-			if !strings.Contains(stringBody, test.expectedError) && test.expectedError != "" {
-				t.Fatalf("Expected %v to contain %v", stringBody, test.expectedError)
-			}
+		t.Run(test._name+"/streaming", func(t *testing.T) {
+			tryStreamingListObjects(t, test, service, retryClient, trustedToken)
 		})
 	}
 

programming_language: Go
filename: logging.go
change_type: ModificationType.MODIFY
diff: @@ -25,3 +25,18 @@ func NewErrorLoggingInterceptor(logger logger.Logger) grpc.UnaryServerIntercepto
 		return resp, nil
 	}
 }
+
+func NewStreamingErrorLoggingInterceptor(logger logger.Logger) grpc.StreamServerInterceptor {
+	return func(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+		err := handler(srv, stream)
+		if err != nil {
+			var e error
+			if internalError, ok := err.(serverErrors.InternalError); ok {
+				e = internalError.Internal()
+			}
+			logger.Error("grpc_error", zap.Error(e), zap.String("public_error", err.Error()))
+		}
+
+		return err
+	}
+}

programming_language: Go
filename: server.go
change_type: ModificationType.MODIFY
diff: @@ -81,6 +81,7 @@ type Config struct {
 	ListObjectsDeadline    time.Duration
 	ListObjectsMaxResults  uint32
 	UnaryInterceptors      []grpc.UnaryServerInterceptor
+	StreamingInterceptors  []grpc.StreamServerInterceptor
 	MuxOptions             []runtime.ServeMuxOption
 }
 
@@ -486,13 +487,19 @@ func (s *Server) IsReady(ctx context.Context) (bool, error) {
 // server cancel the provided ctx.
 func (s *Server) Run(ctx context.Context) error {
 
-	interceptors := []grpc.UnaryServerInterceptor{
+	unaryServerInterceptors := []grpc.UnaryServerInterceptor{
 		grpc_validator.UnaryServerInterceptor(),
 	}
-	interceptors = append(interceptors, s.config.UnaryInterceptors...)
+	unaryServerInterceptors = append(unaryServerInterceptors, s.config.UnaryInterceptors...)
+
+	streamingInterceptors := []grpc.StreamServerInterceptor{
+		grpc_validator.StreamServerInterceptor(),
+	}
+	streamingInterceptors = append(streamingInterceptors, s.config.StreamingInterceptors...)
 
 	opts := []grpc.ServerOption{
-		grpc.ChainUnaryInterceptor(interceptors...),
+		grpc.ChainUnaryInterceptor(unaryServerInterceptors...),
+		grpc.ChainStreamInterceptor(streamingInterceptors...),
 	}
 
 	if s.config.GRPCServer.TLSConfig != nil {

programming_language: Go