Files
ResolveSpec/audit/pkg/modelregistry.audit.md
T
Hein bc8bff7955
Tests / Unit Tests (push) Failing after 24s
Tests / Integration Tests (push) Failing after 26s
Build , Vet Test, and Lint / Build (push) Successful in 1m7s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m31s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m33s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m34s
docs(audit): add audit reports for pkg/testmodels and pkg/tracing
2026-09-29 17:15:00 +02:00

23 KiB

Audit — pkg/modelregistry

  • Date: 2026-09-29
  • Scope: pkg/modelregistry/model_registry.go (381 LOC, 1 file, no tests)
  • Axes: thread locking/waiting · slowness · security · panic handling & logging
  • Threat model: hostile internet client. This package holds the ModelRules that pkg/security/hooks.go consults to authorise read/update/create/delete, so it is on the authorisation path.

Summary

This package is the highest-risk find in the audit. It has been deliberately reworked to "never hang" by replacing blocking Lock/RLock with bounded TryLock retry loops that give up and return a wrong answer — and because those wrong answers are consumed by pkg/security/hooks.go as authorisation decisions, the result is an authorisation control that fails open under lock contention.

The comments in the file are explicit about the trade-off ("falls back to the last known value without synchronization", "the call is a no-op") — so the hazard was known at the time of writing. What appears not to have been traced is where those degraded results end up. They end up in checkModelUpdateAllowed / checkModelDeleteAllowed, which treat any error as permit.

There are no tests in this package and no -race coverage of it anywhere.

# Severity Axis Finding
1 Critical Security + Locking GetModel's "registry locked" error is consumed by pkg/security/hooks.go as allow by default → authorisation fails open under write-lock contention
2 High Locking GetDefaultRegistry documents and performs an unsynchronised read of defaultRegistry on lock-acquire failure — a data race by design
3 High Locking SetDefaultRegistry silently no-ops after ~20 ms of contention; caller gets no error
4 High Security RegisterModelWithRules is non-atomic: the model is visible with permissive DefaultModelRules before its real rules are applied (TOCTOU)
5 Medium Correctness GetAllModels returns an empty map, and GetModels silently skips whole registries, on lock-acquire failure
6 Medium Locking IterateModels invokes the caller's callback while holding RLock → guaranteed self-deadlock if the callback touches the registry
7 Medium Slowness time.Sleep(1ms) spin loops add up to 20 ms of latency per call and defeat mutex fairness/hand-off
8 Medium Locking defaultRegistry is read unsynchronised by six package-level functions while SetDefaultRegistry writes it under lock
9 Medium Locking Inconsistent discipline: SetModelRules/GetModelRules/AddRegistry/IterateModels use blocking locks; the rest use try-locks
10 Low Slowness/Locking Reflection (TypeOf, unwrap loop, reflect.New) runs while holding the registry write lock
11 Low Availability Unbounded unwrap loop: a recursive pointer type (type T *T) spins forever holding the write lock (verified)
12 Low Panic Package has no recover anywhere, and calls a caller-supplied callback under a lock (see 6)
13 Low Security DefaultModelRules() grants CanRead/Update/Create/Delete: true — registration without explicit rules is fully mutable

Findings

1. Authorisation fails open under lock contention (Critical, Security + Locking)

The mechanism spans two packages.

Here, GetModel conflates "not found" with "could not lock" into a single error return (model_registry.go:198-210):

func (r *DefaultModelRegistry) GetModel(name string) (interface{}, error) {
	if !r.tryRLock() {
		return nil, fmt.Errorf("failed to get model %s: registry locked", name)
	}
	defer r.mutex.RUnlock()

	model, exists := r.models[name]
	if !exists {
		return nil, fmt.Errorf("model %s not found", name)
	}
	return model, nil
}

GetModelRulesByName (model_registry.go:364-376) uses GetModel as its existence probe:

for _, registry := range registries {
	if _, err := registry.GetModel(name); err == nil {
		return registry.GetModelRules(name)
	}
}
return ModelRules{}, fmt.Errorf("model %s not found in any registry", name)

So a tryRLock failure makes the registry look like it does not contain the model.

In pkg/security/hooks.go, that outcome is interpreted as permit (pkg/security/hooks.go:274-294, and identically at :298-318):

