Skip to content

Fix race condition in one-time software installer download token - #49569

Merged
juan-fdz-hawa merged 1 commit into
mainfrom
16788-race-condition-in-one-time-software-installer-download-token
Jul 21, 2026
Merged

Fix race condition in one-time software installer download token#49569
juan-fdz-hawa merged 1 commit into
mainfrom
16788-race-condition-in-one-time-software-installer-download-token

Conversation

@juan-fdz-hawa

@juan-fdz-hawa juan-fdz-hawa commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Token redemption consumed the key via a non-atomic read-then-delete, which allowed concurrent requests to redeem the same one-time token more than once. Made consumption atomic so a token can only be used once, even under concurrent access, and added coverage for the concurrent path.

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a race condition that could allow a one-time software installer download token to be redeemed more than once during simultaneous requests.
    • Token consumption is now atomic, ensuring only one request can successfully use each token.

Token redemption consumed the key via a non-atomic read-then-delete,
which allowed concurrent requests to redeem the same one-time token more
than once. Made consumption atomic so a token can only be used once,
even under concurrent access, and added coverage for the concurrent path.
@juan-fdz-hawa
juan-fdz-hawa force-pushed the 16788-race-condition-in-one-time-software-installer-download-token branch from 4781cd5 to 3334956 Compare July 20, 2026 14:53
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

GetAndDelete now uses a Redis Lua script to retrieve and delete a key atomically, replacing separate GET and DEL operations. Error handling is consolidated under "redis GET/DEL". A concurrent test verifies that exactly one of many simultaneous callers receives the stored value, and the change note documents the one-time installer token fix.

Possibly related PRs

  • fleetdm/fleet#49359: Addresses a similar one-time token redemption race using atomic database row locking and deletion.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: fixing the race condition in one-time token redemption.
Description check ✅ Passed The description covers the fix summary, changes file, automated tests, and QA, with only the related-issue line omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 16788-race-condition-in-one-time-software-installer-download-token

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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)
server/service/redis_lock/redis_lock.go (1)

124-138: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use GETDEL or cache the Lua script
GETDEL covers this atomic read-and-delete path on Redis 6.2+, and redigo.NewScript avoids resending the script body if older Redis support is still needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/redis_lock/redis_lock.go` around lines 124 - 138, Update the
atomic read-and-delete logic in the redis lock method using Redis GETDEL, or use
a cached redigo.NewScript when older Redis versions must remain supported.
Preserve the existing nil-result handling and ctxerr.Wrap error path, and avoid
sending the Lua script body on every invocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/service/redis_lock/redis_lock_test.go`:
- Around line 144-159: Update the worker function in the concurrent GetAndDelete
test to avoid calling require.NoError from goroutines. Replace it with
assert.NoError, or collect and report errors after wg.Wait from the main test
goroutine, while preserving the winners counting and synchronization behavior.

---

Nitpick comments:
In `@server/service/redis_lock/redis_lock.go`:
- Around line 124-138: Update the atomic read-and-delete logic in the redis lock
method using Redis GETDEL, or use a cached redigo.NewScript when older Redis
versions must remain supported. Preserve the existing nil-result handling and
ctxerr.Wrap error path, and avoid sending the Lua script body on every
invocation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8428d471-4e48-4a85-91c4-0b77fd11b2c4

📥 Commits

Reviewing files that changed from the base of the PR and between 4303188 and 3334956.

📒 Files selected for processing (3)
  • changes/16788-race-condition-one-time-installer-token
  • server/service/redis_lock/redis_lock.go
  • server/service/redis_lock/redis_lock_test.go

Comment on lines +144 to +159
var wg sync.WaitGroup
var start sync.WaitGroup
var winners atomic.Int64
start.Add(1)
for range workers {
wg.Go(func() {
start.Wait() // release all goroutines at once to maximize contention
got, err := lock.GetAndDelete(ctx, "raceKey")
require.NoError(t, err)
if got != nil {
winners.Add(1)
}
})
}
start.Done()
wg.Wait()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the Go standard library sync.WaitGrouphave aGo method?

