diff --git a/go.mod b/go.mod index f2adacca21..ddc81bdf08 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.5 github.com/go-pg/pg v8.0.7+incompatible github.com/go-sql-driver/mysql v1.7.1 + github.com/graphql-go/graphql v0.8.1 github.com/h2non/filetype v1.1.3 github.com/invopop/yaml v0.3.1 github.com/kitabisa/go-ci v1.0.3 diff --git a/go.sum b/go.sum index da71e9b287..1613e5dc5d 100644 --- a/go.sum +++ b/go.sum @@ -528,6 +528,8 @@ github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc= +github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= diff --git a/pkg/fuzz/component/body.go b/pkg/fuzz/component/body.go index 9d5bbe66c8..cc8df8de70 100644 --- a/pkg/fuzz/component/body.go +++ b/pkg/fuzz/component/body.go @@ -3,6 +3,7 @@ package component import ( "bytes" "context" + "fmt" "io" "strconv" "strings" @@ -62,6 +63,12 @@ func (b *Body) Parse(req *retryablehttp.Request) (bool, error) { switch { case strings.Contains(contentType, "application/json") && tmp.IsNIL(): + // In case its a json body, check if the underlying data + // is graphql if so, parse it as graphql + if dataformat.Get(dataformat.GraphqlDataFormat).IsType(dataStr) { + fmt.Printf("dataStr: %s\n", dataStr) + return b.parseBody(dataformat.GraphqlDataFormat, req) + } return b.parseBody(dataformat.JSONDataFormat, req) case strings.Contains(contentType, "application/xml") && tmp.IsNIL(): return b.parseBody(dataformat.XMLDataFormat, req) diff --git a/pkg/fuzz/dataformat/dataformat.go b/pkg/fuzz/dataformat/dataformat.go index 9d3cdc3061..e24d5882b6 100644 --- a/pkg/fuzz/dataformat/dataformat.go +++ b/pkg/fuzz/dataformat/dataformat.go @@ -21,6 +21,7 @@ func init() { RegisterDataFormat(NewJSON()) RegisterDataFormat(NewXML()) RegisterDataFormat(NewRaw()) + RegisterDataFormat(NewGraphql()) RegisterDataFormat(NewForm()) RegisterDataFormat(NewMultiPartForm()) } @@ -36,6 +37,8 @@ const ( FormDataFormat = "form" // MultiPartFormDataFormat is the name of the MultiPartForm data format MultiPartFormDataFormat = "multipart/form-data" + // GraphqlDataFormat is the name of the Graphql data format + GraphqlDataFormat = "graphql" ) // Get returns the dataformat by name diff --git a/pkg/fuzz/dataformat/graphql.go b/pkg/fuzz/dataformat/graphql.go new file mode 100644 index 0000000000..058fb5e822 --- /dev/null +++ b/pkg/fuzz/dataformat/graphql.go @@ -0,0 +1,278 @@ +package dataformat + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/graphql-go/graphql/language/kinds" + "github.com/graphql-go/graphql/language/parser" + "github.com/graphql-go/graphql/language/printer" + "github.com/graphql-go/graphql/language/source" + jsoniter "github.com/json-iterator/go" + "github.com/pkg/errors" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + + "github.com/graphql-go/graphql/language/ast" +) + +type Graphql struct{} + +var ( + _ DataFormat = &Graphql{} +) + +// NewGraphql returns a new GraphQL encoder +func NewGraphql() *Graphql { + return &Graphql{} +} + +// IsType returns true if the data is Graqhql encoded +func (m *Graphql) IsType(data string) bool { + _, isGraphql, _ := isGraphQLOperation([]byte(data), true) + return isGraphql +} + +// consider it container type of our graphql representation +type graphQLRequest struct { + Query string `json:"query,omitempty"` + OperationName string `json:"operationName,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +func isGraphQLOperation(jsonData []byte, validate bool) (graphQLRequest, bool, error) { + jsonStr := string(jsonData) + if !strings.HasPrefix(jsonStr, "{") && !strings.HasSuffix(jsonStr, "}") { + return graphQLRequest{}, false, nil + } + + var request graphQLRequest + if err := json.Unmarshal(jsonData, &request); err != nil { + return graphQLRequest{}, false, errors.Wrap(err, "could not unmarshal json") + } + + if request.Query == "" && request.OperationName == "" && len(request.Variables) == 0 { + return graphQLRequest{}, false, nil + } + + // Validate if query actually is a graphql + // query and not just some random json + if !validate { + return request, true, nil + } + + doc, err := parser.Parse(parser.ParseParams{ + Source: &source.Source{ + Body: []byte(request.Query), + }, + }) + if err != nil { + return graphQLRequest{}, false, err + } + if len(doc.Definitions) == 0 { + return graphQLRequest{}, false, nil + } + + return request, true, nil +} + +// Encode encodes the data into MultiPartForm format +func (m *Graphql) Encode(data KV) (string, error) { + parsedRequest := data.Get("#_parsedReq") + if parsedRequest == nil { + return "", fmt.Errorf("parsed request not found") + } + parsedRequestStruct, ok := parsedRequest.(graphQLRequest) + if !ok { + return "", fmt.Errorf("parsed request is not of type graphQLRequest") + } + + _, astDoc, err := m.parseGraphQLRequest(parsedRequestStruct.Query, false) + if err != nil { + return "", fmt.Errorf("error parsing graphql request: %v", err) + } + + var hasVariables bool + if hasVariablesItem := data.Get("#_hasVariables"); hasVariablesItem != nil { + hasVariables, _ = hasVariablesItem.(bool) + } + + data.Iterate(func(key string, value any) bool { + if strings.HasPrefix(key, "#_") { + return true + } + + if hasVariables { + parsedRequestStruct.Variables[key] = value + return true + } + if err := m.modifyASTWithKeyValue(astDoc, key, value); err != nil { + log.Printf("error modifying ast with key value: %v", err) + return false + } + return true + }) + + modifiedQuery := printer.Print(astDoc) + parsedRequestStruct.Query = types.ToString(modifiedQuery) + + marshalled, err := jsoniter.Marshal(parsedRequestStruct) + if err != nil { + return "", fmt.Errorf("error marshalling parsed request: %v", err) + } + return string(marshalled), nil +} + +func (m *Graphql) modifyASTWithKeyValue(astDoc *ast.Document, key string, value any) error { + for _, def := range astDoc.Definitions { + switch v := def.(type) { + case *ast.OperationDefinition: + if v.SelectionSet == nil { + continue + } + + for _, selection := range v.SelectionSet.Selections { + switch field := selection.(type) { + case *ast.Field: + for _, arg := range field.Arguments { + if arg.Name.Value == key { + arg.Value = convertGoValueToASTValue(value) + } + } + } + } + } + } + return nil +} + +// Decode decodes the data from Graphql format +func (m *Graphql) Decode(data string) (KV, error) { + parsedReq, astDoc, err := m.parseGraphQLRequest(data, true) + if err != nil { + return KV{}, fmt.Errorf("error parsing graphql request: %v", err) + } + + kv := KVMap(map[string]interface{}{}) + kv.Set("#_parsedReq", parsedReq) + + for k, v := range parsedReq.Variables { + kv.Set(k, v) + } + if len(parsedReq.Variables) > 0 { + kv.Set("#_hasVariables", true) + } + if err := m.populateGraphQLKV(astDoc, kv); err != nil { + return KV{}, fmt.Errorf("error populating graphql kv: %v", err) + } + return kv, nil +} + +func (m *Graphql) populateGraphQLKV(astDoc *ast.Document, kv KV) error { + for _, def := range astDoc.Definitions { + switch def := def.(type) { + case *ast.OperationDefinition: + args, err := getSelectionSetArguments(def) + if err != nil { + return fmt.Errorf("error getting selection set arguments: %v", err) + } + + for k, v := range args { + if item := kv.Get(k); item != nil { + continue + } + kv.Set(k, v) + } + } + } + return nil +} + +func (m *Graphql) parseGraphQLRequest(query string, unmarshal bool) (graphQLRequest, *ast.Document, error) { + var parsedReq graphQLRequest + var err error + + if unmarshal { + parsedReq, _, err = isGraphQLOperation([]byte(query), false) + if err != nil { + return graphQLRequest{}, nil, fmt.Errorf("error parsing query: %v", err) + } + } else { + parsedReq.Query = query + } + + astDoc, err := parser.Parse(parser.ParseParams{ + Source: &source.Source{ + Body: []byte(parsedReq.Query), + }, + }) + if err != nil { + return graphQLRequest{}, nil, fmt.Errorf("error parsing ast: %v", err) + } + return parsedReq, astDoc, nil +} + +func getSelectionSetArguments(def *ast.OperationDefinition) (map[string]interface{}, error) { + args := make(map[string]interface{}) + + if def.SelectionSet == nil { + return args, nil + } + for _, selection := range def.SelectionSet.Selections { + switch field := selection.(type) { + case *ast.Field: + for _, arg := range field.Arguments { + args[arg.Name.Value] = convertValueToGoType(arg.Value) + } + } + } + return args, nil +} + +func convertGoValueToASTValue(value any) ast.Value { + switch v := value.(type) { + case string: + newValue := &ast.StringValue{ + Kind: kinds.StringValue, + Value: v, + } + return newValue + } + return nil +} + +func convertValueToGoType(value ast.Value) interface{} { + switch value := value.(type) { + case *ast.StringValue: + return value.Value + case *ast.IntValue: + return value.Value + case *ast.FloatValue: + return value.Value + case *ast.BooleanValue: + return value.Value + case *ast.EnumValue: + return value.Value + case *ast.ListValue: + var list []interface{} + for _, v := range value.Values { + list = append(list, convertValueToGoType(v)) + } + return list + case *ast.ObjectValue: + obj := make(map[string]interface{}) + for _, v := range value.Fields { + obj[v.Name.Value] = convertValueToGoType(v.Value) + } + return obj + } + + return nil + +} + +// Name returns the name of the encoder +func (m *Graphql) Name() string { + return "graphql" +} diff --git a/pkg/fuzz/dataformat/graphql_test.go b/pkg/fuzz/dataformat/graphql_test.go new file mode 100644 index 0000000000..46c8c9a591 --- /dev/null +++ b/pkg/fuzz/dataformat/graphql_test.go @@ -0,0 +1,111 @@ +package dataformat + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + graphqlQueryWithInlineArgs = `{ + "query": "\n query {\n jobs(jobType: \"front-end\") {\n id\n name\n type\n description\n }\n }\n " + }` + + graphqlQueryWithVariables = `{ + "query": "mutation ImportPaste ($host: String!, $port: Int!, $path: String!, $scheme: String!) {\n importPaste(host: $host, port: $port, path: $path, scheme: $scheme) {\n result\n }\n }", + "variables": { + "host": "example.com", + "port": 80, + "path": "/robots.txt", + "scheme": "http" + } +}` +) + +func Test_GraphQL_IsGraphQL(t *testing.T) { + graphql := NewGraphql() + require.True( + t, + graphql.IsType(graphqlQueryWithInlineArgs), + "expected query to be detected as graphql", + ) + require.False( + t, + graphql.IsType("not a graphql query"), + "expected query to not be detected as graphql", + ) + require.False( + t, + graphql.IsType(`{"query": "not a graphql query"}`), + "expected query to not be detected as graphql", + ) +} + +func Test_GraphQL_DecodeEncode_InlineArgs(t *testing.T) { + decodeQueryGetKV := func(query string) (KV, map[string]interface{}, *Graphql) { + graphql := NewGraphql() + + decoded, err := graphql.Decode(query) + require.Nil(t, err, "could not decode graphql query") + + keyValues := make(map[string]interface{}) + decoded.Iterate(func(key string, value interface{}) bool { + if strings.HasPrefix(key, "#_") { + return true + } + keyValues[key] = value + return true + }) + return decoded, keyValues, graphql + } + + // Test decoding and encoding + t.Run("inline args with variables", func(t *testing.T) { + decoded, keyValues, graphql := decodeQueryGetKV(graphqlQueryWithVariables) + require.Equal(t, map[string]interface{}{ + "host": "example.com", + "port": float64(80), + "path": "/robots.txt", + "scheme": "http", + }, keyValues) + + decoded.Set("path", "/robots.txt; cat /etc/passwd") + + // Test encoding + encoded, err := graphql.Encode(decoded) + require.Nil(t, err, "could not encode graphql query") + + _, newKeyValues, _ := decodeQueryGetKV(encoded) + require.Equal(t, "/robots.txt; cat /etc/passwd", newKeyValues["path"]) + + // Try to write non-string paths as well + t.Run("non-string paths", func(t *testing.T) { + decoded.Set("port", "80; cat /etc/passwd") + encoded, err = graphql.Encode(decoded) + require.Nil(t, err, "could not encode graphql query") + + _, newKeyValues, _ = decodeQueryGetKV(encoded) + require.Equal(t, "80; cat /etc/passwd", newKeyValues["port"]) + }) + }) + + t.Run("inline args", func(t *testing.T) { + decoded, keyValues, graphql := decodeQueryGetKV(graphqlQueryWithInlineArgs) + require.Equal(t, map[string]interface{}{ + "jobType": "front-end", + }, keyValues) + + decoded.Set("jobType", "canary") + + // Test encoding + encoded, err := graphql.Encode(decoded) + require.Nil(t, err, "could not encode graphql query") + + _, newKeyValues, _ := decodeQueryGetKV(encoded) + require.Equal(t, map[string]interface{}{ + "jobType": "canary", + }, newKeyValues) + }) + +} diff --git a/pkg/fuzz/dataformat/json.go b/pkg/fuzz/dataformat/json.go index 99dd0430ec..c04fbfcfe0 100644 --- a/pkg/fuzz/dataformat/json.go +++ b/pkg/fuzz/dataformat/json.go @@ -26,7 +26,14 @@ func NewJSON() *JSON { // IsType returns true if the data is JSON encoded func (j *JSON) IsType(data string) bool { - return strings.HasPrefix(data, "{") && strings.HasSuffix(data, "}") + isJSON := strings.HasPrefix(data, "{") && strings.HasSuffix(data, "}") + if isJSON { + // Check if its GraphQL + if Get(GraphqlDataFormat).IsType(data) { + return false + } + } + return isJSON } // Encode encodes the data into JSON format