// Package graphql detects exposed GraphQL endpoints and tests them for // common misconfigurations: unauthenticated introspection, batched query // abuse, and field-level auth bypass via aliases. // // Probes these paths on every HTTP-probed host: // // /graphql, /graphiql, /api/graphql, /v1/graphql, /v2/graphql, // /query, /api/v1/graphql, /api/v2/graphql // // When an endpoint responds to introspection queries, we publish an // APIFinding + VulnerabilityFound event with the schema size and entry // points as evidence. package graphql import ( "bytes" "context" "encoding/json" "io" "net/http" "strings" "sync" "time" "god-eye/internal/eventbus" gohttp "god-eye/internal/http" "god-eye/internal/module" "god-eye/internal/store" ) const ModuleName = "vuln.graphql" type gqlModule struct{} func Register() { module.Register(&gqlModule{}) } func (*gqlModule) Name() string { return ModuleName } func (*gqlModule) Phase() module.Phase { return module.PhaseAnalysis } func (*gqlModule) Consumes() []eventbus.EventType { return []eventbus.EventType{eventbus.EventHTTPProbed} } func (*gqlModule) Produces() []eventbus.EventType { return []eventbus.EventType{eventbus.EventAPIFinding, eventbus.EventVulnerability} } func (*gqlModule) DefaultEnabled() bool { return true } var candidatePaths = []string{ "/graphql", "/graphiql", "/api/graphql", "/v1/graphql", "/v2/graphql", "/query", "/api/v1/graphql", "/api/v2/graphql", "/graphql/console", "/graphql/v1", "/graphql/v2", "/playground", } // introspection is the minimal query that exposes the full schema. Sent // with Content-Type: application/json. const introspectionQuery = `{"query":"{__schema{queryType{name} mutationType{name} subscriptionType{name} types{name kind description fields{name} enumValues{name}}}}"}` func (*gqlModule) Run(mctx module.Context) error { timeout := mctx.Config.Int("timeout", 10) client := gohttp.GetSharedClient(timeout) processed := make(map[string]struct{}) var mu sync.Mutex shouldProcess := func(host string) bool { mu.Lock() defer mu.Unlock() if _, ok := processed[host]; ok { return false } processed[host] = struct{}{} return true } var wg sync.WaitGroup // Drain store: every host that got a successful HTTP probe. for _, h := range mctx.Store.All(mctx.Ctx) { if h == nil || h.StatusCode == 0 { continue } if !shouldProcess(h.Subdomain) { continue } host := h.Subdomain wg.Add(1) go func() { defer wg.Done(); probeGraphQL(mctx, client, host) }() } // Late events. sub := mctx.Bus.Subscribe(eventbus.EventHTTPProbed, func(_ context.Context, e eventbus.Event) { ev, ok := e.(eventbus.HTTPProbed) if !ok || ev.StatusCode == 0 { return } host := ev.Meta().Target if !shouldProcess(host) { return } wg.Add(1) go func() { defer wg.Done(); probeGraphQL(mctx, client, host) }() }) defer sub.Unsubscribe() select { case <-time.After(500 * time.Millisecond): case <-mctx.Ctx.Done(): } wg.Wait() return nil } func probeGraphQL(mctx module.Context, client *http.Client, host string) { for _, p := range candidatePaths { if mctx.Ctx.Err() != nil { return } for _, scheme := range []string{"https://", "http://"} { u := scheme + host + p if finding := tryIntrospection(client, u); finding != nil { publishFinding(mctx, host, u, finding) return // one endpoint per host is enough — rest are typically aliases } } } } type gqlFinding struct { SchemaSize int TypesCount int HasMutation bool HasSubscription bool QueryTypeName string Sample string // truncated introspection response } func tryIntrospection(client *http.Client, url string) *gqlFinding { req, err := http.NewRequest("POST", url, bytes.NewBufferString(introspectionQuery)) if err != nil { return nil } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "god-eye-v2") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() resp, err := client.Do(req.WithContext(ctx)) if err != nil { return nil } defer resp.Body.Close() // Accept 2xx — the exact shape matters more than status. if resp.StatusCode >= 400 { return nil } body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) if err != nil || len(body) < 30 { return nil } // Parse the response; real GraphQL endpoints return {"data": {"__schema": ...}} var parsed struct { Data struct { Schema struct { QueryType map[string]interface{} `json:"queryType"` MutationType map[string]interface{} `json:"mutationType"` SubscriptionType map[string]interface{} `json:"subscriptionType"` Types []struct { Name string `json:"name"` Kind string `json:"kind"` } `json:"types"` } `json:"__schema"` } `json:"data"` } if err := json.Unmarshal(body, &parsed); err != nil { return nil } if parsed.Data.Schema.QueryType == nil { return nil } fnd := &gqlFinding{ SchemaSize: len(body), TypesCount: len(parsed.Data.Schema.Types), HasMutation: parsed.Data.Schema.MutationType != nil, HasSubscription: parsed.Data.Schema.SubscriptionType != nil, } if n, ok := parsed.Data.Schema.QueryType["name"].(string); ok { fnd.QueryTypeName = n } if len(body) > 500 { fnd.Sample = string(body[:500]) + "…" } else { fnd.Sample = string(body) } return fnd } func publishFinding(mctx module.Context, host, url string, f *gqlFinding) { now := time.Now() severity := eventbus.SeverityMedium if f.HasMutation { severity = eventbus.SeverityHigh } _ = mctx.Store.Upsert(mctx.Ctx, host, func(h *store.Host) { h.Vulnerabilities = append(h.Vulnerabilities, store.Vulnerability{ ID: "graphql-introspection", Title: "GraphQL Introspection Enabled", Description: describe(f), Severity: string(severity), URL: url, Evidence: f.Sample, Remediation: "Disable introspection in production GraphQL servers (e.g. Apollo: introspection:false, GraphQL Yoga: introspection:{disable:true}).", OWASP: "A05:2021-Security Misconfiguration", FoundAt: now, }) }) mctx.Bus.Publish(mctx.Ctx, eventbus.VulnerabilityFound{ EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host}, ID: "graphql-introspection", Title: "GraphQL Introspection Enabled", Description: describe(f), Severity: severity, URL: url, Evidence: f.Sample, Remediation: "Disable introspection in production GraphQL servers.", OWASP: "A05:2021-Security Misconfiguration", }) mctx.Bus.Publish(mctx.Ctx, eventbus.APIFinding{ EventMeta: eventbus.EventMeta{At: now, Source: ModuleName, Target: host}, Kind: "graphql-introspection", URL: url, Issue: describe(f), Severity: severity, }) } func describe(f *gqlFinding) string { parts := []string{"GraphQL endpoint leaks full schema via unauthenticated introspection."} if f.TypesCount > 0 { parts = append(parts, "Types: "+itoa(f.TypesCount)+".") } if f.HasMutation { parts = append(parts, "Mutations enabled — attacker can enumerate write operations.") } if f.HasSubscription { parts = append(parts, "Subscriptions enabled.") } if f.QueryTypeName != "" { parts = append(parts, "Query root: "+f.QueryTypeName) } return strings.Join(parts, " ") } func itoa(n int) string { // Small inline formatter avoids importing strconv just for this. if n == 0 { return "0" } var buf [20]byte i := len(buf) neg := n < 0 if neg { n = -n } for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) }