func checkModelUpdateAllowed(secCtx SecurityContext) error {
	rules, ok := GetModelRulesFromContext(secCtx.GetContext())
	if !ok {
		schema := secCtx.GetSchema()
		entity := secCtx.GetEntity()
		var err error
		if schema != "" {
			rules, err = modelregistry.GetModelRulesByName(fmt.Sprintf("%s.%s", schema, entity))
		}
		if err != nil || schema == "" {
			rules, err = modelregistry.GetModelRulesByName(entity)
		}
		if err != nil {
			return nil // model not registered, allow by default
		}
	}
	if !rules.CanUpdate {
		return fmt.Errorf("update not allowed for %s", secCtx.GetEntity())
	}
	return nil
}

Note the context fast-path at hooks.go:275: if NewModelAuthMiddleware already put rules in the context, the registry is not consulted and this bug does not fire. The registry fallback runs whenever that middleware is absent or did not resolve rules — so the blast radius depends on deployment wiring. audit/pkg/security.audit.md covers whether that middleware is mandatory.

return nil from checkModelUpdateAllowed means the update is authorised. Same for checkModelDeleteAllowed. GetModelRules(name) for the "found" path also uses a blocking RLock (model_registry.go:253) — so the two calls in GetModelRulesByName don't even use the same locking discipline.

Failure scenario. A model public.employees is registered with CanDelete: false. A concurrent RegisterModel (or SetModelRules, or RegisterModelWithRules) holds the write lock for longer than lockRetryAttempts * lockRetryDelay = 20 ms — which is entirely achievable given finding 10 (reflection under the write lock) and finding 7 (each waiter sleeps in 1 ms increments, so N waiters serialise). During that window every DELETE request against public.employees has GetModelRulesByName return an error, checkModelDeleteAllowed return nil, and the delete proceeds. The model's CanDelete: false is not enforced.

This is remotely triggerable if any request path can cause a model registration or a rules update; even without that, it is a straightforward race that will fire under load.

Recommendation, in order of value:

  1. Make the security layer fail closed: distinguish a sentinel ErrModelNotFound from any other error, and only allow-by-default on ErrModelNotFound. Any other error must deny.
  2. Delete the try-lock scheme here entirely and use plain RLock/Lock (see finding 2 for why the scheme does not achieve its stated goal anyway).
  3. Separate the existence probe from the rules fetch so GetModelRulesByName takes each registry's lock once and returns a typed "found / not found / unavailable" result.

2. GetDefaultRegistry races by design (High, Locking)

model_registry.go:71-84

// GetDefaultRegistry returns the current default registry. It uses a
// bounded TryRLock instead of a blocking RLock so it can never hang;
// if the lock can't be acquired in time it falls back to the last known
// value without synchronization.
func GetDefaultRegistry() *DefaultModelRegistry {
	for i := 0; i < lockRetryAttempts; i++ {
		if registriesMutex.TryRLock() {
			defer registriesMutex.RUnlock()
			return defaultRegistry
		}
		time.Sleep(lockRetryDelay)
	}
	return defaultRegistry
}

The return defaultRegistry on line 83 reads a pointer that SetDefaultRegistry (model_registry.go:89-116) writes under the write lock. The only time this path is taken is precisely when a writer holds or is contending for the lock — i.e. the fallback executes exactly in the window where the race is live. The trade is not "hang vs. slightly stale value"; it is "block for 20 ms vs. data race", and a torn/nil pointer read here means a nil-pointer dereference in the caller.

The premise is also wrong: a sync.RWMutex.RLock that is only ever held for a map lookup cannot "hang". The hang this was written to avoid must have had a different root cause — most likely finding 6 (self-deadlock through IterateModels) or a lock-ordering inversion — and the try-lock scheme papers over it rather than fixing it.

Recommendation: revert to RLock/RUnlock. If a real hang was observed, reproduce it under -race and GODEBUG=gctrace/SIGQUIT stack dump; the fix belongs at the deadlock, not here.

3. SetDefaultRegistry silently no-ops (High, Locking)

model_registry.go:90-100

