Files
nuclei/pkg/fuzz/dataformat/graphql.go
2026-09-24 00:50:53 +02:00

516 lines
13 KiB
Go

package dataformat
import (
"fmt"
"sort"
"strings"
"github.com/graphql-go/graphql/language/ast"
"github.com/graphql-go/graphql/language/kinds"
"github.com/graphql-go/graphql/language/lexer"
"github.com/graphql-go/graphql/language/parser"
"github.com/graphql-go/graphql/language/printer"
"github.com/graphql-go/graphql/language/source"
"github.com/graphql-go/graphql/language/visitor"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
const (
graphqlMetaQuery = "#_query"
graphqlMetaOperationName = "#_operationName"
graphqlMetaHasVariables = "#_hasVariables"
graphqlMetaExtensions = "#_extensions"
)
// Graphql encodes and decodes GraphQL-over-HTTP JSON bodies
// ({"query","variables","operationName"}) so DAST fuzzing can target
// variables and inline field arguments instead of the raw query string.
type Graphql struct{}
var _ DataFormat = &Graphql{}
// NewGraphql returns a new GraphQL data format encoder/decoder.
func NewGraphql() *Graphql {
return &Graphql{}
}
// Name returns the name of the data format.
func (g *Graphql) Name() string {
return GraphqlDataFormat
}
// IsType reports whether data is a GraphQL HTTP request body.
func (g *Graphql) IsType(data string) bool {
_, ok := parseGraphQLHTTPBody(data, true)
return ok
}
type graphQLHTTPBody struct {
Query string `json:"query,omitempty"`
OperationName string `json:"operationName,omitempty"`
Variables *map[string]any `json:"variables,omitempty"`
Extensions json.Message `json:"extensions,omitempty"`
}
func parseGraphQLHTTPBody(data string, validateQuery bool) (graphQLHTTPBody, bool) {
trimmed := strings.TrimSpace(data)
if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") {
return graphQLHTTPBody{}, false
}
var body graphQLHTTPBody
if err := json.Unmarshal([]byte(trimmed), &body); err != nil {
return graphQLHTTPBody{}, false
}
if strings.TrimSpace(body.Query) == "" {
return graphQLHTTPBody{}, false
}
if !validateQuery {
return body, true
}
if _, err := parseQueryAST(body.Query); err != nil {
return graphQLHTTPBody{}, false
}
return body, true
}
func parseQueryAST(query string) (*ast.Document, error) {
doc, err := parser.Parse(parser.ParseParams{
Source: &source.Source{Body: []byte(query)},
})
if err == nil {
return doc, nil
}
// graphql-go v0.8.1 predates GraphQL null literal support. Replace null
// name tokens with an enum sentinel for parsing, then restore the AST.
rewritten, sentinel, replaced, rewriteErr := rewriteGraphQLNullTokens(query)
if rewriteErr != nil || !replaced {
return nil, err
}
doc, rewriteErr = parser.Parse(parser.ParseParams{
Source: &source.Source{Body: []byte(rewritten)},
})
if rewriteErr != nil {
return nil, err
}
visitor.Visit(doc, &visitor.VisitorOptions{
Enter: func(params visitor.VisitFuncParams) (string, interface{}) {
switch node := params.Node.(type) {
case *ast.Name:
if node.Value == sentinel {
node.Value = "null"
}
case *ast.EnumValue:
if node.Value == sentinel {
node.Value = "null"
}
}
return visitor.ActionNoChange, nil
},
}, nil)
return doc, nil
}
func rewriteGraphQLNullTokens(query string) (string, string, bool, error) {
sentinel := "__NUCLEI_GRAPHQL_NULL__"
for strings.Contains(query, sentinel) {
sentinel += "_"
}
src := &source.Source{Body: []byte(query)}
next := lexer.Lex(src)
var out strings.Builder
last := 0
replaced := false
for {
token, err := next(0)
if err != nil {
return "", "", false, err
}
if token.Kind == lexer.EOF {
break
}
if token.Kind != lexer.NAME || token.Value != "null" {
continue
}
out.WriteString(query[last:token.Start])
out.WriteString(sentinel)
last = token.End
replaced = true
}
if !replaced {
return query, sentinel, false, nil
}
out.WriteString(query[last:])
return out.String(), sentinel, true, nil
}
// Decode extracts fuzzable GraphQL variables / inline arguments.
func (g *Graphql) Decode(data string) (KV, error) {
body, ok := parseGraphQLHTTPBody(data, true)
if !ok {
return KV{}, fmt.Errorf("not a graphql http body")
}
doc, err := parseQueryAST(body.Query)
if err != nil {
return KV{}, fmt.Errorf("could not parse graphql query: %w", err)
}
kv := KVMap(map[string]any{})
kv.Set(graphqlMetaQuery, body.Query)
if body.OperationName != "" {
kv.Set(graphqlMetaOperationName, body.OperationName)
}
if body.Extensions != nil {
kv.Set(graphqlMetaExtensions, body.Extensions)
}
hasVariables := body.Variables != nil
if hasVariables {
kv.Set(graphqlMetaHasVariables, true)
for key, value := range *body.Variables {
kv.Set(key, value)
}
}
// Inline arguments are only exposed when variables are absent; otherwise
// variables are the stable fuzz surface (and rewrites stay in JSON).
if !hasVariables {
for key, value := range collectInlineArguments(doc) {
if kv.Get(key) != nil {
continue
}
kv.Set(key, value)
}
}
return kv, nil
}
// Encode rebuilds a GraphQL HTTP JSON body from fuzzed KV values.
func (g *Graphql) Encode(data KV) (string, error) {
queryVal := data.Get(graphqlMetaQuery)
if queryVal == nil {
return "", fmt.Errorf("graphql query metadata missing")
}
query := types.ToString(queryVal)
body := graphQLHTTPBody{Query: query}
if op := data.Get(graphqlMetaOperationName); op != nil {
body.OperationName = types.ToString(op)
}
if extensions, ok := data.Get(graphqlMetaExtensions).(json.Message); ok {
body.Extensions = extensions
}
hasVariables, _ := data.Get(graphqlMetaHasVariables).(bool)
if hasVariables {
variables := make(map[string]any)
body.Variables = &variables
data.Iterate(func(key string, value any) bool {
if strings.HasPrefix(key, "#_") {
return true
}
variables[key] = value
return true
})
} else {
doc, err := parseQueryAST(query)
if err != nil {
return "", fmt.Errorf("could not parse graphql query: %w", err)
}
data.Iterate(func(key string, value any) bool {
if strings.HasPrefix(key, "#_") {
return true
}
applyInlineArgument(doc, key, value)
return true
})
body.Query = types.ToString(printer.Print(doc))
}
encoded, err := json.Marshal(body)
if err != nil {
return "", err
}
return string(encoded), nil
}
func collectInlineArguments(doc *ast.Document) map[string]any {
args := make(map[string]any)
for _, ref := range inlineArgumentRefs(doc) {
if containsVariable(ref.argument.Value) {
continue
}
args[ref.key] = astValueToGo(ref.argument.Value)
}
return args
}
func applyInlineArgument(doc *ast.Document, key string, value any) {
for _, ref := range inlineArgumentRefs(doc) {
if ref.key == key && !containsVariable(ref.argument.Value) {
ref.argument.Value = goValueToAST(value, ref.argument.Value)
return
}
}
}
func containsVariable(value ast.Value) bool {
switch typed := value.(type) {
case *ast.Variable:
return true
case *ast.ListValue:
for _, item := range typed.Values {
if containsVariable(item) {
return true
}
}
case *ast.ObjectValue:
for _, field := range typed.Fields {
if containsVariable(field.Value) {
return true
}
}
}
return false
}
type inlineArgumentRef struct {
key string
path string
argument *ast.Argument
}
func inlineArgumentRefs(doc *ast.Document) []inlineArgumentRef {
var refs []inlineArgumentRef
walkFields(doc, func(path string, field *ast.Field) {
for _, argument := range field.Arguments {
if argument.Name == nil {
continue
}
refs = append(refs, inlineArgumentRef{
path: path,
argument: argument,
})
}
})
counts := make(map[string]int)
for _, ref := range refs {
counts[ref.argument.Name.Value]++
}
for index := range refs {
name := refs[index].argument.Name.Value
refs[index].key = name
if counts[name] > 1 {
refs[index].key = refs[index].path + "." + name
}
}
keyCounts := make(map[string]int)
for _, ref := range refs {
keyCounts[ref.key]++
}
keyIndexes := make(map[string]int)
for index := range refs {
key := refs[index].key
keyIndexes[key]++
if keyCounts[key] > 1 {
refs[index].key = fmt.Sprintf("%s[%d]", key, keyIndexes[key])
}
}
return refs
}
func walkFields(doc *ast.Document, fn func(string, *ast.Field)) {
if doc == nil {
return
}
for _, def := range doc.Definitions {
switch typed := def.(type) {
case *ast.OperationDefinition:
prefix := ""
if typed.Name != nil {
prefix = typed.Name.Value
}
walkSelectionSet(typed.SelectionSet, prefix, fn)
case *ast.FragmentDefinition:
prefix := "fragment"
if typed.Name != nil {
prefix += "." + typed.Name.Value
}
walkSelectionSet(typed.SelectionSet, prefix, fn)
}
}
}
func walkSelectionSet(set *ast.SelectionSet, parentPath string, fn func(string, *ast.Field)) {
if set == nil {
return
}
fieldCounts := make(map[string]int)
for _, selection := range set.Selections {
if field, ok := selection.(*ast.Field); ok {
fieldCounts[fieldResponseName(field)]++
}
}
fieldIndexes := make(map[string]int)
for _, selection := range set.Selections {
switch typed := selection.(type) {
case *ast.Field:
name := fieldResponseName(typed)
fieldIndexes[name]++
if fieldCounts[name] > 1 {
name = fmt.Sprintf("%s[%d]", name, fieldIndexes[name])
}
path := name
if parentPath != "" {
path = parentPath + "." + name
}
fn(path, typed)
walkSelectionSet(typed.SelectionSet, path, fn)
case *ast.InlineFragment:
walkSelectionSet(typed.SelectionSet, parentPath, fn)
}
}
}
func fieldResponseName(field *ast.Field) string {
if field.Alias != nil {
return field.Alias.Value
}
if field.Name != nil {
return field.Name.Value
}
return "field"
}
func goValueToAST(value any, original ast.Value) ast.Value {
if enum, ok := original.(*ast.EnumValue); ok && enum.Value == "null" && value == nil {
return &ast.EnumValue{Kind: kinds.EnumValue, Value: "null"}
}
switch original.(type) {
case *ast.IntValue:
return &ast.IntValue{Kind: kinds.IntValue, Value: types.ToString(value)}
case *ast.FloatValue:
return &ast.FloatValue{Kind: kinds.FloatValue, Value: types.ToString(value)}
case *ast.EnumValue:
return &ast.EnumValue{Kind: kinds.EnumValue, Value: types.ToString(value)}
case *ast.StringValue:
return &ast.StringValue{Kind: kinds.StringValue, Value: types.ToString(value)}
}
switch v := value.(type) {
case string:
return &ast.StringValue{Kind: kinds.StringValue, Value: v}
case bool:
return &ast.BooleanValue{Kind: kinds.BooleanValue, Value: v}
case int:
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", v)}
case int32:
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", v)}
case int64:
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", v)}
case float32:
return &ast.FloatValue{Kind: kinds.FloatValue, Value: fmt.Sprintf("%v", v)}
case float64:
// JSON numbers decode as float64; keep integers looking like ints when possible.
if v == float64(int64(v)) {
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", int64(v))}
}
return &ast.FloatValue{Kind: kinds.FloatValue, Value: fmt.Sprintf("%v", v)}
case []any:
originalList, _ := original.(*ast.ListValue)
values := make([]ast.Value, 0, len(v))
for index, item := range v {
var originalItem ast.Value
if originalList != nil && index < len(originalList.Values) {
originalItem = originalList.Values[index]
}
values = append(values, goValueToAST(item, originalItem))
}
return &ast.ListValue{Kind: kinds.ListValue, Values: values}
case map[string]any:
originalObject, _ := original.(*ast.ObjectValue)
fields := make([]*ast.ObjectField, 0, len(v))
used := make(map[string]struct{}, len(v))
if originalObject != nil {
for _, field := range originalObject.Fields {
if field.Name == nil {
continue
}
name := field.Name.Value
value, ok := v[name]
if !ok {
continue
}
fields = append(fields, &ast.ObjectField{
Kind: kinds.ObjectField,
Name: &ast.Name{Kind: kinds.Name, Value: name},
Value: goValueToAST(value, field.Value),
})
used[name] = struct{}{}
}
}
names := make([]string, 0, len(v)-len(used))
for name := range v {
if _, ok := used[name]; !ok {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
fields = append(fields, &ast.ObjectField{
Kind: kinds.ObjectField,
Name: &ast.Name{Kind: kinds.Name, Value: name},
Value: goValueToAST(v[name], nil),
})
}
return &ast.ObjectValue{Kind: kinds.ObjectValue, Fields: fields}
default:
return &ast.StringValue{Kind: kinds.StringValue, Value: types.ToString(v)}
}
}
func astValueToGo(value ast.Value) any {
switch v := value.(type) {
case *ast.StringValue:
return v.Value
case *ast.IntValue:
return v.Value
case *ast.FloatValue:
return v.Value
case *ast.BooleanValue:
return v.Value
case *ast.EnumValue:
if v.Value == "null" {
return nil
}
return v.Value
case *ast.ListValue:
out := make([]any, 0, len(v.Values))
for _, item := range v.Values {
out = append(out, astValueToGo(item))
}
return out
case *ast.ObjectValue:
out := make(map[string]any, len(v.Fields))
for _, field := range v.Fields {
if field.Name == nil {
continue
}
out[field.Name.Value] = astValueToGo(field.Value)
}
return out
default:
return nil
}
}