From ac99c3e01c62826cd2ea9f42295fcc9683a569b7 Mon Sep 17 00:00:00 2001 From: Emmanuel Gautier Date: Sun, 1 Dec 2024 22:32:11 +0100 Subject: [PATCH] feat: add api key authentication support --- .github/workflows/scans.yml | 77 ++++++++++++++++++++++++--- internal/operation/operation.go | 4 +- internal/operation/operation_test.go | 2 +- openapi/security_scheme.go | 16 +++--- openapi/security_scheme_test.go | 40 +++++++++----- scenario/url_test.go | 45 ++++++++++++++++ scenario/utils.go | 30 ++++++++--- test/stub/simple_api_key.openapi.json | 2 +- 8 files changed, 178 insertions(+), 38 deletions(-) diff --git a/.github/workflows/scans.yml b/.github/workflows/scans.yml index 00f25ce..b68e846 100644 --- a/.github/workflows/scans.yml +++ b/.github/workflows/scans.yml @@ -72,8 +72,75 @@ jobs: if: ${{ always() }} run: docker stop $(docker ps -q --filter ancestor=ghcr.io/cerberauth/api-vulns-challenges/${{ matrix.challenge }}:latest) - run-api-key-scans: - name: JWT Scans + run-header-strong-api-key-scan: + name: Strong API Key Scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Server + run: docker run -d -p 8080:8080 ghcr.io/cerberauth/api-vulns-challenges/strong-api-key:latest + + - name: Setup Go environment + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: VulnAPI + id: vulnapi + run: | + go run main.go scan curl http://localhost:8080 -H "X-API-Key: abcdef1234" --sqa-opt-out + + - name: Stop Server + if: ${{ always() }} + run: docker stop $(docker ps -q --filter ancestor=ghcr.io/cerberauth/api-vulns-challenges/strong-api-key:latest) + + run-header-api-key-scan: + name: API Key in header Scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Server + run: docker run -d -p 8080:8080 ghcr.io/cerberauth/api-vulns-challenges/auth-not-verified:latest + + - name: Setup Go environment + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: VulnAPI + id: vulnapi + continue-on-error: true + run: | + go run main.go scan curl http://localhost:8080 -H "X-API-Key: abcdef1234" --sqa-opt-out + + - name: Check for vulnerabilities + if: ${{ steps.vulnapi.outputs.conclusion == 'failure' }} + run: echo "Vulnerabilities found" + + - name: Stop Server + if: ${{ always() }} + run: docker stop $(docker ps -q --filter ancestor=ghcr.io/cerberauth/api-vulns-challenges/auth-not-verified:latest) + + run-bearer-api-key-scan: + name: Bearer API Key Scan runs-on: ubuntu-latest steps: @@ -209,7 +276,7 @@ jobs: run: docker stop $(docker ps -q --filter ancestor=ghcr.io/cerberauth/api-vulns-challenges/apollo:latest) run-openapi-scans: - name: JWT Scans + name: OpenAPI Scans runs-on: ubuntu-latest strategy: @@ -235,10 +302,6 @@ jobs: - name: Run Server run: docker run -d -p 8080:8080 ghcr.io/cerberauth/api-vulns-challenges/auth-not-verified:latest - - name: Get JWT - id: get-jwt - run: echo "jwt=$(docker run --rm ghcr.io/cerberauth/api-vulns-challenges/jwt-strong-eddsa-key:latest jwt)" >> $GITHUB_OUTPUT - - name: Setup Go environment uses: actions/setup-go@v5 with: diff --git a/internal/operation/operation.go b/internal/operation/operation.go index 0bdd8e0..3e8b360 100644 --- a/internal/operation/operation.go +++ b/internal/operation/operation.go @@ -2,7 +2,7 @@ package operation import ( "bytes" - "errors" + "fmt" "io" "net" "net/http" @@ -109,7 +109,7 @@ func (operation *Operation) IsReachable() error { case "https": host += ":443" default: - return errors.New("unsupported scheme") + return fmt.Errorf("unsupported scheme: %s", operation.URL.Scheme) } } diff --git a/internal/operation/operation_test.go b/internal/operation/operation_test.go index a8bf3d2..9918418 100644 --- a/internal/operation/operation_test.go +++ b/internal/operation/operation_test.go @@ -99,7 +99,7 @@ func TestOperation_IsReachableWhenUnsupportedScheme(t *testing.T) { err := operation.IsReachable() assert.Error(t, err) - assert.Equal(t, "unsupported scheme", err.Error()) + assert.Equal(t, "unsupported scheme: ftp", err.Error()) } func TestNewOperationFromRequest(t *testing.T) { diff --git a/openapi/security_scheme.go b/openapi/security_scheme.go index 6cd8b79..32a4243 100644 --- a/openapi/security_scheme.go +++ b/openapi/security_scheme.go @@ -39,17 +39,14 @@ func NewErrUnsupportedSecuritySchemeType(schemeType string) error { } func mapHTTPSchemeType(name string, scheme *openapi3.SecuritySchemeRef, securitySchemeValue *string) (*auth.SecurityScheme, error) { - schemeScheme := strings.ToLower(scheme.Value.Scheme) - - switch schemeScheme { + switch schemeScheme := strings.ToLower(scheme.Value.Scheme); schemeScheme { case BearerScheme: securityScheme, err := auth.NewAuthorizationBearerSecurityScheme(name, securitySchemeValue) if err != nil { return nil, err } - bearerFormat := strings.ToLower(scheme.Value.BearerFormat) - switch bearerFormat { + switch bearerFormat := strings.ToLower(scheme.Value.BearerFormat); bearerFormat { case "": return securityScheme, nil case "jwt": @@ -66,6 +63,10 @@ func mapHTTPSchemeType(name string, scheme *openapi3.SecuritySchemeRef, security } } +func mapAPIKeySchemeType(name string, scheme *openapi3.SecuritySchemeRef, securitySchemeValue *string) (*auth.SecurityScheme, error) { + return auth.NewAPIKeySecurityScheme(name, auth.SchemeIn(scheme.Value.In), securitySchemeValue) +} + func mapOAuth2SchemeType(name string, scheme *openapi3.SecuritySchemeRef, securitySchemeValue *auth.OAuthValue) (*auth.SecurityScheme, error) { if scheme.Value.Flows == nil { return auth.NewOAuthSecurityScheme(name, nil, securitySchemeValue, nil) @@ -113,8 +114,7 @@ func (openapi *OpenAPI) SecuritySchemeMap(values *SecuritySchemeValues) (auth.Se value, _ = securitySchemeValue.(*string) } - schemeType := strings.ToLower(scheme.Value.Type) - switch schemeType { + switch schemeType := strings.ToLower(scheme.Value.Type); schemeType { case HttpSchemeType: securitySchemes[name], err = mapHTTPSchemeType(name, scheme, value) case OAuth2SchemeType, OpenIdConnectSchemeType: @@ -123,6 +123,8 @@ func (openapi *OpenAPI) SecuritySchemeMap(values *SecuritySchemeValues) (auth.Se oauthValue = auth.NewOAuthValue(*value, nil, nil, nil) } securitySchemes[name], err = mapOAuth2SchemeType(name, scheme, oauthValue) + case ApiKeySchemeType: + securitySchemes[name], err = mapAPIKeySchemeType(name, scheme, value) default: err = NewErrUnsupportedSecuritySchemeType(schemeType) } diff --git a/openapi/security_scheme_test.go b/openapi/security_scheme_test.go index 071383c..be45cd3 100644 --- a/openapi/security_scheme_test.go +++ b/openapi/security_scheme_test.go @@ -12,7 +12,7 @@ import ( func TestSecuritySchemeMap_WithoutSecurityComponents(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}}}}}`), ) @@ -25,7 +25,7 @@ func TestSecuritySchemeMap_WithoutSecurityComponents(t *testing.T) { func TestSecuritySchemeMap_WithUnknownSchemeType(t *testing.T) { expectedErr := openapi.NewErrUnsupportedSecuritySchemeType("other") openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: other}}}}`), ) @@ -39,7 +39,7 @@ func TestSecuritySchemeMap_WithUnknownSchemeType(t *testing.T) { func TestSecuritySchemeMap_WithUnknownScheme(t *testing.T) { expectedErr := openapi.NewErrUnsupportedScheme("other") openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: http, scheme: other}}}}`), ) @@ -53,7 +53,7 @@ func TestSecuritySchemeMap_WithUnknownScheme(t *testing.T) { func TestSecuritySchemeMap_WithUnknownBearerFormat(t *testing.T) { expectedErr := openapi.NewErrUnsupportedBearerFormat("other") openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: http, scheme: bearer, bearerFormat: other}}}}`), ) @@ -66,7 +66,7 @@ func TestSecuritySchemeMap_WithUnknownBearerFormat(t *testing.T) { func TestSecuritySchemeMap_WithHTTPJWTBearer(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: http, scheme: bearer, bearerFormat: JWT}}}}`), ) @@ -81,7 +81,7 @@ func TestSecuritySchemeMap_WithHTTPJWTBearer(t *testing.T) { func TestSecuritySchemeMap_WithHTTPBearer(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: http, scheme: bearer}}}}`), ) @@ -95,7 +95,7 @@ func TestSecuritySchemeMap_WithHTTPBearer(t *testing.T) { func TestSecuritySchemeMap_WithoutHTTPJWTBearerAndDefaultValue(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: http, scheme: bearer, bearerFormat: JWT}}}}`), ) @@ -109,9 +109,23 @@ func TestSecuritySchemeMap_WithoutHTTPJWTBearerAndDefaultValue(t *testing.T) { assert.Equal(t, auth.JWTTokenFormat, *result["bearer_auth"].GetTokenFormat()) } +func TestSecuritySchemeMap_WithAPIKeyInHeader(t *testing.T) { + openapiContract, _ := openapi.LoadFromData( + context.TODO(), + []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [{name: 'Authorization', in: header, required: true, schema: {type: string}}], responses: {'204': {description: successful operation}}, security: [{api_key_auth: []}]}}}, components: {securitySchemes: {api_key_auth: {type: apiKey, in: header, name: X-API-KEY}}}}`), + ) + + result, err := openapiContract.SecuritySchemeMap(openapi.NewEmptySecuritySchemeValues()) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, auth.ApiKey, result["api_key_auth"].GetType()) + assert.Equal(t, auth.InHeader, *result["api_key_auth"].In) +} + func TestSecuritySchemeMap_WithInvalidValueType(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{bearer_auth: []}]}}}, components: {securitySchemes: {bearer_auth: {type: http, scheme: bearer, bearerFormat: JWT}}}}`), ) @@ -126,7 +140,7 @@ func TestSecuritySchemeMap_WithInvalidValueType(t *testing.T) { func TestSecuritySchemeMap_WithOAuth(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{oauth_auth: []}]}}}, components: {securitySchemes: {oauth_auth: {type: oauth2}}}}`), ) @@ -140,7 +154,7 @@ func TestSecuritySchemeMap_WithOAuth(t *testing.T) { func TestSecuritySchemeMap_WithOAuthAndAuthorizationCodeFlow(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{oauth_auth: []}]}}}, components: {securitySchemes: {oauth_auth: {type: oauth2, flows: {authorizationCode: {tokenUrl: 'http://localhost:8080/token', refreshUrl: 'http://localhost:8080/refresh'}}}}}}`), ) @@ -158,7 +172,7 @@ func TestSecuritySchemeMap_WithOAuthAndAuthorizationCodeFlow(t *testing.T) { func TestSecuritySchemeMap_WithOAuthAndImplicitFlow(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{oauth_auth: []}]}}}, components: {securitySchemes: {oauth_auth: {type: oauth2, flows: {implicit: {tokenUrl: 'http://localhost:8080/token', refreshUrl: 'http://localhost:8080/refresh'}}}}}}`), ) @@ -176,7 +190,7 @@ func TestSecuritySchemeMap_WithOAuthAndImplicitFlow(t *testing.T) { func TestSecuritySchemeMap_WithOAuthAndClientCredentialsFlow(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{oauth_auth: []}]}}}, components: {securitySchemes: {oauth_auth: {type: oauth2, flows: {clientCredentials: {tokenUrl: 'http://localhost:8080/token', refreshUrl: 'http://localhost:8080/refresh'}}}}}}`), ) @@ -194,7 +208,7 @@ func TestSecuritySchemeMap_WithOAuthAndClientCredentialsFlow(t *testing.T) { func TestSecuritySchemeMap_WithOpenIDConnect(t *testing.T) { openapiContract, _ := openapi.LoadFromData( - context.Background(), + context.TODO(), []byte(`{openapi: 3.0.2, servers: [{url: 'http://localhost:8080'}], paths: {/: {get: {parameters: [], responses: {'204': {description: successful operation}}, security: [{oidc_auth: []}]}}}, components: {securitySchemes: {oidc_auth: {type: openIdConnect}}}}`), ) diff --git a/scenario/url_test.go b/scenario/url_test.go index 3ab86c4..7b32e29 100644 --- a/scenario/url_test.go +++ b/scenario/url_test.go @@ -88,3 +88,48 @@ func TestNewURLScanWithLowerCaseAuthorizationHeader(t *testing.T) { assert.Equal(t, http.MethodGet, s.Operations[0].Method) assert.Equal(t, []*auth.SecurityScheme{auth.MustNewAuthorizationBearerSecurityScheme("default", &token)}, s.Operations[0].SecuritySchemes) } + +func TestNewURLScanWithAPIKeyInHeader(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + apiKey := "token" + tests := []struct { + name string + }{ + { + name: "X-Api-Key", + }, + { + name: "Apikey", + }, + { + name: "App-Key", + }, + { + name: "X-Token", + }, + { + name: "Api-Secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + header.Add(tt.name, apiKey) + client := request.NewClient(request.NewClientOptions{ + Header: header, + }) + + s, err := scenario.NewURLScan(http.MethodGet, server.URL, "", client, nil) + + require.NoError(t, err) + assert.Equal(t, server.URL, s.Operations[0].URL.String()) + assert.Equal(t, http.MethodGet, s.Operations[0].Method) + assert.Equal(t, []*auth.SecurityScheme{auth.MustNewAPIKeySecurityScheme(tt.name, auth.InHeader, &apiKey)}, s.Operations[0].SecuritySchemes) + }) + } +} diff --git a/scenario/utils.go b/scenario/utils.go index a251ff5..9b3846e 100644 --- a/scenario/utils.go +++ b/scenario/utils.go @@ -1,7 +1,6 @@ package scenario import ( - "fmt" "net/http" "strings" @@ -10,6 +9,8 @@ import ( const bearerPrefix = auth.BearerPrefix + " " +var apiKeyKeywords = []string{"key", "token", "secret"} + func detectAuthorizationHeader(header http.Header) string { if h := header.Get(auth.AuthorizationHeader); h != "" { return h @@ -35,18 +36,33 @@ func getBearerToken(authHeader string) string { return "" } +func detectAPIKeyHeader(header http.Header) (string, string) { + for headerName, headerValue := range header { + for _, keyword := range apiKeyKeywords { + if strings.Contains(strings.ToLower(headerName), keyword) { + return headerName, headerValue[0] + } + } + } + + return "", "" +} + func detectSecurityScheme(header http.Header) (*auth.SecurityScheme, error) { authHeader := detectAuthorizationHeader(header) - if authHeader == "" { - return nil, nil + if authHeader != "" { + if token := getBearerToken(authHeader); token != "" { + return auth.NewAuthorizationBearerSecurityScheme("default", &token) + } + + return auth.NewAPIKeySecurityScheme(auth.AuthorizationHeader, auth.InHeader, &authHeader) } - token := getBearerToken(authHeader) - if token == "" { - return nil, fmt.Errorf("empty authorization header") + if headerName, headerValue := detectAPIKeyHeader(header); headerName != "" { + return auth.NewAPIKeySecurityScheme(headerName, auth.InHeader, &headerValue) } - return auth.NewAuthorizationBearerSecurityScheme("default", &token) + return nil, nil } func addDefaultProtocolWhenMissing(url string) string { diff --git a/test/stub/simple_api_key.openapi.json b/test/stub/simple_api_key.openapi.json index 8c6ce30..06041ed 100644 --- a/test/stub/simple_api_key.openapi.json +++ b/test/stub/simple_api_key.openapi.json @@ -30,7 +30,7 @@ "components": { "securitySchemes": { "api_key_auth": { - "type": "http", + "type": "apiKey", "in": "header", "name": "X-API-Key" }