SECVULN-41437: Require sudo for mounts auth tune (#13738) (#14044)

* SECVULN-41437 Require sudo for mounts auth tune

* SECVULN-41437 Handle read case and update description

* SECVULN-41437 Update godoc linter error

* SECVULN-41437 Add changelog entry

* SECVULN-41437 Rename changelog entry

* SECVULN-41437 Fixing tests

* SECVULN-41437 enforce sudo parity for mounts auth tune via root path policy

•  add mounts/auth/* to system PathsSpecial.Root so sys/mounts/auth/<path>/tune is sudo-gated through core policy checks
•  remove explicit handler-level sudo/token capability checks for auth-tune routes (OSS + enterprise) that were causing replication/perf invalid-token failures
•  update TestSystemBackend_mountsAuthTuneRequiresSudo policy expectations for the new enforcement point
•  align replication overload sys-auth-tune subtest expectations with current behavior

* SECVULN-41437 Add static sudo path for API

* SECVULN-41437 Update test based on review

* SECVULN-41437 Handle incorrect paths special config for sudo

* Update changelog/_13738.txt



* VAULT-41437 Update system path description per pr feedback

* SECVULN-41437 Add external auth tune test with NewTestCluster

* SECVULN-41437 Remove un-needed lines in external test

* Apply suggestion from @VioletHynes



---------

Co-authored-by: Jason Pilz <jasonpilz@gmail.com>
Co-authored-by: Violet Hynes <violet.hynes@hashicorp.com>
This commit is contained in:
Vault Automation 2026-04-22 10:43:13 -04:00 committed by GitHub
parent 31fb778a51
commit fee2a76a3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 156 additions and 16 deletions

View File

@ -22,6 +22,7 @@ var sudoPaths = map[string]*regexp.Regexp{
"/sys/audit/{path}": regexp.MustCompile(`^/sys/audit/.+$`),
"/sys/auth/{path}": regexp.MustCompile(`^/sys/auth/.+$`),
"/sys/auth/{path}/tune": regexp.MustCompile(`^/sys/auth/.+/tune$`),
"/sys/mounts/auth/{path}/tune": regexp.MustCompile(`^/sys/mounts/auth/.+/tune$`),
"/sys/config/auditing/request-headers": regexp.MustCompile(`^/sys/config/auditing/request-headers$`),
"/sys/config/auditing/request-headers/{header}": regexp.MustCompile(`^/sys/config/auditing/request-headers/.+$`),
"/sys/config/cors": regexp.MustCompile(`^/sys/config/cors$`),

3
changelog/_13738.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:change
core: Require sudo capability for `sys/mounts/auth/<path>/tune`, matching `sys/auth/<path>/tune`.
```

View File

@ -122,7 +122,10 @@ type Factory func(context.Context, *BackendConfig) (Backend, error)
// Paths is the structure of special paths that is used for SpecialPaths.
type Paths struct {
// Root are the API paths that require a root token to access
// Root are the API paths that require a root token to access.
// These can't be regular expressions; each entry is either an exact match,
// a prefix match (append '*' as a suffix), or a wildcard segment match
// (use '+' in a segment, e.g. 'foo/+/bar').
Root []string
// Unauthenticated are the API paths that can be accessed without any auth.

View File

@ -0,0 +1,105 @@
// Copyright IBM Corp. 2016, 2026
// SPDX-License-Identifier: BUSL-1.1
package mountsauthtune
import (
"fmt"
"testing"
"github.com/hashicorp/vault/api"
credUserpass "github.com/hashicorp/vault/builtin/credential/userpass"
vaulthttp "github.com/hashicorp/vault/http"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/vault"
"github.com/stretchr/testify/require"
)
// TestMountsAuthTuneRequiresSudo ensures sys/mounts/auth/<path>/tune requires
// sudo-equivalent privilege while sys/mounts/auth/<path> remains readable
// without sudo when ACL allows it.
func TestMountsAuthTuneRequiresSudo(t *testing.T) {
t.Parallel()
cluster := vault.NewTestCluster(t, &vault.CoreConfig{
CredentialBackends: map[string]logical.Factory{
"userpass": credUserpass.Factory,
},
}, &vault.TestClusterOptions{
HandlerFunc: vaulthttp.Handler,
NumCores: 1,
})
client := cluster.Cores[0].Client
rootToken := cluster.RootToken
require.NoError(t, client.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{
Type: "userpass",
}))
noSudoPolicy := `
path "sys/mounts/auth/userpass/tune" {
capabilities = ["read", "update"]
}
path "sys/mounts/auth/userpass*" {
capabilities = ["read"]
}
`
require.NoError(t, client.Sys().PutPolicy("mounts-auth-tune-no-sudo", noSudoPolicy))
noSudoTokenResp, err := client.Auth().Token().Create(&api.TokenCreateRequest{
Policies: []string{"mounts-auth-tune-no-sudo"},
})
require.NoError(t, err)
require.NotNil(t, noSudoTokenResp)
require.NotNil(t, noSudoTokenResp.Auth)
require.NotEmpty(t, noSudoTokenResp.Auth.ClientToken)
client.SetToken(noSudoTokenResp.Auth.ClientToken)
// Non-tune path should remain readable without sudo.
nonTuneResp, err := client.Logical().Read("sys/mounts/auth/userpass")
require.NoError(t, err)
require.NotNil(t, nonTuneResp)
// Tune endpoints should fail without sudo.
_, err = client.Logical().Write("sys/mounts/auth/userpass/tune", map[string]interface{}{
"max_lease_ttl": "2h",
})
require.Error(t, err)
require.Contains(t, err.Error(), logical.ErrPermissionDenied.Error())
_, err = client.Logical().Read("sys/mounts/auth/userpass/tune")
require.Error(t, err)
require.Contains(t, err.Error(), logical.ErrPermissionDenied.Error())
client.SetToken(rootToken)
sudoPolicy := `
path "sys/mounts/auth/userpass/tune" {
capabilities = ["sudo", "read", "update"]
}
`
require.NoError(t, client.Sys().PutPolicy("mounts-auth-tune-with-sudo", sudoPolicy))
withSudoTokenResp, err := client.Auth().Token().Create(&api.TokenCreateRequest{
Policies: []string{"mounts-auth-tune-with-sudo"},
})
require.NoError(t, err)
require.NotNil(t, withSudoTokenResp)
require.NotNil(t, withSudoTokenResp.Auth)
require.NotEmpty(t, withSudoTokenResp.Auth.ClientToken)
client.SetToken(withSudoTokenResp.Auth.ClientToken)
_, err = client.Logical().Write("sys/mounts/auth/userpass/tune", map[string]interface{}{
"max_lease_ttl": "3h",
})
require.NoError(t, err)
tuneResp, err := client.Logical().Read("sys/mounts/auth/userpass/tune")
require.NoError(t, err)
require.NotNil(t, tuneResp)
require.Contains(t, tuneResp.Data, "max_lease_ttl")
require.Equal(t, "10800", fmt.Sprint(tuneResp.Data["max_lease_ttl"]))
}

View File

@ -169,6 +169,7 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf
PathsSpecial: &logical.Paths{
Root: []string{
"auth/*",
"mounts/auth/+/tune",
"remount",
"audit",
"audit/*",

View File

@ -4015,7 +4015,7 @@ func (b *SystemBackend) authPaths() []*framework.Path {
OperationSuffix: "tuning-information",
},
Summary: "Reads the given auth path's configuration.",
Description: "This endpoint requires sudo capability on the final path, but the same functionality can be achieved without sudo via `sys/mounts/auth/[auth-path]/tune`.",
Description: "This endpoint requires sudo capability on the final path. The equivalent endpoint at `sys/mounts/auth/[auth-path]/tune` also requires sudo capability.",
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: "OK",
@ -4030,7 +4030,7 @@ func (b *SystemBackend) authPaths() []*framework.Path {
OperationSuffix: "configuration-parameters",
},
Summary: "Tune configuration parameters for a given auth path.",
Description: "This endpoint requires sudo capability on the final path, but the same functionality can be achieved without sudo via `sys/mounts/auth/[auth-path]/tune`.",
Description: "This endpoint requires sudo capabilities on the final path. The equivalent endpoint at `sys/mounts/auth/[auth-path]/tune` also requires sudo capabilities.",
Responses: map[int][]framework.Response{
http.StatusNoContent: {{
Description: "OK",
@ -4671,7 +4671,7 @@ func (b *SystemBackend) mountsPaths() []*framework.Path {
OperationSuffix: "tuning-information",
},
Summary: "Reads the given auth path's configuration.",
Description: "This endpoint does NOT require sudo capability. For the sudo-required alternative, use the endpoint at `sys/auth/[auth-path]/tune`.",
Description: "This endpoint requires sudo capability on the final path. The equivalent endpoint at `sys/auth/[auth-path]/tune` also requires sudo capability.",
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: "OK",
@ -4686,7 +4686,7 @@ func (b *SystemBackend) mountsPaths() []*framework.Path {
OperationSuffix: "configuration-parameters",
},
Summary: "Tune configuration parameters for a given auth path.",
Description: "This endpoint does NOT require sudo capability. The same functionality can be achieved with sudo via the `sys/auth/[auth-path]/tune` endpoint.",
Description: "This endpoint requires sudo capability on the final path. The equivalent endpoint at `sys/auth/[auth-path]/tune` also requires sudo capability.",
Responses: map[int][]framework.Response{
http.StatusNoContent: {{
Description: "OK",

View File

@ -270,7 +270,11 @@ func (c *Core) reloadBackendCommon(ctx context.Context, entry *MountEntry, isAut
// Set paths as well
paths := backend.SpecialPaths()
if paths != nil {
re.rootPaths.Store(pathsToRadix(paths.Root))
rootPathsEntry, err := parseSpecialPaths(paths.Root)
if err != nil {
return err
}
re.rootPaths.Store(rootPathsEntry)
loginPathsEntry, err := parseSpecialPaths(paths.Unauthenticated)
if err != nil {
return err

View File

@ -208,7 +208,11 @@ func (r *Router) Mount(backend logical.Backend, prefix string, mountEntry *Mount
storageView: storageView,
}
re.tainted.Store(mountEntry.Tainted)
re.rootPaths.Store(pathsToRadix(paths.Root))
rootPathsEntry, err := parseSpecialPaths(paths.Root)
if err != nil {
return err
}
re.rootPaths.Store(rootPathsEntry)
loginPathsEntry, err := parseSpecialPaths(paths.Unauthenticated)
if err != nil {
return err
@ -886,20 +890,35 @@ func (r *Router) RootPath(ctx context.Context, path string) bool {
remain := strings.TrimPrefix(adjustedPath, mount)
// Check the rootPaths of this backend
rootPaths := re.rootPaths.Load().(*radix.Tree)
match, raw, ok := rootPaths.LongestPrefix(remain)
if !ok {
rootPaths := re.rootPaths.Load().(*specialPathsEntry)
match, raw, ok := rootPaths.paths.LongestPrefix(remain)
if !ok && len(rootPaths.wildcardPaths) == 0 {
return false
}
prefixMatch := raw.(bool)
// Handle the prefix match case
if prefixMatch {
return strings.HasPrefix(remain, match)
if ok {
prefixMatch := raw.(bool)
// Handle the prefix match case
if prefixMatch {
return strings.HasPrefix(remain, match)
}
// Handle the exact match case
if match == remain {
return true
}
}
// Handle the exact match case
return match == remain
// Check root paths containing wildcards
reqPathParts := strings.Split(remain, "/")
for _, w := range rootPaths.wildcardPaths {
if pathMatchesWildcardPath(reqPathParts, w.segments, w.isPrefix) {
return true
}
}
return false
}
// LoginPath checks if the given path is used for logins

View File

@ -313,6 +313,7 @@ func TestRouter_RootPath(t *testing.T) {
Root: []string{
"root",
"policy/*",
"mounts/auth/+/tune",
},
}
err = r.Mount(n, "prod/aws/", &MountEntry{UUID: meUUID, Accessor: "awsaccessor", NamespaceID: namespace.RootNamespaceID, namespace: namespace.RootNamespace}, view)
@ -332,6 +333,9 @@ func TestRouter_RootPath(t *testing.T) {
{"prod/aws/policy", false},
{"prod/aws/policy/", true},
{"prod/aws/policy/ops", true},
{"prod/aws/mounts/auth/userpass/tune", true},
{"prod/aws/mounts/auth/userpass", false},
{"prod/aws/mounts/auth/userpass/roles", false},
}
for _, tc := range tcases {