rc: stop global.* connection string options changing config CVE-2026-49980

A connection string can carry global.* options which change rclone's
process-wide configuration (e.g. global.http_proxy). This is
undesirable for the rc interface which was designed to have multiple
users or connections at once. The rc interface has the `_config`
mechanism for setting request scoped global config.

This blocks global.* options on all rc paths by marking the context as
a remote control request at the rc boundaries. fs.NewFs then skips
applying global.* to the process-wide config for a marked context.

The marker is reapplied in fs.CopyConfig, which is the call rclone
uses to detach context but keep config.

global.* options still apply to the individual backend they are set
on, exactly like override.* options; they just no longer leak into the
rest of the process. Remotes created directly on the command line are
unaffected as are remotes defined in the config file.

See: GHSA-qw24-gh76-8rvv
This commit is contained in:
Nick Craig-Wood
2026-06-05 15:21:01 +01:00
parent 2326ea79f7
commit 53f972830c
9 changed files with 135 additions and 5 deletions
+3
View File
@@ -328,6 +328,9 @@ func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Jo
// Add the job to the context
ctx = context.WithValue(ctx, jobKey, job)
// Mark the context as created from an rc request
ctx = fs.WithRCRequest(ctx)
if isAsync {
go job.run(ctx, fn, in)
out = make(rc.Params)
+31
View File
@@ -300,6 +300,37 @@ func TestExecuteJobWithConfig(t *testing.T) {
assert.NotEqual(t, 42*fs.Mebi, ci.BufferSize)
}
// NewJob must mark the context as a remote control (rc) request, including for
// asynchronous jobs whose context is detached
func TestNewJobMarksRCRequest(t *testing.T) {
jobID.Store(0)
jobs := newJobs() // local instance so we don't pollute the global registry
// synchronous job
var syncMarked bool
_, _, err := jobs.NewJob(context.Background(), func(ctx context.Context, in rc.Params) (rc.Params, error) {
syncMarked = fs.IsRCRequest(ctx)
return nil, nil
}, rc.Params{})
require.NoError(t, err)
assert.True(t, syncMarked, "sync rc job context must be marked as an rc request")
// asynchronous job - the context is detached, so the marker must be set
// after that detachment to survive
done := make(chan bool, 1)
_, _, err = jobs.NewJob(context.Background(), func(ctx context.Context, in rc.Params) (rc.Params, error) {
done <- fs.IsRCRequest(ctx)
return nil, nil
}, rc.Params{"_async": true})
require.NoError(t, err)
select {
case asyncMarked := <-done:
assert.True(t, asyncMarked, "async rc job context must be marked as an rc request")
case <-time.After(5 * time.Second):
t.Fatal("async job did not run")
}
}
func TestExecuteJobWithFilter(t *testing.T) {
ctx := context.Background()
called := false
+3 -2
View File
@@ -348,7 +348,7 @@ func (s *Server) serveRoot(w http.ResponseWriter, r *http.Request) {
// Instantiating a backend from request-supplied configuration can execute
// commands during initialisation (e.g. webdav bearer_token_command, sftp ssh),
// read arbitrary local files, or mutate process-wide config via global.*
// options. See GHSA-qw24-gh76-8rvv.
// options so shouldn't be done without authentication.
//
// authenticated must be true if the request has been authenticated (HTTP auth
// is configured on the server) or --rc-no-auth was passed to explicitly opt in
@@ -391,7 +391,8 @@ func (s *Server) serveRemote(w http.ResponseWriter, r *http.Request, path string
writeError(path, nil, w, err, http.StatusForbidden)
return
}
f, err := cache.Get(s.ctx, fsName)
// Mark as an rc request e.g. so NewFs can reject global.* config
f, err := cache.Get(fs.WithRCRequest(s.ctx), fsName)
if err != nil {
writeError(path, nil, w, fmt.Errorf("failed to make Fs: %w", err), http.StatusInternalServerError)
return
+24
View File
@@ -480,6 +480,30 @@ func TestServeRemoteWithAuth(t *testing.T) {
testServer(t, tests, &opt)
}
// The serve path must mark backend creation as a remote control (rc) request
// so a request-supplied remote can't change process-wide config via global.*
// options.
func TestServeRemoteMarksRCRequest(t *testing.T) {
configfile.Install()
opt := newTestOpt()
opt.Serve = true
opt.NoAuth = true // allow inline remotes so we reach backend creation
opt.Template.Path = defaultTestTemplate
rcServer, err := newServer(context.Background(), &opt, http.NewServeMux())
require.NoError(t, err)
ctx := context.Background()
original := fs.GetConfig(ctx).UserAgent
defer func() { fs.GetConfig(ctx).UserAgent = original }()
// serveRemote uses s.ctx, marked as an rc request, so global.* must not
// change the process-wide config.
rcServer.serveRemote(httptest.NewRecorder(), httptest.NewRequest("GET", "/file.txt", nil),
"file.txt", ":local,global.user_agent=rcservertest:"+testFs)
assert.Equal(t, original, fs.GetConfig(ctx).UserAgent,
"the serve path must not let global.* change the process-wide config")
}
func TestRC(t *testing.T) {
tests := []testRun{{
Name: "rc-root",