java-topology/defects/traefik/patch/traefik-0004-checkcursion-stack-contains-hashset.md

3.6 KiB
Raw Permalink Blame History

UNDF: UNDF-2026-000000616

UNDF: (pending)

traefik-0004: CheckRecursion — O(D²) slices.Contains on growing stack

CWE-407 — Algorithmic Complexity: Quadratic Recursion Detection

Field Value
ID traefik-0004
Severity MEDIUM
Ecosystem traefik
Package github.com/traefik/traefik/v3/pkg/server/recursion
File pkg/server/recursion/recursion.go
Lines 1625
Complexity O(D²)
Hot path Every middleware/service build — called per middleware per request chain construction

Defect

CheckRecursion stores visited names in a []string (propagated via context), then calls slices.Contains(currentStack, name) to detect cycles. Because slices.Contains is an O(D) linear scan and the stack grows one entry per depth level, the total work across all D levels is:

1 + 2 + 3 + ... + D  =  D*(D+1)/2  =  O(D²)

Called from BuildMiddlewareChain (once per middleware name) and from service.go (once per service name). For a route with D=50 chained middleware references this is 1,275 string comparisons instead of 50.

// pkg/server/recursion/recursion.go
func CheckRecursion(ctx context.Context, itemType, itemName string) (context.Context, error) {
    currentStack, ok := ctx.Value(stackKey).([]string)
    if !ok {
        currentStack = []string{}
    }
    name := itemType + ":" + itemName
    if slices.Contains(currentStack, name) {        // O(D) scan ← defect
        return ctx, fmt.Errorf(...)
    }
    return context.WithValue(ctx, stackKey, append(currentStack, name)), nil
}

The same slice is stored in the context value, so the full scan repeats at each call level.

Fix

Carry a parallel map[string]struct{} in the context for O(1) membership tests. Keep the []string only for error message formatting (already done via the slice join in the error path).

type stackType int
type stackSetType int

const (
    stackKey    stackType    = iota
    stackSetKey stackSetType = iota
)

func CheckRecursion(ctx context.Context, itemType, itemName string) (context.Context, error) {
    currentStack, ok := ctx.Value(stackKey).([]string)
    if !ok {
        currentStack = []string{}
    }
    currentSet, ok := ctx.Value(stackSetKey).(map[string]struct{})
    if !ok {
        currentSet = make(map[string]struct{})
    }
    name := itemType + ":" + itemName
    if _, exists := currentSet[name]; exists {         // O(1) lookup ← fix
        return ctx, fmt.Errorf("could not instantiate %s %s: recursion detected in %s",
            itemType, itemName, strings.Join(append(currentStack, name), "->"))
    }
    newSet := make(map[string]struct{}, len(currentSet)+1)
    for k, v := range currentSet {
        newSet[k] = v
    }
    newSet[name] = struct{}{}
    ctx = context.WithValue(ctx, stackKey, append(currentStack, name))
    ctx = context.WithValue(ctx, stackSetKey, newSet)
    return ctx, nil
}

Speedup

The fix removes the O(D) linear scan from every depth level, reducing total work from O(D²) to O(D) for the overall chain construction.

D (chain depth) Before (ops) After (ops) Speedup
10 55 10 5.5×
50 1,275 50 25.5×
100 5,050 100 50.5×
200 20,100 200 100.5×

In practice D is bounded by real configuration, but deeply nested middleware chains (e.g. plugin compositions, ForwardAuth chains, composite routing setups) hit this on every config reload.