Fixes finding 5 from the 2026-08-22 security audit: newAuthStore and
replaceFileUsers rejected duplicate API keys but not duplicate names, and
counters are keyed on u.name (routes_unified.go), so two users sharing a
name silently shared llmgw:user:{name}:* counters and both appeared in
buildAdminUsage; an empty name collapsed every such user into
llmgw:user::*.
- buildEntry now rejects an empty user name outright — the source of
both bugs, since duplicate names and an empty name are the same root
cause (two config entries resolving to the same counter key).
- newAuthStore and replaceFileUsers each reject a duplicate name WITHIN
their own source (two inline users, or two file users) — but not
across sources: a file user overriding an inline user of the same name
stays intentional and unaffected, per replaceFileUsers' own documented
merge semantics.
- Deliberately does NOT extend configNamePattern's character
restriction to user names: that pattern exists because provider/
mcpServers/agents names are embedded as URL path segments, which a
user name never is here. Restricting the charset further risked
rejecting real operator-chosen names this round has no visibility
into; the live users file's steve-cw/kevinsandom already pass the
non-empty check unaffected.
Also adds TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically
(admin_test.go), modeling the live cluster's 5 friends + 4 home users and
11 MCP servers to prove all five fixes in this round load and serve
identically at that scale.
Fixes finding 3 from the 2026-08-22 security audit: authStore.identify
had no counter, no backoff, and no lockout on failed attempts, and
logger.go wrote one unbuffered stderr line per attempt including the
caller's RemoteAddr. An unauthenticated attacker got unlimited digest
guesses at line rate and could drive synchronous stderr writes on the
shared Traefik ingress.
- Add per-source-IP failure throttling to identify, reusing the existing
in-process memoryStore (limits.go) as authStore's own dedicated
instance rather than a new dependency: authFailureLimit (20) failed
attempts per authFailureWindow (1 minute) throttles further attempts
from that IP outright, including a subsequently valid key, and resets
once the window elapses.
- clientIP reads r.RemoteAddr only, never a client-supplied header,
matching the same trust boundary the rest of this package already
assumes (routes_passthrough.go strips X-Forwarded-*/X-Auth-* before any
outbound request).
- Rate-limit logAuthEvent's failure line via authStore.shouldLogAuthFailure,
the identical technique limiter.logStoreError already applies to store
errors. Successful-auth logging stays unbounded (bounded by legitimate,
already-authenticated traffic elsewhere).
Close the %2f allowlist bypass (security review finding 3, round 3,
2026-08-22): allowsPassthroughPath and hasTraversalSegment (routes_
passthrough.go) reasoned about rest asymmetrically -- the traversal check
decodes it first, the allowlist matcher ran path.Match against the raw
escaped form. path.Match("v1/*", "v1/chat%2fcompletions") reports true
(one escaped segment, no literal "/" to split it), while the decoded
equivalent, "v1/chat/completions", is two segments and does not match
"v1/*". A group restricted to PassthroughPaths: ["v1/*"] therefore let
"/openai/v1/fine_tuning%2Fjobs" through -- a path an upstream that
itself decodes %2F would read as a different, unauthorized endpoint.
allowsPassthroughPath now url.PathUnescape's rest before matching -- the
same decode hasTraversalSegment already applies -- and denies a rest
that fails to unescape at all, mirroring hasTraversalSegment's own
fail-closed rule for malformed encoding. The empty-pattern fast path
(matchesGlob's own empty-means-all contract) is checked first, before
any decoding: a group with no PassthroughPaths configured -- every live
group today -- keeps allowing every request exactly as before, malformed
encoding included; hasTraversalSegment (unconditional, later in
handlePassthrough) remains the gate for that case.
Tests: a direct unit test proving the decoded three-segment form is
denied (both via the encoded and literal spellings), a single-segment
encoded value still matches, malformed encoding denies for a restricted
group and is unaffected for an unrestricted one, and an end-to-end
ServeHTTP test reproducing the exact finding scenario.
handlePassthrough checked only the provider allowlist, never the model
or the path. A tenant scoped to one cheap model could reach a
provider's entire native API (files, batches, fine-tuning) on the
operator's key, and client identity headers reached keyless in-cluster
MCP/A2A targets unfiltered. This change closes both gaps, additively:
every new field defaults to reproducing prior behavior exactly.
Model enforcement: when a passthrough JSON body carries a top-level
"model" field, handlePassthrough checks it against the group's model
glob, in both the bare and provider-prefixed form (the same
dual-candidate matcher the unified route's resolveAgainst already
applies). A body with no inspectable model (Gemini's URL-embedded
model, a multipart upload, a non-JSON body) falls back to
provider-only authorization, unchanged from before this round.
Per-provider toggle: ProviderConfig.Passthrough, nil or true by
default, lets an operator disable a provider's native passthrough
route. A disabled provider now reports the ordinary unknown-route 404,
checked in ServeHTTP's own route gate, before auth even runs.
Per-group path allowlist: GroupConfig.PassthroughPaths, empty by
default (matching every other allow-list field's empty-means-all
semantics), restricts which native rest path a group may address.
Dangerous-header stripping: proxyUpstream now additionally strips
Forwarded, X-Forwarded-*, X-Auth-*, X-Remote-User, X-Remote-Groups, and
Cookie on both the native-passthrough and MCP/A2A target-proxy paths,
plus OpenAI-Organization, OpenAI-Project, and anthropic-beta on native
passthrough only. This is an additive denylist expansion, not an
allowlist inversion; ordinary headers still pass through.
A dedicated test asserts a config with none of the new fields set
behaves identically to before this change. The operator's home group
(providers/models/mcpServers/agents/passthroughPaths all empty) stays
fully unrestricted, and macstudio-rerank/parakeet-mlx/piper-tts stay
reachable through native passthrough by default.
GroupConfig gains CacheTTL (json cacheTTL, empty inherits the global
cache.ttl). Parsed and validated at construction (newAuthStore,
groupCacheTTL in cache.go): a malformed duration, a duration <= 0, or
CacheTTL set while the global cache block is not configured is a
constructor error naming the group, mirroring Cache:true's existing
"nothing to inherit from" check.
group carries the resolved time.Duration (0 means inherit).
responseCache.store gains an explicit ttl parameter; routes_unified.go
computes effectiveTTL(g.cache, grp) immediately before each store call,
so setEx's Redis-native EX seconds reflect the group's own override.
Tests: constructor-error table, empty-inherits and valid-override
resolution, effectiveTTL inheritance/override, a scripted-RESP-server
proof that store's SET...EX carries a different value per group, and an
end-to-end Gateway.ServeHTTP test with two groups asserting the actual
wire EX value (30 vs 60) differs per group.
README: GroupConfig.cacheTTL config row and a Caching-section paragraph.
- providers.go: validate provider names against ^[a-zA-Z0-9._-]+$ and
reject reserved names (v1, mcp, a2a) that would shadow gateway routes;
reject nil ProviderConfig map values at construction instead of
panicking.
- mcp_a2a.go: apply the same name-pattern and reserved-name validation to
mcpServers/agents keys, and reject nil TargetConfig/AgentConfig map
values at construction.
- auth.go: reject nil GroupConfig map values at construction.
- provider_openai_test.go, mcp_a2a_test.go, auth_test.go: table-driven
constructor-error coverage for the above.
- integration/integration_test.go: point the package doc at
`make integration-up`/`integration-keep` instead of a bare
`docker compose up -d --build`, which skips the dynamic.yml.tmpl
render step and can run against a stale or absent dynamic.yml; add
max_tokens to the gemini chat request so translate_gemini.go's
hasMaxTokens=true branch is actually exercised under real Yaegi.
- translate_gemini.go: soften the max_tokens comment's verification claim
to match what the integration suite actually now exercises.
1. refreshProvider (the plugin's only background goroutine) had no
recover(): a panicking adapter's listModels would crash the whole
Traefik process. Restructured around deferred recover + finishRefresh,
so a panic is turned into a logged error, inFlight always releases, and
the next interval retries. Documented as a scoped exception to
limits.go's no-background-goroutine rule.
2. newGateway wrapped ctx in an outer 5s WithTimeout before warmFill,
but warmFill already derives a fresh 5s budget per provider from
whatever ctx it receives. The outer wrap made that a single budget
shared across every discovery-enabled provider instead of a per-provider
one - providers after the first could get a near-expired context and
fail instantly. Dropped the outer wrapper; construction's worst case is
now N * 5s, accepted.
3. group.allowsModel stripped at any first slash, so a glob like
'deepseek-*' could spuriously match a bare model id that merely
contains a slash (e.g. an upstream's own 'uni/deepseek-v4-flash-0731'
naming) even though the leading segment names no configured provider.
allowsModel is now an exact glob with no internal splitting;
resolveAgainst and listFor generate the provider-prefixed and
bare-suffix candidates themselves, only when a prefix legitimately
names a configured provider. Updated the Task-3 TestIdentify assertion
to match.
4. Annotated the discarded json.Encode error in handleModels.
Gate: go test ./... -count=1, -race, gofmt, vet, fieldalignment,
golangci-lint (incl gosec) all clean.
Add buildMu to serialize replaceFileUsers's writer body (including
buildEntry's potentially slow file-backed resolveSecret reads for
"file:"-prefixed API keys). Scope mu.Lock() to the final byDigest
swap only, so a rebuild in progress no longer stalls every identify
call for its whole duration. Behavior-preserving: writers stay fully
serialized, same as before.
Adds a regression test that holds buildMu externally and asserts
identify still succeeds — proving identify's read path does not
depend on the writer-serialization lock.