Skip to content

Commit 6e04dba

Browse files
committed
Fix reference token privilege escalation
1 parent 89d5ff4 commit 6e04dba

File tree

4 files changed

+170
-1
lines changed

4 files changed

+170
-1
lines changed

common/commands/config.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ const (
4747

4848
const (
4949
// Default config command name.
50-
configCommandName = "config"
50+
configCommandName = "config"
51+
referenceTokenPrefix = "cmVmdGtuOj"
5152
)
5253

5354
// Internal golang locking for the same process.
@@ -295,10 +296,21 @@ func (cc *ConfigCommand) configRefreshableTokenIfPossible() {
295296
if cc.details.User == "" || cc.details.Password == "" {
296297
return
297298
}
299+
if isReferenceToken(cc.details.Password) {
300+
log.Info("Detected a reference token as the password. Using basic authentication to preserve " +
301+
"the token's restricted scope. The automatic token refresh mechanism is disabled.")
302+
cc.useBasicAuthOnly = true
303+
return
304+
}
298305
// Set the default interval for the refreshable tokens to be initialized in the next CLI run.
299306
cc.details.ArtifactoryTokenRefreshInterval = coreutils.TokenRefreshDefaultInterval
300307
}
301308

309+
// isReferenceToken checks whether the given value is a JFrog reference token.
310+
func isReferenceToken(token string) bool {
311+
return strings.HasPrefix(token, referenceTokenPrefix)
312+
}
313+
302314
func (cc *ConfigCommand) prepareConfigurationData() ([]*config.ServerDetails, error) {
303315
// If details is nil, initialize a new one
304316
if cc.details == nil {

common/commands/config_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"encoding/base64"
45
"encoding/json"
56
"github.com/jfrog/jfrog-cli-core/v2/general/token"
67
"os"
@@ -16,6 +17,10 @@ import (
1617
"github.com/stretchr/testify/assert"
1718
)
1819

20+
func getReferenceToken(suffix string) string {
21+
return base64.StdEncoding.EncodeToString([]byte("reftkn:01:1776144219:" + suffix))
22+
}
23+
1924
const (
2025
testServerId = "test"
2126
//#nosec G101 - Dummy token for tests.
@@ -191,6 +196,52 @@ func TestBasicAuthOnlyOption(t *testing.T) {
191196
assert.NoError(t, NewConfigCommand(Delete, testServerId).Run())
192197
}
193198

199+
func TestReferenceTokenAutoDetection(t *testing.T) {
200+
cleanUpJfrogHome, err := utilsTests.SetJfrogHome()
201+
assert.NoError(t, err)
202+
defer cleanUpJfrogHome()
203+
204+
inputDetails := tests.CreateTestServerDetails()
205+
inputDetails.User = "testuser"
206+
207+
inputDetails.Password = getReferenceToken("RefToken")
208+
outputConfig, err := configAndGetTestServer(t, inputDetails, false, false)
209+
assert.NoError(t, err)
210+
assert.Equal(t, coreutils.TokenRefreshDisabled, outputConfig.ArtifactoryTokenRefreshInterval,
211+
"Reference token detected: token refresh should be auto-disabled")
212+
assert.NoError(t, NewConfigCommand(Delete, testServerId).Run())
213+
214+
// Regular password (not a reference token) should still enable token refresh.
215+
inputDetails.Password = "my-regular-password"
216+
inputDetails.ArtifactoryTokenRefreshInterval = 0
217+
outputConfig, err = configAndGetTestServer(t, inputDetails, false, false)
218+
assert.NoError(t, err)
219+
assert.Equal(t, coreutils.TokenRefreshDefaultInterval, outputConfig.ArtifactoryTokenRefreshInterval,
220+
"Regular password: token refresh should remain enabled")
221+
assert.NoError(t, NewConfigCommand(Delete, testServerId).Run())
222+
}
223+
224+
func TestIsReferenceToken(t *testing.T) {
225+
tests := []struct {
226+
name string
227+
token string
228+
expected bool
229+
}{
230+
{"valid reference token", getReferenceToken("refToken"), true},
231+
{"regular password", "my-password-123", false},
232+
{"API key", "AKCp8kqqMa7buMW3FeN28joXt", false},
233+
{"JWT access token", "eyJ2ZXIiOiIyIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJ0ZXN0In0.signature", false},
234+
{"empty string", "", false},
235+
{"partial prefix only reftkn", base64.StdEncoding.EncodeToString([]byte("reftkn")) + "XYZ", false},
236+
{"prefix without colon separator", base64.StdEncoding.EncodeToString([]byte("reftkn")) + "SomePassword", false},
237+
}
238+
for _, tt := range tests {
239+
t.Run(tt.name, func(t *testing.T) {
240+
assert.Equal(t, tt.expected, isReferenceToken(tt.token))
241+
})
242+
}
243+
}
244+
194245
func TestMakeDefaultOption(t *testing.T) {
195246
cleanUpJfrogHome, err := utilsTests.SetJfrogHome()
196247
assert.NoError(t, err)

utils/config/tokenrefresh.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"errors"
5+
"strings"
56
"sync"
67
"time"
78

@@ -19,6 +20,8 @@ import (
1920
"github.com/jfrog/jfrog-client-go/utils/log"
2021
)
2122

23+
const referenceTokenPrefix = "cmVmdGtuOj"
24+
2225
// Internal golang locking for the same process.
2326
var mutex sync.Mutex
2427

@@ -206,6 +209,12 @@ func CreateInitialRefreshableTokensIfNeeded(serverDetails *ServerDetails) (err e
206209
if serverDetails.ArtifactoryTokenRefreshInterval <= 0 || serverDetails.ArtifactoryRefreshToken != "" || serverDetails.AccessToken != "" {
207210
return nil
208211
}
212+
if strings.HasPrefix(serverDetails.Password, referenceTokenPrefix) {
213+
log.Info("Reference token detected as password. Skipping automatic token creation " +
214+
"to preserve the token's restricted scope.")
215+
serverDetails.ArtifactoryTokenRefreshInterval = 0
216+
return nil
217+
}
209218
mutex.Lock()
210219
defer mutex.Unlock()
211220
lockDirPath, err := coreutils.GetJfrogConfigLockDir()

utils/config/tokenrefresh_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
package config
22

33
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"sync"
410
"testing"
511

612
configtests "github.com/jfrog/jfrog-cli-core/v2/utils/config/tests"
713
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
815
)
916

17+
func fakeReferenceToken(suffix string) string {
18+
return base64.StdEncoding.EncodeToString([]byte("reftkn:01:1776144219:" + suffix))
19+
}
20+
1021
func TestCreateInitialRefreshableTokensIfNeededEarlyReturns(t *testing.T) {
1122
tests := []struct {
1223
name string
@@ -570,3 +581,89 @@ func TestCreateInitialRefreshableTokensIfNeededBranchCoverage(t *testing.T) {
570581
assert.NotNil(t, serverDetails)
571582
})
572583
}
584+
585+
func TestReferenceTokenBlocksTokenCreation_MockServer(t *testing.T) {
586+
cleanUpTempEnv := configtests.CreateTempEnv(t, false)
587+
defer cleanUpTempEnv()
588+
589+
var mu sync.Mutex
590+
var tokenRequestReceived bool
591+
592+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
593+
if r.URL.Path == "/api/system/version" {
594+
w.WriteHeader(http.StatusOK)
595+
resp := map[string]string{"version": "7.77.0", "revision": "12345"}
596+
json.NewEncoder(w).Encode(resp)
597+
return
598+
}
599+
if r.URL.Path == "/api/security/token" && r.Method == http.MethodPost {
600+
mu.Lock()
601+
tokenRequestReceived = true
602+
mu.Unlock()
603+
w.Header().Set("Content-Type", "application/json")
604+
resp := map[string]interface{}{
605+
"access_token": "mock-jwt-token",
606+
"refresh_token": "mock-refresh-token",
607+
"expires_in": 3600,
608+
"scope": "member-of-groups:*",
609+
"token_type": "Bearer",
610+
}
611+
json.NewEncoder(w).Encode(resp)
612+
return
613+
}
614+
w.WriteHeader(http.StatusNotFound)
615+
}))
616+
defer mockServer.Close()
617+
618+
parsedURL, err := url.Parse(mockServer.URL)
619+
require.NoError(t, err)
620+
artURL := parsedURL.Scheme + "://" + parsedURL.Host + "/api/"
621+
622+
// Test 1: Reference token as password — should NOT trigger token creation
623+
serverDetails := &ServerDetails{
624+
ServerId: "test-ref-token-blocked",
625+
ArtifactoryTokenRefreshInterval: 60,
626+
ArtifactoryRefreshToken: "",
627+
AccessToken: "",
628+
ArtifactoryUrl: mockServer.URL + "/",
629+
Url: artURL,
630+
User: "testuser",
631+
Password: fakeReferenceToken("testBlockedToken"),
632+
}
633+
634+
err = SaveServersConf([]*ServerDetails{serverDetails})
635+
require.NoError(t, err)
636+
637+
err = CreateInitialRefreshableTokensIfNeeded(serverDetails)
638+
assert.NoError(t, err)
639+
640+
mu.Lock()
641+
assert.False(t, tokenRequestReceived,
642+
"FIX VERIFIED: Reference token password should prevent token creation request")
643+
assert.Equal(t, 0, serverDetails.ArtifactoryTokenRefreshInterval,
644+
"Token refresh interval should be reset to 0 for reference tokens")
645+
tokenRequestReceived = false
646+
mu.Unlock()
647+
648+
// Test 2: Regular password — SHOULD trigger token creation
649+
serverDetails2 := &ServerDetails{
650+
ServerId: "test-regular-pass",
651+
ArtifactoryTokenRefreshInterval: 60,
652+
ArtifactoryRefreshToken: "",
653+
AccessToken: "",
654+
ArtifactoryUrl: mockServer.URL + "/",
655+
Url: artURL,
656+
User: "testuser",
657+
Password: "my-regular-password",
658+
}
659+
660+
err = SaveServersConf([]*ServerDetails{serverDetails2})
661+
require.NoError(t, err)
662+
663+
_ = CreateInitialRefreshableTokensIfNeeded(serverDetails2)
664+
665+
mu.Lock()
666+
assert.True(t, tokenRequestReceived,
667+
"Regular password should trigger token creation as before")
668+
mu.Unlock()
669+
}

0 commit comments

Comments
 (0)