💡 Result:

Yes, the Go standard library sync.WaitGroup has a Go method, which was introduced in Go 1.25 [1][2]. The Go method provides a simplified way to manage goroutines with a WaitGroup [2]. It automatically handles the bookkeeping of the internal task counter by calling Add(1) before starting the goroutine and deferring a call to Done within the goroutine to ensure the counter is decremented when the task completes [1][3][2]. Its signature is: func (wg *WaitGroup) Go(f func) This method simplifies the traditional pattern of manually calling Add(1) before spawning a goroutine and ensuring Done is called when it finishes, thereby reducing the risk of counter mismatches [2].

Citations:


🏁 Script executed:

set -euo pipefail

printf '--- go.mod ---\n'
if [ -f go.mod ]; then
  cat -n go.mod | sed -n '1,80p'
else
  echo 'no go.mod found'
fi

printf '\n--- target file excerpt ---\n'
sed -n '120,180p' server/service/redis_lock/redis_lock_test.go

Repository: fleetdm/fleet

Length of output: 242


🏁 Script executed:

set -euo pipefail

echo '--- go.mod ---'
if [ -f go.mod ]; then
  sed -n '1,120p' go.mod
else
  echo 'no go.mod found'
fi

echo
echo '--- server/service/redis_lock/redis_lock_test.go excerpt ---'
sed -n '120,180p' server/service/redis_lock/redis_lock_test.go

Repository: fleetdm/fleet

Length of output: 7021


🌐 Web query:

Go testing.T FailNow from spawned goroutine documentation require.NoError goroutine

💡 Result:

Calling testing.T.FailNow from a spawned goroutine is not supported in Go and can lead to unpredictable behavior, including data races [1][2][3]. Official Go documentation explicitly states that FailNow, as well as related methods like Fatal, Fatalf, Skip, SkipNow, and Skipf, must be called only from the goroutine that is running the test or benchmark function [2][4][5]. The reason for this restriction is that FailNow internally calls runtime.Goexit, which terminates only the current goroutine [2][5]. If called from a spawned goroutine, it will terminate that background goroutine rather than the test itself, meaning the test will not stop as intended [6][7][8]. Furthermore, because these methods interact with the test's internal state (which is not designed for concurrent access from multiple goroutines), calling them concurrently can trigger data races or other undefined behavior [9][3]. Regarding testify's require.NoError: Since require.NoError calls FailNow internally, it inherits the same restriction [9][10]. It is not safe to use require functions inside spawned goroutines [9][11]. If you need to perform checks inside a spawned goroutine, you should use the assert package instead (which calls t.Errorf rather than t.FailNow) or return errors through a channel to the main test goroutine, where you can then safely perform fatal assertions [11][8].

Citations:


Avoid require.NoError inside the worker goroutines. require.NoError calls FailNow, which is only supported from the test goroutine. Use assert.NoError here or report the error back to the main test goroutine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/redis_lock/redis_lock_test.go` around lines 144 - 159, Update
the worker function in the concurrent GetAndDelete test to avoid calling
require.NoError from goroutines. Replace it with assert.NoError, or collect and
report errors after wg.Wait from the main test goroutine, while preserving the
winners counting and synchronization behavior.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 67.82%. Comparing base (fd6209a) to head (3334956).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
server/service/redis_lock/redis_lock.go 88.88% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #49569   +/-   ##
=======================================
  Coverage   67.81%   67.82%           
=======================================
  Files        3890     3890           
  Lines      247631   247635    +4     
  Branches    13175    13175           
=======================================
+ Hits       167942   167952   +10     
+ Misses      64524    64522    -2     
+ Partials    15165    15161    -4     
Flag Coverage Δ
backend 69.23% <88.88%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@juan-fdz-hawa
juan-fdz-hawa merged commit ff7955c into main Jul 21, 2026
45 checks passed
@juan-fdz-hawa
juan-fdz-hawa deleted the 16788-race-condition-in-one-time-software-installer-download-token branch July 21, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants