Skip to content

fix(authx): prevent concurrent dynamic fetch race#6946

Closed
Godzilla675 wants to merge 3 commits intoprojectdiscovery:devfrom
Godzilla675:fix-authx-dynamic-fetch-race-6592
Closed

fix(authx): prevent concurrent dynamic fetch race#6946
Godzilla675 wants to merge 3 commits intoprojectdiscovery:devfrom
Godzilla675:fix-authx-dynamic-fetch-race-6592

Conversation

@Godzilla675
Copy link
Contributor

@Godzilla675 Godzilla675 commented Feb 18, 2026

Proposed Changes

  • replace Dynamic.Fetch atomic-flag race path with sync.Once-based synchronization
  • ensure GetStrategies waits for fetch completion before returning auth strategies
  • add concurrency regression tests for authx dynamic fetch and file auth provider strategy application

Proof

Summary by CodeRabbit

  • Tests

    • Added concurrent tests that validate dynamic secret fetch runs exactly once and that all waiters unblock correctly under heavy concurrent access; includes an end-to-end concurrent scenario.
  • Bug Fixes

    • Improved fetch/error handling and messaging when initialization or re-validation is missing or repeated, allowing re-validation to reset state so secrets can be re-fetched predictably.
  • Refactor

    • Streamlined internal fetch state and concurrency control to make fetch behavior more reliable under concurrency.

Use sync.Once-backed fetch synchronization for dynamic secrets, ensure strategy lookup waits for fetch completion, and add concurrency regression tests for authx and file auth provider paths.
Copilot AI review requested due to automatic review settings February 18, 2026 22:21
@auto-assign auto-assign bot requested a review from dwisiswant0 February 18, 2026 22:21
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 18, 2026

Walkthrough

Replaces atomic flag-based lazy-fetch state with a per-instance fetchState using sync.Once and an err sentinel (errNotValidated); refactors Validate, Fetch, GetStrategies, and Error to use the new one-time fetch lifecycle. Adds concurrent tests ensuring single-execution semantics.

Changes

Cohort / File(s) Summary
Concurrency state & logic
pkg/authprovider/authx/dynamic.go
Replaces atomic fetched/fetching/error fields with fetchState *fetchState (once sync.Once, err error). Adds errNotValidated sentinel. Validate() initializes/reset fetchState. Fetch(), GetStrategies(), and Error() refactored to use fetchState/sync.Once and fetchState.err for error propagation; removes atomic CAS logic.
Unit tests — dynamic fetch concurrency
pkg/authprovider/authx/dynamic_test.go
Adds TestDynamicFetchConcurrent with subtests that assert concurrent Fetch() calls block until the one callback runs and that the callback executes exactly once. Uses channels, sync.WaitGroup, and atomic counters for synchronization and verification.
Integration test — file provider concurrency
pkg/authprovider/file_test.go
Adds TestFileAuthProviderDynamicSecretConcurrentAccess which writes a secret file and runs 20 concurrent lookups for the same domain to verify the dynamic secret fetch runs only once and all requests receive the expected Authorization header.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nibbled at flags and found a single door,
Once hopped through it, callers waited no more.
One callback, one carrot, no races to fight,
All paws aligned — concurrency done right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(authx): prevent concurrent dynamic fetch race' directly and accurately summarizes the main change: replacing atomic-flag-based synchronization with sync.Once to prevent race conditions in concurrent dynamic fetching.
Linked Issues check ✅ Passed The PR directly addresses issue #6592 by implementing sync.Once-based synchronization to ensure dynamic secret-file authentication completes before template execution, and adds concurrency regression tests demonstrating the fix.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue: refactoring Dynamic's fetch state management, ensuring GetStrategies waits for completion, and adding concurrency tests—no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates authx.Dynamic’s lazy-fetch synchronization to prevent concurrent fetch races, and adds regression tests to validate correct blocking/once-only behavior under concurrency.

Changes:

  • Replace the previous atomic-flag fetch gating with sync.Once-based coordination in Dynamic.Fetch.
  • Ensure Dynamic.GetStrategies waits for fetch completion before returning strategies.
  • Add concurrency regression tests for both authx.Dynamic and FileAuthProvider dynamic-secret strategy application.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
pkg/authprovider/authx/dynamic.go Switches dynamic secret fetch coordination to sync.Once and forces GetStrategies to wait for fetch completion.
pkg/authprovider/authx/dynamic_test.go Adds concurrent fetch tests validating waiters block and callback executes only once.
pkg/authprovider/file_test.go Adds a concurrent-access regression test for file-based dynamic secrets applying strategies safely.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +214 to +220
d.fetchOnce.Do(func() {
if d.fetchCallback == nil {
d.error = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
return
}
d.error = d.fetchCallback(d)
})
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dynamic is copied by value in DynamicAuthStrategy (authx/strategy.go:20-23) and when strategies are built in FileAuthProvider.init (file.go:91+). With fetchOnce shared across those copies but d.error stored per-copy, only the copy that actually executes the Do closure will capture the callback error; other copies will skip the closure and may incorrectly return nil (and GetStrategies may proceed) even if the fetch failed. To make the fetch result consistent for all callers, keep the fetch state (once + error, and ideally extracted values) in a shared pointer owned by Dynamic, or avoid copying Dynamic by storing/passing it as *Dynamic in strategies/providers.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/authprovider/authx/dynamic.go (1)

186-202: ⚠️ Potential issue | 🟡 Minor

GetStrategies calls Fetch(isFatal: true) — any fetch error terminates the process.

The Fetch() method explicitly documents (line 205) that isFatal: true will "stop the execution if the secret could not be fetched" by calling gologger.Fatal().Msgf(). This means a transient network error during dynamic secret resolution will terminate the entire application rather than returning an empty strategy list.

The Apply() method in strategy.go (line 27) gracefully handles a nil return with an explicit check, but ApplyOnRR() (line 41) does not. If this termination behavior is unintended, consider using isFatal: false and letting the d.error != nil check at line 191 return nil naturally.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic.go` around lines 186 - 202, GetStrategies
currently calls d.Fetch(true) which will terminate the process on fetch errors;
change the call to d.Fetch(false) so fetch failures are non-fatal and let the
existing d.error != nil check return nil strategies; reference
Dynamic.GetStrategies and Fetch(isFatal bool). Also verify consumers like
ApplyOnRR handle a nil strategies return (or add a nil-check there) to avoid
panics when no strategies are returned.
🧹 Nitpick comments (2)
pkg/authprovider/authx/dynamic_test.go (1)

189-222: Consider adding a barrier for stronger concurrent overlap guarantee.

The "fetch-callback-runs-once" subtest relies on the 20ms sleep in the callback to provide a window for concurrent goroutines to arrive. Without a barrier (like the first subtest's fetchStarted channel), there's no guarantee that all 20 goroutines enter Fetch before the first one completes — especially on fast machines or under low load. This is fine as a regression test and the assertion is still valid, but a close(barrier) pattern (as used in file_test.go) would make the timing-independence explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic_test.go` around lines 189 - 222, The test
"fetch-callback-runs-once" can race because it relies solely on a sleep to allow
other goroutines to enter Fetch; add a barrier channel (e.g., fetchStarted :=
make(chan struct{})) and change the SetLazyFetchCallback closure to block on
that channel (e.g., <-fetchStarted) before doing the sleep and setting
dynamic.Extracted, then start all goroutines, close(fetchStarted) to release the
callback once all goroutines are waiting, and proceed with wg.Wait/err
collection; reference the test name ("fetch-callback-runs-once") and the
Dynamic.SetLazyFetchCallback closure to locate and modify the code.
pkg/authprovider/authx/dynamic.go (1)

17-18: Document the re-validation behavior of Validate(): Calling Validate() after a successful Fetch() replaces fetchOnce with a fresh sync.Once, making the Dynamic instance re-fetchable. If this is intentional (e.g., for credential refresh), add a comment clarifying the design. If not, consider either preventing re-initialization or resetting fetchOnce only if it's nil.

