VYPR
Critical severity9.8GHSA Advisory· Published Jun 16, 2026· Updated Jun 16, 2026

Rclone: Unauthenticated command execution in `rclone rcd --rc-serve` via inline remote instantiation, bypassing CVE-2026-41179 fix

CVE-2026-49980

Description

Unauthenticated command execution in rclone's remote control API via inline remote instantiation in GET/HEAD requests, affecting versions 1.55.0 to 1.74.2.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Unauthenticated command execution in rclone's remote control API via inline remote instantiation in GET/HEAD requests, affecting versions 1.55.0 to 1.74.2.

Vulnerability

The rclone rcd --rc-serve endpoint accepts unauthenticated GET and HEAD requests to paths of the form /[remote:path]/object. The remote value is parsed from the URL and passed to normal backend initialization. Inline remote configuration can set backend options that execute local commands during initialization. Versions from 1.55.0 onwards are vulnerable to command execution; earlier versions (1.46.0 to 1.54.x) are vulnerable to unauthenticated local file read but not command execution because inline backend option overrides did not exist until 1.55.0 [1][2].

Exploitation

Preconditions: the remote control API must be enabled (via --rc flag or rclone rcd), the RC HTTP listener must be reachable by the attacker (default localhost unless --rc-addr is used), no global RC HTTP authentication is configured (i.e., no --rc-user/--rc-pass/--rc-htpasswd), and the --rc-serve flag must be in use. An attacker sends a single unauthenticated GET or HEAD request with a crafted URL containing inline remote options that execute arbitrary commands during backend initialization [1][2].

Impact

An unauthenticated network attacker who can reach the RC HTTP listener can execute commands as the rclone process user. Additional impacts include unauthenticated local file read through inline local remotes, and mutation of process-wide rclone configuration (e.g., global.http_proxy) via inline global.* options. Browser subresource requests (e.g., an `` tag from a public HTTPS page) can trigger the issue against a localhost-only RC listener, expanding the attack surface [1][2].

Mitigation

Upgrade to rclone 1.74.3 (or 1.75.0 when released). Alternatively, configure HTTP authentication on the RC endpoint using --rc-user/--rc-pass or --rc-htpasswd, which has always been the recommended security measure [1][2].

AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

2
53f972830c17

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