acquired := false
for i := 0; i < lockRetryAttempts; i++ {
	if registriesMutex.TryLock() { acquired = true; break }
	time.Sleep(lockRetryDelay)
}
if !acquired {
	return
}

The function returns no error. A caller that swaps in a registry — plausibly one with restrictive `ModelRules* — has no way to learn the swap did not happen, and continues believing the new registry is in effect. Every subsequent authorisation check consults the old registry's rules.

GetModels (model_registry.go:319-329) has the same shape and returns nil.

Recommendation: return error from SetDefaultRegistry; or (better) use a blocking Lock, since this is a startup-time operation where blocking is correct.

4. RegisterModelWithRules is non-atomic (High, Security)

model_registry.go:270-282

func (r *DefaultModelRegistry) RegisterModelWithRules(name string, model interface{}, rules ModelRules) error {
	// First register the model
	if err := r.RegisterModel(name, model); err != nil {
		return err
	}

	// Then set the rules (we need to lock again for rules)
	r.mutex.Lock()
	defer r.mutex.Unlock()
	r.rules[name] = rules
	return nil
}

RegisterModel releases the write lock before returning, and it initialises the model's rules to DefaultModelRules() (model_registry.go:191-194) — which is permissive: CanRead/CanUpdate/CanCreate/CanDelete all true.

Between the two lock acquisitions, any concurrent GetModelRulesByName sees the model registered with full read/update/create/delete permission, regardless of the restrictive rules the caller passed. The comment "we need to lock again for rules" acknowledges the re-lock without noticing the gap it opens.

Failure scenario. RegisterModelWithRules("public.audit_log", AuditLog{}, ModelRules{CanRead: true}) — intended read-only. A DELETE /public.audit_log/... that lands in the window is authorised because rules.CanDelete is true from the default.

Recommendation: add an unexported registerLocked(name, model, rules) that writes both maps under one lock acquisition, and build both public constructors on it. Also change the default initialisation in RegisterModel to deny-by-default, or require rules at registration.

5. Degraded results indistinguishable from real results (Medium, Correctness)

Three functions return a plausible-looking answer when they cannot lock:

  • GetAllModels (model_registry.go:212-215) — return make(map[string]interface{}), i.e. "the registry is empty".
  • GetModels (model_registry.go:327-329) — return nil on registriesMutex failure, and model_registry.go:336-338 continues past any individual registry it cannot read, returning a partial list with no indication of truncation.
  • GetDefaultRegistry — finding 2.

Consumers of GetModels/GetAllModels (schema introspection, OpenAPI generation, migration helpers) will emit a document that is missing models, and there is no error to log. Note these two have no callers in pkg/ today, which is the only reason this is Medium.

Recommendation: return (T, error); never manufacture an empty-but-valid result.

6. IterateModels calls a user callback under a read lock (Medium, Locking)

model_registry.go:307-314

func IterateModels(fn func(name string, model interface{})) {
	defaultRegistry.mutex.RLock()
	defer defaultRegistry.mutex.RUnlock()

	for name, model := range defaultRegistry.models {
		fn(name, model)
	}
}

fn is arbitrary caller code running with defaultRegistry.mutex read-held. sync.RWMutex is not reentrant, and once a writer is blocked on Lock it also blocks new readers. So:

  • fn calling modelregistry.RegisterModel / SetModelRules → Lock waits for the reader, which is the same goroutine. Permanent self-deadlock.
  • fn calling GetModel → tryRLock fails for 20 ms and returns "registry locked" for every model, which is silent nonsense rather than a deadlock (and feeds finding 1).
  • fn doing anything slow (I/O, a DB call) holds the registry read lock for that whole duration, blocking all registration and — via the blocked-writer rule — all other readers too.

This is the most likely original cause of the "hang" the try-lock scheme was introduced to work around.

Recommendation: snapshot under the lock, release, then iterate:

func IterateModels(fn func(name string, model interface{})) {
	reg := GetDefaultRegistry()
	snapshot := reg.GetAllModels()   // takes and releases the lock
	for name, model := range snapshot {
		fn(name, model)
	}
}

7. time.Sleep spin loops (Medium, Slowness)

tryLock (model_registry.go:128-136), tryRLock (:140-148), and the inline loops in GetDefaultRegistry, SetDefaultRegistry, GetModels.

for i := 0; i < lockRetryAttempts; i++ {
	if r.mutex.TryLock() { return true }
	time.Sleep(lockRetryDelay)   // 1ms
}

Problems:

  • Latency floor. A contended call costs a multiple of 1 ms even if the lock frees after 10 µs, because the waiter is asleep. A blocking Lock would be handed the mutex in microseconds. So the "no-hang" scheme is slower in the common contended case, not faster.
  • No fairness. sync.Mutex has a starvation-avoidance mode that hands the lock to a waiter queued > 1 ms. TryLock participates in none of it, so a try-lock waiter can be starved indefinitely by a stream of blocking Lock callers (SetModelRules, AddRegistry, IterateModels all still block) — see finding 9.
  • Timer churn. 20 timer allocations per contended call.
  • Sleeping in a loop scales badly: 50 concurrent callers each sleep and wake 20 times, producing 1000 needless scheduler round-trips for what a mutex does with one park/unpark.

Recommendation: delete the try-lock helpers. If a bounded wait is genuinely required for an SLO, express it as context-aware acquisition (a buffered-channel semaphore with a select on ctx.Done()), which gives a real deadline and a real error — not a silent wrong answer.

8. defaultRegistry read without the guarding mutex (Medium, Locking)

SetDefaultRegistry writes defaultRegistry (model_registry.go:110) under registriesMutex. These read it without taking that mutex:

  • RegisterModel (model_registry.go:288)
  • IterateModels (model_registry.go:308, 311)
  • SetModelRules (model_registry.go:354)
  • GetModelRules (model_registry.go:359)
  • GetDefaultRegistry's fallback (model_registry.go:83, finding 2)

A data race on the pointer, and semantically these functions may operate on the previous default registry after a swap — so rules set through SetModelRules can land on a registry nobody consults any more.

Recommendation: route every access through one accessor that takes the lock (and make defaultRegistry an atomic.Pointer[DefaultModelRegistry] if lock-free reads are wanted — that is the correct way to get the "never blocks" property finding 2 was reaching for).

9. Inconsistent locking discipline (Medium, Locking)

Within one 381-line file:

Function registriesMutex r.mutex
GetDefaultRegistry TryRLock + fallback —
SetDefaultRegistry TryLock, no-op on fail —
AddRegistry (:120) blocking Lock —
GetModelByName (:293) blocking RLock via GetModel → TryRLock
GetModelRulesByName (:365) blocking RLock TryRLock then blocking RLock
GetModels (:318) TryRLock, nil on fail tryRLock, skip on fail
RegisterModel (:150) — tryLock, error on fail
GetModel (:198) — tryRLock, error on fail
GetAllModels (:212) — tryRLock, empty on fail
SetModelRules (:237) — blocking Lock
GetModelRules (:252) — blocking RLock
IterateModels (:307) — blocking RLock

Four different failure behaviours for the same class of event. The mix also means the try-lock callers can be starved by the blocking ones (finding 7), so the functions that "can never hang" are the ones most likely to return garbage.

Recommendation: pick one discipline — blocking locks with snapshot-and-release — and apply it uniformly.

10. Reflection under the write lock (Low, Slowness + Locking)

RegisterModel holds r.mutex (write) from model_registry.go:151 through :195, and inside that window does reflect.TypeOf (:161), the unwrap loop (:169-171), reflect.New(...).Elem().Interface() (:181), and another reflect.TypeOf (:185). None of that touches r.models/r.rules and none of it needs the lock.

This directly lengthens the window that makes finding 1 exploitable. Validate first, then take the lock only for the two map writes.

11. Unbounded unwrap loop on a recursive pointer type (Low, Availability)

model_registry.go:169-171

for modelType.Kind() == reflect.Pointer || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array {
	modelType = modelType.Elem()
}

type T *T is legal Go, and reflect.Type.Elem() on it returns itself — so the loop never terminates. Verified experimentally:

type T *T
var x T
tt := reflect.TypeOf(x)            // main.T
for tt.Kind() == reflect.Pointer { tt = tt.Elem() }   // spins on main.T forever
// → "INFINITE LOOP CONFIRMED after 101 iterations, still main.T"

Because the loop runs with the write lock held (finding 10), this doesn't just hang one goroutine — it wedges the registry permanently, at which point every try-lock caller starts returning "registry locked", which via finding 1 means authorisation fails open for the rest of the process lifetime.

Requires a pathological model type, so exploitability is near zero; the fix is a one-line depth cap and it converts a permanent fail-open into an error return.

Recommendation: bound the loop (for depth := 0; depth < 16 && ...; depth++) and return an error if the cap is hit.

12. No panic handling at all (Low, Panic handling)

The package contains zero recover() calls and never logs — it does not import pkg/logger. For a pure data structure that is a defensible choice, with two caveats:

  • IterateModels runs a caller callback under a read lock (finding 6). If fn panics, the defer RUnlock does release the lock, so the registry is not wedged — that part is fine — but the panic propagates to whatever boundary handler exists, and nothing here records which model was being processed. A logger-free package can still name the model in a re-panic.
  • Every failure mode in the package is reported as a fmt.Errorf string with no wrapping and no sentinel values, so callers cannot distinguish them (finding 1). That is the panic/error-handling defect that actually matters here.

Recommendation: define ErrModelNotFound, ErrModelExists, ErrRegistryUnavailable as sentinels and wrap them, so errors.Is works at the security layer.

13. Permissive default rules (Low, Security)

DefaultModelRules() (model_registry.go:24-36) returns CanRead, CanUpdate, CanCreate, CanDelete all true. RegisterModel applies it to any model registered without explicit rules (model_registry.go:191-194), and GetModelRules falls back to it as well (model_registry.go:266).

The CanPublic* flags default to false and SecurityDisabled to false, which is right. But the authenticated-path flags default open, so RegisterModel(name, m) — the form used by pkg/testmodels/business.go RegisterTestModels and the modelregistry.RegisterModel convenience wrapper — yields a fully mutable model. Combined with pkg/security/hooks.go's allow-on-error, the system's default posture at every layer is permit.

Recommendation: default to deny and make permissions opt-in, or at minimum log at registration time when a model is registered without explicit rules.


What looks right

  • The struct-vs-pointer validation in RegisterModel (model_registry.go:160-194) is careful and well-reasoned: it rejects nil, unwraps pointer/slice/array to find the base type, rejects non-struct kinds with a message naming the original type, normalises a pointer/slice input to a zero struct value, and re-checks the final type. The error message even tells the caller to use MyModel{} instead of &MyModel{}. Good API ergonomics.
  • Duplicate registration is rejected (model_registry.go:156-158) rather than silently overwriting — important, since silent overwrite would be a rules-replacement primitive.
  • GetAllModels returns a copy of the map (model_registry.go:218-222) rather than the internal one, so callers cannot mutate registry state or race on it after the lock is dropped. This is the pattern the rest of the package should follow.
  • GetModelByEntity (model_registry.go:225-234) tries schema.entity before bare entity, which is the right precedence and matches what pkg/security/hooks.go does.
  • GetModels de-duplicates by name across registries (model_registry.go:335-347), so registry-order precedence is consistent with GetModelByName's first-match rule.
  • Every defer for an acquired lock is correctly paired; there is no missing-Unlock path. The problems here are about which lock discipline was chosen, not about leaking locks.

Suggested follow-up

Ordered by risk:

  1. Make pkg/security/hooks.go fail closed (finding 1). This is the single change that converts a Critical authorisation bypass into a Medium availability issue. It does not require touching this package.
  2. Remove the try-lock scheme (findings 2, 3, 5, 7, 9) and fix the underlying hang by snapshotting in IterateModels (finding 6).
  3. Make RegisterModelWithRules atomic (finding 4).
  4. Route defaultRegistry access through a single locked accessor or atomic.Pointer (finding 8).
  5. Move reflection out of the write-locked region and cap the unwrap loop (findings 10, 11).
  6. Add tests. This package has none. Priority cases: -race test with concurrent RegisterModel + GetModelRulesByName asserting that rules are never observed as permissive for a restrictively-registered model; a test that GetModelRulesByName under contention does not return a "not found"-shaped error; IterateModels with a callback that calls back into the registry (should not deadlock); sentinel-error assertions.