Additionally, while the current call-sites safely separate Validate() (during initialization in NewFileAuthProvider) from Fetch() (during runtime), the API doesn't enforce this constraint. If Validate() and Fetch() were called concurrently on the same instance, there would be a data race on the fetchOnce pointer. Consider documenting that Validate() must not be called concurrently with Fetch().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic.go` around lines 17 - 18, Document that
calling Dynamic.Validate() after a successful Dynamic.Fetch() resets the
internal fetchOnce (*sync.Once) making the instance re-fetchable (used for
credential refresh), and explicitly state that Validate() must not be called
concurrently with Fetch() because resetting fetchOnce races with Fetch()—add
this comment to the Dynamic type and to the Validate() method (reference:
Dynamic, Validate(), Fetch(), fetchOnce *sync.Once); alternatively, if you
prefer to prevent re-initialization, adjust Validate() to only reset fetchOnce
when it is nil (instead of always replacing it) and document that choice and its
concurrency implications.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@pkg/authprovider/authx/dynamic.go`:
- Around line 186-202: GetStrategies currently calls d.Fetch(true) which will
terminate the process on fetch errors; change the call to d.Fetch(false) so
fetch failures are non-fatal and let the existing d.error != nil check return
nil strategies; reference Dynamic.GetStrategies and Fetch(isFatal bool). Also
verify consumers like ApplyOnRR handle a nil strategies return (or add a
nil-check there) to avoid panics when no strategies are returned.

---

Nitpick comments:
In `@pkg/authprovider/authx/dynamic_test.go`:
- Around line 189-222: The test "fetch-callback-runs-once" can race because it
relies solely on a sleep to allow other goroutines to enter Fetch; add a barrier
channel (e.g., fetchStarted := make(chan struct{})) and change the
SetLazyFetchCallback closure to block on that channel (e.g., <-fetchStarted)
before doing the sleep and setting dynamic.Extracted, then start all goroutines,
close(fetchStarted) to release the callback once all goroutines are waiting, and
proceed with wg.Wait/err collection; reference the test name
("fetch-callback-runs-once") and the Dynamic.SetLazyFetchCallback closure to
locate and modify the code.

In `@pkg/authprovider/authx/dynamic.go`:
- Around line 17-18: Document that calling Dynamic.Validate() after a successful
Dynamic.Fetch() resets the internal fetchOnce (*sync.Once) making the instance
re-fetchable (used for credential refresh), and explicitly state that Validate()
must not be called concurrently with Fetch() because resetting fetchOnce races
with Fetch()—add this comment to the Dynamic type and to the Validate() method
(reference: Dynamic, Validate(), Fetch(), fetchOnce *sync.Once); alternatively,
if you prefer to prevent re-initialization, adjust Validate() to only reset
fetchOnce when it is nil (instead of always replacing it) and document that
choice and its concurrency implications.

Ensure concurrent Dynamic value copies observe the same fetch error by storing sync.Once and error in shared state, and strengthen concurrency test with a start barrier.
@Godzilla675
Copy link
Contributor Author

Addressed review feedback: Dynamic is copied by value in DynamicAuthStrategy, so Fetch error state must be shared across copies. Reworked Dynamic fetch synchronization to store sync.Once+error in a shared *fetchState and updated the concurrency test to start goroutines behind a barrier. PR head updated to a3591da.

Call Fetch(false) from Dynamic.GetStrategies so dynamic auth fetch errors do not terminate the scan; strategies are skipped when fetch fails.
@Godzilla675
Copy link
Contributor Author

Addressed remaining review notes: Dynamic.GetStrategies now calls Fetch(false) (non-fatal) and returns nil strategies when fetch fails, matching previous non-fatal behavior after a failed prefetch. PR head updated to 5b126df.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
pkg/authprovider/authx/dynamic.go (1)

213-232: Capture d.fetchState in a local variable to make the single-acquisition semantics explicit.

Fetch() currently dereferences d.fetchState at least five times (nil check, once.Do selector, two writes inside the closure, two reads after Do). Under the documented "no concurrent Validate()" contract this is safe, but if the captured d and the raw field access inside the once.Do closure diverge (e.g., Validate() races despite the warning), the closure ends up calling once.Do on the old fetchState.once while writing fetchState.err to whatever d.fetchState happens to point to at execution time. Snapshotting into a local removes all five re-reads and aligns the Once call site with its error target.

♻️ Proposed refactor
 func (d *Dynamic) Fetch(isFatal bool) error {
-	if d.fetchState == nil {
+	state := d.fetchState
+	if state == nil {
 		if isFatal {
 			gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", errNotValidated)
 		}
 		return errNotValidated
 	}

-	d.fetchState.once.Do(func() {
+	state.once.Do(func() {
 		if d.fetchCallback == nil {
-			d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
+			state.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
 			return
 		}
-		d.fetchState.err = d.fetchCallback(d)
+		state.err = d.fetchCallback(d)
 	})

-	if d.fetchState.err != nil && isFatal {
-		gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", d.fetchState.err)
+	if state.err != nil && isFatal {
+		gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", state.err)
 	}
-	return d.fetchState.err
+	return state.err
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic.go` around lines 213 - 232, The Fetch method
should snapshot d.fetchState into a local variable to avoid multiple field reads
and ensure the once.Do and err writes/read operate on the same fetchState
instance; modify Dynamic.Fetch to assign s := d.fetchState at the top, use s for
the nil check, call s.once.Do(...) and set/read s.fetchState.err via
s.fetchState.err (i.e., s.err) and leave existing checks for s.fetchCallback
(and the log/error paths calling gologger and errkit) unchanged; ensure
references to SetLazyFetchCallback() remain valid and the behavior for isFatal
is preserved.
pkg/authprovider/authx/dynamic_test.go (1)

