fix: recover introspection block across named fragment spreads; dedup cache hash; fix cache Set counter race

- graphql.go: parseGraphQLQuery now indexes top-level FragmentDefinition nodes
  and checkSelections resolves FragmentSpread by name, closing an
  introspection-blocking bypass (`{ ...F }` where F spreads __schema was
  allowed because the spread carried no selection set and definitions loop
  skipped fragments).
- proxy.go/server.go: handleCaching stores the computed cache hash on the
  request context; performProxyRequest reuses it instead of recomputing a
  full MD5 of the request body per proxied request (fallback preserved for
  direct callers).
- cache/memory: Set serializes the exists-check + counter update + store
  with the embedded mutex so concurrent Sets of the same new key can't
  double-increment entryCount/memoryUsage; regression test added.

Verification: go build, go vet, gofmt clean; full go test ./... and
go test -race ./... green (9 packages); golangci-lint 0 issues; trufflehog clean.
This commit is contained in:
2026-08-13 09:07:52 +01:00
parent 14be2398e8
commit 56eb0af213
6 changed files with 117 additions and 14 deletions
+38 -6
View File
@@ -322,6 +322,17 @@ func parseGraphQLQuery(c fiber.Ctx) *parseGraphQLQueryResult {
res.shouldIgnore = false
res.operationName = "undefined"
// Index named fragments so FragmentSpread nodes can be resolved during the
// introspection check. A named spread (e.g. `...F`) only carries a name;
// its content lives in the matching top-level FragmentDefinition, which the
// OperationDefinition loop below otherwise skips.
fragments := make(map[string]*ast.FragmentDefinition)
for _, d := range p.Definitions {
if fd, ok := d.(*ast.FragmentDefinition); ok && fd.Name != nil {
fragments[fd.Name.Value] = fd
}
}
// Single pass over definitions: gather operation type, mutation flag,
// operation name, and process directives / introspection checks together.
// Mutations take priority for operationType regardless of order.
@@ -368,7 +379,7 @@ func parseGraphQLQuery(c fiber.Ctx) *parseGraphQLQueryResult {
processDirectives(oper, res)
// Check for introspection queries if they're blocked
if cfg.Security.BlockIntrospection && checkSelections(c, oper.GetSelectionSet().Selections) {
if cfg.Security.BlockIntrospection && checkSelections(c, oper.GetSelectionSet().Selections, fragments) {
_ = c.Status(403).SendString("Introspection queries are not allowed")
res.shouldBlock = true
return res
@@ -417,8 +428,10 @@ func processDirectives(oper *ast.OperationDefinition, res *parseGraphQLQueryResu
}
}
// checkSelections recursively checks if any selection is an introspection query that should be blocked
func checkSelections(c fiber.Ctx, selections []ast.Selection) bool {
// checkSelections recursively checks if any selection is an introspection query that should be blocked.
// fragments indexes top-level named FragmentDefinition nodes so that a FragmentSpread (which only
// carries a name) can be resolved to its actual selection set.
func checkSelections(c fiber.Ctx, selections []ast.Selection, fragments map[string]*ast.FragmentDefinition) bool {
if len(selections) == 0 {
return false
}
@@ -450,7 +463,7 @@ func checkSelections(c fiber.Ctx, selections []ast.Selection) bool {
// Check nested selections if present
if sel.SelectionSet != nil && len(sel.GetSelectionSet().Selections) > 0 {
if checkSelections(c, sel.GetSelectionSet().Selections) {
if checkSelections(c, sel.GetSelectionSet().Selections, fragments) {
return true
}
}
@@ -458,10 +471,22 @@ func checkSelections(c fiber.Ctx, selections []ast.Selection) bool {
case *ast.InlineFragment:
// Check nested selections in fragments
if sel.SelectionSet != nil && len(sel.GetSelectionSet().Selections) > 0 {
if checkSelections(c, sel.GetSelectionSet().Selections) {
if checkSelections(c, sel.GetSelectionSet().Selections, fragments) {
return true
}
}
case *ast.FragmentSpread:
// A named fragment spread carries no selection set itself; resolve it
// to its top-level FragmentDefinition so introspection inside a named
// fragment (e.g. `...F` where F selects __schema) cannot bypass the block.
if sel.Name != nil {
if frag, ok := fragments[sel.Name.Value]; ok && frag.SelectionSet != nil {
if checkSelections(c, frag.SelectionSet.Selections, fragments) {
return true
}
}
}
}
}
@@ -500,11 +525,18 @@ func checkIfContainsIntrospection(c fiber.Ctx, query string) bool {
}
if p != nil {
// Index named fragments so FragmentSpread nodes resolve correctly.
fragments := make(map[string]*ast.FragmentDefinition)
for _, def := range p.Definitions {
if fd, ok := def.(*ast.FragmentDefinition); ok && fd.Name != nil {
fragments[fd.Name.Value] = fd
}
}
// It's a complete query, check all selections
for _, def := range p.Definitions {
if op, ok := def.(*ast.OperationDefinition); ok {
if op.SelectionSet != nil {
blocked = checkSelections(c, op.GetSelectionSet().Selections)
blocked = checkSelections(c, op.GetSelectionSet().Selections, fragments)
break
}
}