https://github.com/rclone/rcloneNick Craig-WoodMay 27, 2026via github-commit-search
9 files changed · +135 5
  • docs/content/rc.md+11 0 modified
    @@ -87,6 +87,17 @@ commands or read arbitrary local files.
     
     Default Off.
     
    +### global.* connection string options and the rc
    +
    +Remotes instantiated by the rc do not let [connection
    +string](/docs/#connection-strings) `global.*` options change rclone's
    +process-wide configuration. Remotes created directly on the command
    +line or defined in the config file are unaffected.
    +
    +A `global.*` option still takes effect for the individual backend it
    +is set on (exactly like an `override.*` option), it just does not leak
    +into the global config for the rest of the process.
    +
     ### --rc-serve-no-modtime
     
     Set this flag to skip reading the modification time (can speed things up).
    
  • fs/config.go+5 1 modified
    @@ -802,11 +802,15 @@ func GetConfig(ctx context.Context) *ConfigInfo {
     }
     
     // CopyConfig copies the global config (if any) from srcCtx into
    -// dstCtx returning the new context.
    +// dstCtx returning the new context. It also copies the rc request
    +// marker if present.
     func CopyConfig(dstCtx, srcCtx context.Context) context.Context {
     	if srcCtx == nil {
     		return dstCtx
     	}
    +	if IsRCRequest(srcCtx) {
    +		dstCtx = WithRCRequest(dstCtx)
    +	}
     	c := srcCtx.Value(configContextKey)
     	if c == nil {
     		return dstCtx
    
  • fs/config_test.go+18 0 modified
    @@ -29,3 +29,21 @@ func TestGetConfig(t *testing.T) {
     	config2ctx := GetConfig(ctx2)
     	assert.Equal(t, config2, config2ctx)
     }
    +
    +// The rc request marker must survive CopyConfig, which is how rclone
    +// does detach context but keep config.
    +func TestRCRequestContext(t *testing.T) {
    +	ctx := context.Background()
    +	assert.False(t, IsRCRequest(ctx))
    +
    +	rcCtx := WithRCRequest(ctx)
    +	assert.True(t, IsRCRequest(rcCtx))
    +
    +	// CopyConfig carries the marker even when there is no config to copy
    +	assert.True(t, IsRCRequest(CopyConfig(context.Background(), rcCtx)))
    +	// and when there is
    +	rcCtx, _ = AddConfig(rcCtx)
    +	assert.True(t, IsRCRequest(CopyConfig(context.Background(), rcCtx)))
    +	// An unmarked context stays unmarked
    +	assert.False(t, IsRCRequest(CopyConfig(context.Background(), ctx)))
    +}
    
  • fs/newfs.go+18 2 modified
    @@ -25,6 +25,22 @@ var (
     	overriddenConfig   = make(map[string]string)
     )
     
    +// rcRequestKey marks a context as created by a remote control (rc) request.
    +type rcRequestKeyType struct{}
    +
    +var rcRequestKey = rcRequestKeyType{}
    +
    +// WithRCRequest marks ctx as created by a remote control (rc) request.
    +func WithRCRequest(ctx context.Context) context.Context {
    +	return context.WithValue(ctx, rcRequestKey, true)
    +}
    +
    +// IsRCRequest returns true if ctx was created by a remote control (rc) request.
    +func IsRCRequest(ctx context.Context) bool {
    +	v, _ := ctx.Value(rcRequestKey).(bool)
    +	return v
    +}
    +
     // NewFs makes a new Fs object from the path
     //
     // The path is of the form remote:path
    @@ -115,8 +131,8 @@ func addConfigToContext(ctx context.Context, configName string, config configmap
     		return ctx, fmt.Errorf("failed to set override config variables %q: %w", overrideKeys, err)
     	}
     	Debugf(configName, "Set overridden config %q for backend startup", overrideKeys)
    -	// Set the global context only
    -	if len(globalConfig) != 0 {
    +	// Set the global context unless this Fs is being created for an rc request
    +	if len(globalConfig) != 0 && !IsRCRequest(ctx) {
     		globalCI := GetConfig(context.Background())
     		err = configstruct.Set(globalConfig, globalCI)
     		if err != nil {
    
  • fs/newfs_internal_test.go+22 0 modified
    @@ -53,3 +53,25 @@ func TestAddConfigToContext_GlobalOnly(t *testing.T) {
     	ci := GetConfig(newCtx)
     	assert.Equal(t, "potato2", ci.UserAgent)
     }
    +
    +// When the ctx is marked as a remote control (rc) request, a global.key must
    +// apply to the backend's own ctx but must NOT change the process-wide config.
    +func TestAddConfigToContext_GlobalFromRC(t *testing.T) {
    +	global := configmap.Simple{
    +		"global.user_agent": "potato3",
    +	}
    +	ctx := WithRCRequest(context.Background())
    +	globalCI := GetConfig(ctx)
    +	original := globalCI.UserAgent
    +	defer func() {
    +		globalCI.UserAgent = original
    +	}()
    +	newCtx, err := addConfigToContext(ctx, "unit-test", global)
    +	require.NoError(t, err)
    +	assert.NotEqual(t, newCtx, ctx)
    +	// The process-wide config must be untouched
    +	assert.Equal(t, original, globalCI.UserAgent)
    +	// but the backend's own ctx still gets the value
    +	ci := GetConfig(newCtx)
    +	assert.Equal(t, "potato3", ci.UserAgent)
    +}
    
  • fs/rc/jobs/job.go+3 0 modified
    @@ -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)
    
  • fs/rc/jobs/job_test.go+31 0 modified
    @@ -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
    
  • fs/rc/rcserver/rcserver.go+3 2 modified
    @@ -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
    
  • fs/rc/rcserver/rcserver_test.go+24 0 modified
    @@ -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",
    
2326ea79f7dd

rc: fix unauthenticated command execution via --rc-serve inline remotes CVE-2026-49980

https://github.com/rclone/rcloneNick Craig-WoodMay 27, 2026via github-commit-search
3 files changed · +163 0
  • docs/content/rc.md+7 0 modified
    @@ -78,6 +78,13 @@ so you can browse to `http://127.0.0.1:5572/` or `http://127.0.0.1:5572/*`
     to see a listing of the remotes.  Objects may be requested from
     remotes using this syntax `http://127.0.0.1:5572/[remote:path]/path/to/object`
     
    +Unless the rc server has authentication configured (`--rc-user`/`--rc-pass`
    +or `--rc-htpasswd`) or the `--rc-no-auth` flag is set, only remotes already
    +present in the config file may be served this way. Inline remotes (e.g.
    +`[:webdav,url=...:]`), connection string parameters and bare local paths are
    +rejected, since instantiating them from an unauthenticated request could run
    +commands or read arbitrary local files.
    +
     Default Off.
     
     ### --rc-serve-no-modtime
    
  • fs/rc/rcserver/rcserver.go+50 0 modified
    @@ -5,6 +5,7 @@ import (
     	"context"
     	"encoding/base64"
     	"encoding/json"
    +	"errors"
     	"flag"
     	"fmt"
     	"mime"
    @@ -20,6 +21,7 @@ import (
     	"github.com/rclone/rclone/fs"
     	"github.com/rclone/rclone/fs/cache"
     	"github.com/rclone/rclone/fs/config"
    +	"github.com/rclone/rclone/fs/fspath"
     	"github.com/rclone/rclone/fs/list"
     	"github.com/rclone/rclone/fs/rc"
     	"github.com/rclone/rclone/fs/rc/jobs"
    @@ -340,7 +342,55 @@ func (s *Server) serveRoot(w http.ResponseWriter, r *http.Request) {
     	directory.Serve(w, r)
     }
     
    +// checkServeRemote returns an error if fsName must not be instantiated on the
    +// file-serving (--rc-serve) path given the current authentication state.
    +//
    +// 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.
    +//
    +// 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
    +// to running without authentication.
    +func checkServeRemote(fsName string, authenticated bool) error {
    +	parsed, err := fspath.Parse(fsName)
    +	if err != nil {
    +		return fmt.Errorf("invalid remote %q: %w", fsName, err)
    +	}
    +
    +	// global.* connection string options mutate process-wide rclone config. Never honour these
    +	// from a request-derived remote, even when the request is authenticated
    +	for k := range parsed.Config {
    +		if strings.HasPrefix(k, "global.") {
    +			return fmt.Errorf("setting %q on a served remote is not allowed", k)
    +		}
    +	}
    +
    +	if authenticated {
    +		return nil
    +	}
    +
    +	// On an unauthenticated server only allow access to pre-configured named remotes. Reject
    +	// inline backend definitions, connection string option overrides and bare local paths
    +	switch {
    +	case parsed.Name == "":
    +		return errors.New("serving local paths requires authentication to be set up on the rc server or the --rc-no-auth flag")
    +	case strings.HasPrefix(parsed.Name, ":"):
    +		return errors.New("serving inline remotes requires authentication to be set up on the rc server or the --rc-no-auth flag")
    +	case len(parsed.Config) > 0:
    +		return errors.New("serving remotes with connection string parameters requires authentication to be set up on the rc server or the --rc-no-auth flag")
    +	}
    +	return nil
    +}
    +
     func (s *Server) serveRemote(w http.ResponseWriter, r *http.Request, path string, fsName string) {
    +	// Check we are allowed to instantiate this remote
    +	authenticated := s.noAuth || s.server.UsingAuth()
    +	if err := checkServeRemote(fsName, authenticated); err != nil {
    +		writeError(path, nil, w, err, http.StatusForbidden)
    +		return
    +	}
     	f, err := cache.Get(s.ctx, fsName)
     	if err != nil {
     		writeError(path, nil, w, fmt.Errorf("failed to make Fs: %w", err), http.StatusInternalServerError)
    
  • fs/rc/rcserver/rcserver_test.go+106 0 modified
    @@ -372,6 +372,111 @@ func TestRemoteServing(t *testing.T) {
     	opt := newTestOpt()
     	opt.Serve = true
     	opt.Files = testFs
    +	opt.NoAuth = true
    +	testServer(t, tests, &opt)
    +}
    +
    +// checkServeRemote must reject request-derived backend instantiation on the
    +// unauthenticated file-serving path, and global.* config mutation.
    +func TestCheckServeRemote(t *testing.T) {
    +	for _, test := range []struct {
    +		name          string
    +		fsName        string
    +		authenticated bool
    +		wantErr       string // substring of expected error, "" means no error
    +	}{
    +		// Plain named remotes are always allowed
    +		{name: "named remote unauth", fsName: "remote:path", wantErr: ""},
    +		{name: "named remote auth", fsName: "remote:path", authenticated: true, wantErr: ""},
    +		// Inline backend definitions can run commands (webdav bearer_token_command, sftp ssh)
    +		{name: "inline webdav unauth", fsName: ":webdav,url=http://localhost,bearer_token_command=id:", wantErr: "inline remotes"},
    +		{name: "inline sftp unauth", fsName: ":sftp,ssh=id:", wantErr: "inline remotes"},
    +		{name: "inline local unauth", fsName: ":local:/etc", wantErr: "inline remotes"},
    +		{name: "inline allowed when authed", fsName: ":local:/etc", authenticated: true, wantErr: ""},
    +		// Bare local paths allow arbitrary local file access
    +		{name: "bare path unauth", fsName: "/etc/passwd", wantErr: "local paths"},
    +		{name: "bare path allowed when authed", fsName: "/etc/passwd", authenticated: true, wantErr: ""},
    +		// Connection string overrides can set command-executing options
    +		{name: "connection string unauth", fsName: "remote,vendor=other:path", wantErr: "connection string"},
    +		{name: "connection string allowed when authed", fsName: "remote,vendor=other:path", authenticated: true, wantErr: ""},
    +		// global.* mutates process-wide config and is never allowed
    +		{name: "global config unauth", fsName: "remote,global.http_proxy=x:path", wantErr: "global.http_proxy"},
    +		{name: "global config blocked even when authed", fsName: "remote,global.http_proxy=x:path", authenticated: true, wantErr: "global.http_proxy"},
    +	} {
    +		t.Run(test.name, func(t *testing.T) {
    +			err := checkServeRemote(test.fsName, test.authenticated)
    +			if test.wantErr == "" {
    +				assert.NoError(t, err)
    +			} else {
    +				require.Error(t, err)
    +				assert.Contains(t, err.Error(), test.wantErr)
    +			}
    +		})
    +	}
    +}
    +
    +// On an unauthenticated server the serve path must not instantiate
    +// attacker-supplied inline remotes or local paths, but must still allow
    +// configured named remotes.
    +func TestServeRemoteUnauthenticated(t *testing.T) {
    +	forbidden := regexp.MustCompile(`"status": 403`)
    +	tests := []testRun{{
    +		// An inline remote could run a command during initialisation
    +		Name:     "inline-remote-rejected",
    +		URL:      "[:local:" + testFs + "]/file.txt",
    +		Status:   http.StatusForbidden,
    +		Contains: forbidden,
    +	}, {
    +		Name:     "bare-local-path-rejected",
    +		URL:      remoteURL + "file.txt",
    +		Status:   http.StatusForbidden,
    +		Contains: forbidden,
    +	}, {
    +		// A configured (here non-existent) named remote is allowed through to
    +		// the backend, which then reports it isn't in the config file
    +		Name:     "named-remote-allowed-through",
    +		URL:      "[notfoundremote:]/",
    +		Status:   http.StatusInternalServerError,
    +		Contains: regexp.MustCompile(`didn't find section in config file`),
    +	}}
    +	opt := newTestOpt()
    +	opt.Serve = true
    +	testServer(t, tests, &opt)
    +}
    +
    +// With authentication configured the serve path may instantiate inline remotes
    +// (the request is authenticated) but must still refuse to mutate process-wide
    +// config via global.* options.
    +func TestServeRemoteWithAuth(t *testing.T) {
    +	const user, pass = "user", "pass"
    +	tests := []testRun{{
    +		// Authenticated requests can serve bare local paths again
    +		Name:     "bare-local-path-served-when-authenticated",
    +		URL:      remoteURL + "file.txt",
    +		User:     user,
    +		Pass:     pass,
    +		Status:   http.StatusOK,
    +		Expected: "this is file1.txt\n",
    +	}, {
    +		// global.* is blocked even for an authenticated request
    +		Name:     "global-config-blocked-when-authenticated",
    +		URL:      "[notfoundremote,global.http_proxy=x:]/",
    +		User:     user,
    +		Pass:     pass,
    +		Status:   http.StatusForbidden,
    +		Contains: regexp.MustCompile(`global\.http_proxy`),
    +	}, {
    +		// A request without credentials is rejected before reaching the handler
    +		Name:     "unauthenticated-request-rejected",
    +		URL:      remoteURL + "file.txt",
    +		Status:   http.StatusUnauthorized,
    +		Contains: regexp.MustCompile(``),
    +	}}
    +	opt := newTestOpt()
    +	opt.Serve = true
    +	opt.Files = testFs
    +	opt.Auth.BasicUser = user
    +	opt.Auth.BasicPass = pass
     	testServer(t, tests, &opt)
     }
     
    @@ -884,6 +989,7 @@ func TestServeModTime(t *testing.T) {
     	opt := newTestOpt()
     	opt.Serve = true
     	opt.Template.Path = "testdata/golden/testmodtime.html"
    +	opt.NoAuth = true
     
     	tests := []testRun{{
     		Name:     "modtime",
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

2

News mentions

0

No linked articles in our index yet.