150-158: Optional: add a start barrier to maximize concurrent overlap in all-waiters-block-until-done.

Unlike fetch-callback-runs-once, this subtest launches goroutines without a synchronizing barrier before they call Fetch(). The 25 ms non-completion check remains logically correct (a goroutine that hasn't called wg.Done() keeps wg.Wait() live regardless), but without a barrier some goroutines may not have entered once.Do when the 25 ms window opens, reducing the test's ability to detect a regression where concurrent callers escape before the callback completes.

♻️ Proposed refactor
+	barrier := make(chan struct{})
 	for i := 0; i < numGoroutines; i++ {
 		go func(idx int) {
 			defer wg.Done()
+			<-barrier
 			results[idx] = d.Fetch(false)
 		}(i)
 	}
+	close(barrier)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic_test.go` around lines 150 - 158, The test
should introduce a start barrier so all goroutines call d.Fetch() at the same
time: create a start channel or a second sync.WaitGroup (e.g., startWG or
startCh), have each goroutine wait on that barrier before calling d.Fetch(),
launch all goroutines with wg.Add(numGoroutines) as before, then release the
barrier (close startCh or call startWG.Done()/startWG.Wait()) so they
concurrently enter d.Fetch() and the once.Do path; this maximizes overlap for
the all-waiters-block-until-done subtest while keeping existing wg and results
usage unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/authprovider/authx/dynamic.go`:
- Around line 17-18: The sentinel error errNotValidated is unexported but
returned by the exported Fetch(ctx, ...) method, preventing callers from using
errors.Is; rename and export it (e.g., ErrNotValidated) and update its
declaration (var ErrNotValidated = errkit.New(...)) and all internal references
(any usages in Validate, Fetch, tests) so external packages can perform
errors.Is(err, authx.ErrNotValidated) to detect the condition.

---

Nitpick comments:
In `@pkg/authprovider/authx/dynamic_test.go`:
- Around line 150-158: The test should introduce a start barrier so all
goroutines call d.Fetch() at the same time: create a start channel or a second
sync.WaitGroup (e.g., startWG or startCh), have each goroutine wait on that
barrier before calling d.Fetch(), launch all goroutines with
wg.Add(numGoroutines) as before, then release the barrier (close startCh or call
startWG.Done()/startWG.Wait()) so they concurrently enter d.Fetch() and the
once.Do path; this maximizes overlap for the all-waiters-block-until-done
subtest while keeping existing wg and results usage unchanged.

In `@pkg/authprovider/authx/dynamic.go`:
- Around line 213-232: The Fetch method should snapshot d.fetchState into a
local variable to avoid multiple field reads and ensure the once.Do and err
writes/read operate on the same fetchState instance; modify Dynamic.Fetch to
assign s := d.fetchState at the top, use s for the nil check, call
s.once.Do(...) and set/read s.fetchState.err via s.fetchState.err (i.e., s.err)
and leave existing checks for s.fetchCallback (and the log/error paths calling
gologger and errkit) unchanged; ensure references to SetLazyFetchCallback()
remain valid and the behavior for isFatal is preserved.

Comment on lines +17 to +18
// errNotValidated is returned when Fetch is called before Validate.
var errNotValidated = errkit.New("dynamic secret not validated: call Validate() before Fetch()")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

errNotValidated is unexported but surfaced through the exported Fetch() method.

When Fetch(false) is called before Validate(), it returns errNotValidated. Because the sentinel is unexported, callers outside the authx package cannot perform a typed check with errors.Is(err, errNotValidated) and must either inspect the error message or treat it as a generic non-nil error.

If callers in other packages need to programmatically distinguish this condition, consider exporting it as ErrNotValidated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic.go` around lines 17 - 18, The sentinel error
errNotValidated is unexported but returned by the exported Fetch(ctx, ...)
method, preventing callers from using errors.Is; rename and export it (e.g.,
ErrNotValidated) and update its declaration (var ErrNotValidated =
errkit.New(...)) and all internal references (any usages in Validate, Fetch,
tests) so external packages can perform errors.Is(err, authx.ErrNotValidated) to
detect the condition.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
pkg/authprovider/authx/dynamic.go (1)

238-243: Error() reads fetchState.err without once.Do — potential data race under -race.

Fetch() writes fetchState.err exclusively inside once.Do. Per the Go memory model, only goroutines that call once.Do after the first execution observe a happens-before guarantee with those writes. Error() bypasses once.Do entirely, so a goroutine that calls Error() concurrently with the first Fetch() (or without having previously called Fetch()) has no synchronization guarantee on the fetchState.err read — the Go race detector would flag this.

Using once.Do(func(){}) inside Error() to establish happens-before would break the fetch: if Error() fires before Fetch(), it consumes the once and fetchCallback never runs.

The lowest-friction remediation is to document the constraint explicitly, or switch fetchState.err to an atomic.Pointer:

♻️ Option A — atomic storage (race-free without extra docs)
 type fetchState struct {
-	once sync.Once
-	err  error
+	once sync.Once
+	err  atomic.Pointer[error] // nil pointer means no error
 }

In Fetch(), replace d.fetchState.err = ... with a helper that stores a non-nil pointer on error:

// inside once.Do:
if err := d.fetchCallback(d); err != nil {
    d.fetchState.err.Store(&err)
}

In Error():

if d.fetchState == nil {
    return nil
}
if p := d.fetchState.err.Load(); p != nil {
    return *p
}
return nil
♻️ Option B — document the constraint (minimal change)
-// Error returns the error if any
+// Error returns the fetch error, if any.
+// It is only safe to call after Fetch() has returned in the same goroutine
+// or with external happens-before synchronization.
 func (d *Dynamic) Error() error {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic.go` around lines 238 - 243, Error() currently
reads d.fetchState.err without synchronization and can race with Fetch() which
writes it inside once.Do; fix by making fetchState.err access race-free: either
convert fetchState.err to an atomic.Pointer to an error and use atomic
Store/Load in Fetch() and Error() (referencing Dynamic.fetchState.err,
Dynamic.Fetch(), and Dynamic.Error()), or document the caller constraint that
Error() must only be called after a successful Fetch() completes; implement the
atomic-pointer approach if you want no behavioral change and no extra caller
documentation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/authprovider/authx/dynamic.go`:
- Around line 196-201: The current code calls d.Fetch(false) but ignores its
return value, so when Validate() wasn't called d.fetchState stays nil and the
subsequent check (d.fetchState != nil && d.fetchState.err != nil) is bypassed,
causing raw templated secrets to be used; change the call to capture and check
the returned error from d.Fetch(false) (i.e., err := d.Fetch(false); if err !=
nil { return nil } ) so all error paths including errNotValidated are handled
instead of relying on d.fetchState being non-nil; update references around
Fetch(false), fetchState, and Validate() accordingly.

---

Duplicate comments:
In `@pkg/authprovider/authx/dynamic.go`:
- Around line 17-18: errNotValidated is currently unexported but returned from
the exported Fetch() method; change it to an exported symbol (ErrNotValidated)
so callers can use errors.Is(err, authx.ErrNotValidated). Update the declaration
of errNotValidated to ErrNotValidated (same errkit.New message) and update any
internal references (e.g., where Fetch() returns it and any tests) to use the
new ErrNotValidated identifier, ensuring package authx exports the error for
external callers.

---

Nitpick comments:
In `@pkg/authprovider/authx/dynamic.go`:
- Around line 238-243: Error() currently reads d.fetchState.err without
synchronization and can race with Fetch() which writes it inside once.Do; fix by
making fetchState.err access race-free: either convert fetchState.err to an
atomic.Pointer to an error and use atomic Store/Load in Fetch() and Error()
(referencing Dynamic.fetchState.err, Dynamic.Fetch(), and Dynamic.Error()), or
document the caller constraint that Error() must only be called after a
successful Fetch() completes; implement the atomic-pointer approach if you want
no behavioral change and no extra caller documentation.

Comment on lines +196 to 201
// Fetch errors are treated as non-fatal here so a failed dynamic auth fetch
// does not terminate the entire scan process.
_ = d.Fetch(false)

if d.error != nil {
if d.fetchState != nil && d.fetchState.err != nil {
return nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

nil fetchState bypasses the error guard — raw, uninterpolated strategies are returned.

When Validate() was never called, d.fetchState is nil. Fetch(false) returns errNotValidated but does not set d.fetchState, leaving it nil. The subsequent condition:

if d.fetchState != nil && d.fetchState.err != nil

short-circuits to false because d.fetchState == nil, so execution falls through and builds AuthStrategy values from the raw, uninterpolated secrets (still containing {{...}} template markers). These strategies are then applied to outbound requests, resulting in exactly the unauthenticated-request scenario this PR is meant to eliminate.

The fix is to use the return value of Fetch(false) directly, which is both idiomatic and covers all error paths including the nil fetchState case:

🐛 Proposed fix
-	_ = d.Fetch(false)
-
-	if d.fetchState != nil && d.fetchState.err != nil {
+	if err := d.Fetch(false); err != nil {
		return nil
	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/authprovider/authx/dynamic.go` around lines 196 - 201, The current code
calls d.Fetch(false) but ignores its return value, so when Validate() wasn't
called d.fetchState stays nil and the subsequent check (d.fetchState != nil &&
d.fetchState.err != nil) is bypassed, causing raw templated secrets to be used;
change the call to capture and check the returned error from d.Fetch(false)
(i.e., err := d.Fetch(false); if err != nil { return nil } ) so all error paths
including errNotValidated are handled instead of relying on d.fetchState being
non-nil; update references around Fetch(false), fetchState, and Validate()
accordingly.

@Mzack9999
Copy link
Member

Thank you for your contribution @Godzilla675! Your mutex-based approach to preventing concurrent fetch races was a solid attempt at solving the problem.

We merged #6976 which uses sync.Once — the idiomatic Go solution for this exact pattern. sync.Once internally handles all the synchronization details (memory barriers, blocking waiters) that a manual mutex+boolean approach needs to carefully get right. The merged PR also includes thorough concurrency tests with race detection to verify correctness.

Closing this as the issue has been resolved. Thank you for contributing to nuclei!

@Mzack9999 Mzack9999 closed this Mar 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Authenticated scanning starts executing templates before the secret-file template finishes

3 participants