mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-30 02:02:43 +00:00
Implement RFC 5785 (.well-known) Redirects (#23973)
* Re-implementation of API redirects with more deterministic matching * add missing file * Handle query params properly * licensing * Add single src deregister * Implement specifically RFC 5785 (.well-known) redirects. Also implement a unit test for HA setups, making sure the standby node redirects to the active (as usual), and that then the active redirects the .well-known request to a backend, and that that is subsequently satisfied. * Remove test code * Rename well known redirect logic * comments/cleanup * PR feedback * Remove wip typo * Update http/handler.go Co-authored-by: Steven Clark <steven.clark@hashicorp.com> * Fix registrations with trailing slashes --------- Co-authored-by: Steven Clark <steven.clark@hashicorp.com>
This commit is contained in:
@@ -413,7 +413,36 @@ func wrapGenericHandler(core *vault.Core, h http.Handler, props *vault.HandlerPr
|
||||
r = newR
|
||||
|
||||
case strings.HasPrefix(r.URL.Path, "/ui"), r.URL.Path == "/robots.txt", r.URL.Path == "/":
|
||||
default:
|
||||
// RFC 5785
|
||||
case strings.HasPrefix(r.URL.Path, "/.well-known/"):
|
||||
standby, err := core.Standby()
|
||||
if err != nil {
|
||||
core.Logger().Warn("error resolving standby status handling .well-known path", "error", err)
|
||||
} else if standby {
|
||||
respondStandby(core, w, r.URL)
|
||||
cancelFunc()
|
||||
return
|
||||
} else {
|
||||
redir, err := core.GetWellKnownRedirect(r.Context(), r.URL.Path)
|
||||
if err != nil {
|
||||
core.Logger().Warn("error resolving potential API redirect", "error", err)
|
||||
} else {
|
||||
if redir != "" {
|
||||
dest := url.URL{
|
||||
Path: redir,
|
||||
RawQuery: r.URL.RawQuery,
|
||||
}
|
||||
w.Header().Set("Location", dest.String())
|
||||
if r.Method == http.MethodGet || r.Proto == "HTTP/1.0" {
|
||||
w.WriteHeader(http.StatusFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
}
|
||||
cancelFunc()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
respondError(nw, http.StatusNotFound, nil)
|
||||
cancelFunc()
|
||||
return
|
||||
|
||||
@@ -111,6 +111,12 @@ type ExtendedSystemView interface {
|
||||
// APILockShouldBlockRequest returns whether a namespace for the requested
|
||||
// mount is locked and should be blocked
|
||||
APILockShouldBlockRequest() (bool, error)
|
||||
|
||||
// Register a redirect from .well-known/src to dest, where dest is a subpath of the mount. An error
|
||||
// is returned if that source path is already taken
|
||||
RequestWellKnownRedirect(ctx context.Context, src, dest string) error
|
||||
// Deregister a specific redirect. Returns true if that redirect source was found
|
||||
DeregisterWellKnownRedirect(ctx context.Context, src string) bool
|
||||
}
|
||||
|
||||
type PasswordGenerator func() (password string, err error)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
paths "path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -132,6 +133,8 @@ const (
|
||||
"disable Vault from using it. To disable Vault from using it,\n" +
|
||||
"set the `disable_mlock` configuration option in your configuration\n" +
|
||||
"file."
|
||||
|
||||
WellKnownPrefix = "/.well-known/"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -692,6 +695,7 @@ type Core struct {
|
||||
// If any role based quota (LCQ or RLQ) is enabled, don't track lease counts by role
|
||||
impreciseLeaseRoleTracking bool
|
||||
|
||||
WellKnownRedirects *wellKnownRedirectRegistry // RFC 5785
|
||||
// Config value for "detect_deadlocks".
|
||||
detectDeadlocks []string
|
||||
}
|
||||
@@ -1039,6 +1043,7 @@ func CreateCore(conf *CoreConfig) (*Core, error) {
|
||||
rollbackMountPathMetrics: conf.MetricSink.TelemetryConsts.RollbackMetricsIncludeMountPoint,
|
||||
numRollbackWorkers: conf.NumRollbackWorkers,
|
||||
impreciseLeaseRoleTracking: conf.ImpreciseLeaseRoleTracking,
|
||||
WellKnownRedirects: NewWellKnownRedirects(),
|
||||
detectDeadlocks: detectDeadlocks,
|
||||
}
|
||||
|
||||
@@ -4226,6 +4231,22 @@ func (c *Core) Events() *eventbus.EventBus {
|
||||
return c.events
|
||||
}
|
||||
|
||||
func (c *Core) GetWellKnownRedirect(ctx context.Context, path string) (string, error) {
|
||||
if c.WellKnownRedirects == nil {
|
||||
return "", nil
|
||||
}
|
||||
path = strings.TrimPrefix(path, WellKnownPrefix)
|
||||
redir, remaining := c.WellKnownRedirects.Find(path)
|
||||
if redir != nil {
|
||||
dest, err := redir.Destination(remaining)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Join("/v1", dest), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *Core) DetectStateLockDeadlocks() bool {
|
||||
if _, ok := c.stateLock.(*locking.DeadlockRWMutex); ok {
|
||||
return true
|
||||
|
||||
@@ -39,6 +39,8 @@ type extendedSystemView interface {
|
||||
SudoPrivilege(context.Context, string, string) bool
|
||||
}
|
||||
|
||||
var _ logical.ExtendedSystemView = (*extendedSystemViewImpl)(nil)
|
||||
|
||||
type extendedSystemViewImpl struct {
|
||||
dynamicSystemView
|
||||
}
|
||||
@@ -150,6 +152,14 @@ func (e extendedSystemViewImpl) APILockShouldBlockRequest() (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e extendedSystemViewImpl) RequestWellKnownRedirect(ctx context.Context, src, dest string) error {
|
||||
return e.core.WellKnownRedirects.TryRegister(ctx, e.core, e.mountEntry.UUID, src, dest)
|
||||
}
|
||||
|
||||
func (e extendedSystemViewImpl) DeregisterWellKnownRedirect(ctx context.Context, src string) bool {
|
||||
return e.core.WellKnownRedirects.DeregisterSource(e.mountEntry.UUID, src)
|
||||
}
|
||||
|
||||
func (d dynamicSystemView) DefaultLeaseTTL() time.Duration {
|
||||
def, _ := d.fetchTTLs()
|
||||
return def
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/vault/helper/testhelpers"
|
||||
vaulthttp "github.com/hashicorp/vault/http"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/hashicorp/vault/helper/testhelpers/minimal"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
@@ -83,3 +88,59 @@ func TestRouter_UnmountRollbackIsntFatal(t *testing.T) {
|
||||
cluster.EnsureCoresSealed(t)
|
||||
cluster.UnsealCores(t)
|
||||
}
|
||||
|
||||
func TestWellKnownRedirect_HA(t *testing.T) {
|
||||
cluster := vault.NewTestCluster(t, &vault.CoreConfig{
|
||||
DisablePerformanceStandby: true,
|
||||
LogicalBackends: map[string]logical.Factory{
|
||||
"noop": func(_ context.Context, _ *logical.BackendConfig) (logical.Backend, error) {
|
||||
return &vault.NoopBackend{
|
||||
RequestHandler: func(context.Context, *logical.Request) (*logical.Response, error) {
|
||||
// Return something for any request
|
||||
return &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"good": "very",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}, &vault.TestClusterOptions{
|
||||
HandlerFunc: vaulthttp.Handler,
|
||||
})
|
||||
testhelpers.WaitForActiveNodeAndStandbys(t, cluster)
|
||||
active := testhelpers.DeriveActiveCore(t, cluster)
|
||||
standbys := testhelpers.DeriveStandbyCores(t, cluster)
|
||||
standby := standbys[0].Client
|
||||
|
||||
if err := active.Client.Sys().Mount("noop", &api.MountInput{
|
||||
Type: "noop",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to mount PKI: %v", err)
|
||||
}
|
||||
|
||||
resp, err := active.Client.Logical().Read("sys/mounts")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch new mount: %v", err)
|
||||
}
|
||||
var mountUUID string
|
||||
for k, m := range resp.Data {
|
||||
if k == "noop/" {
|
||||
mountUUID = m.(map[string]interface{})["uuid"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := active.Core.WellKnownRedirects.TryRegister(context.Background(), active.Core, mountUUID, "foo", "bar"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
standby.SetCheckRedirect(nil)
|
||||
resp2, err := standby.RawRequest(standby.NewRequest(http.MethodGet, "/.well-known/foo/baz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatal("did not get expected response from noop backend after redirect")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,6 +950,8 @@ func (c *Core) unmountInternal(ctx context.Context, path string, updateStorage b
|
||||
}
|
||||
}
|
||||
|
||||
c.WellKnownRedirects.DeregisterMount(entry.UUID)
|
||||
|
||||
if c.logger.IsInfo() {
|
||||
c.logger.Info("successfully unmounted", "path", path, "namespace", ns.Path)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/hashicorp/go-uuid"
|
||||
"github.com/hashicorp/vault/helper/namespace"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
@@ -631,3 +634,53 @@ func TestParseUnauthenticatedPaths_Error(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellKnownRedirectMatching(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
// inputs
|
||||
redirs := map[string]string{
|
||||
"foo": "v1/one-path",
|
||||
"bar/baz": "v1/two-paths",
|
||||
"baz/": "v1/trailing-slash",
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
expected string
|
||||
mismatch bool
|
||||
}{
|
||||
"foo": {"/v1/one-path", false},
|
||||
"foof": {"", true},
|
||||
"foo/extra": {"/v1/one-path/extra", false},
|
||||
"bar/baz": {"/v1/two-paths", false},
|
||||
"bar/baz/extra": {"/v1/two-paths/extra", false},
|
||||
"baz": {"/v1/trailing-slash", false},
|
||||
"baz/extra": {"/v1/trailing-slash/extra", false},
|
||||
}
|
||||
apiRedir := NewWellKnownRedirects()
|
||||
for s, d := range redirs {
|
||||
if err := apiRedir.TryRegister(context.Background(), nil, "my-mount", s, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for k, x := range tests {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
v, s := apiRedir.Find(k)
|
||||
if x.mismatch && v != nil {
|
||||
t.Fail()
|
||||
} else if !x.mismatch && v == nil {
|
||||
t.Fail()
|
||||
} else if !x.mismatch {
|
||||
d, err := v.Destination(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Equal(x.expected, d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if found := apiRedir.DeregisterSource("my-mount", "bar/baz"); !found {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
139
vault/well_known_redirect.go
Normal file
139
vault/well_known_redirect.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/armon/go-radix"
|
||||
)
|
||||
|
||||
type wellKnownRedirect struct {
|
||||
c *Core
|
||||
mountUUID string
|
||||
prefix string
|
||||
isPrefixMatch bool
|
||||
}
|
||||
|
||||
type wellKnownRedirectRegistry struct {
|
||||
lock sync.Mutex
|
||||
paths *radix.Tree
|
||||
}
|
||||
|
||||
func NewWellKnownRedirects() *wellKnownRedirectRegistry {
|
||||
return &wellKnownRedirectRegistry{
|
||||
paths: radix.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to register a mapping from /.well-known/_src_ to /v1/_mount-path_/_dest_
|
||||
func (reg *wellKnownRedirectRegistry) TryRegister(ctx context.Context, core *Core, mountUUID, src, dest string) error {
|
||||
if strings.HasPrefix(dest, "/") {
|
||||
return errors.New("redirect targets must be relative")
|
||||
}
|
||||
src = strings.TrimSuffix(src, "/")
|
||||
reg.lock.Lock()
|
||||
defer reg.lock.Unlock()
|
||||
_, _, found := reg.paths.LongestPrefix(src)
|
||||
if found {
|
||||
return fmt.Errorf("api redirect conflict for %s", src)
|
||||
}
|
||||
reg.paths.Insert(src, &wellKnownRedirect{
|
||||
c: core,
|
||||
mountUUID: mountUUID,
|
||||
prefix: dest,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find any relevant redirects for a given source path
|
||||
func (reg *wellKnownRedirectRegistry) Find(path string) (*wellKnownRedirect, string) {
|
||||
s, a, found := reg.paths.LongestPrefix(path)
|
||||
if found {
|
||||
remaining := strings.TrimPrefix(path, s)
|
||||
if len(remaining) > 0 {
|
||||
switch remaining[0] {
|
||||
case '/':
|
||||
remaining = remaining[1:]
|
||||
case '?':
|
||||
default:
|
||||
// This isn't an exact path match
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
return a.(*wellKnownRedirect), remaining
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Remove all redirects for a given mount
|
||||
func (reg *wellKnownRedirectRegistry) DeregisterMount(mountUuid string) {
|
||||
reg.lock.Lock()
|
||||
defer reg.lock.Unlock()
|
||||
|
||||
var toDelete []string
|
||||
reg.paths.Walk(func(k string, v interface{}) bool {
|
||||
r := v.(*wellKnownRedirect)
|
||||
if r.mountUUID == mountUuid {
|
||||
toDelete = append(toDelete, k)
|
||||
}
|
||||
return false
|
||||
})
|
||||
for _, d := range toDelete {
|
||||
reg.paths.Delete(d)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove a specific redirect for a mount
|
||||
func (reg *wellKnownRedirectRegistry) DeregisterSource(mountUuid, src string) bool {
|
||||
reg.lock.Lock()
|
||||
defer reg.lock.Unlock()
|
||||
var found bool
|
||||
reg.paths.Walk(func(k string, v interface{}) bool {
|
||||
r := v.(*wellKnownRedirect)
|
||||
if r.mountUUID == mountUuid && k == src {
|
||||
found = true
|
||||
reg.paths.Delete(k)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// Construct the full destination of the redirect, including any remaining path past the src
|
||||
func (a *wellKnownRedirect) Destination(remaining string) (string, error) {
|
||||
var destPath string
|
||||
if a.c == nil {
|
||||
// Just for testing
|
||||
destPath = a.prefix
|
||||
} else {
|
||||
m := a.c.router.MatchingMountByUUID(a.mountUUID)
|
||||
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("cannot find backend with uuid: %s", a.mountUUID)
|
||||
}
|
||||
var err error
|
||||
destPath, err = url.JoinPath(m.Namespace().Path, m.Path, a.prefix)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
u := url.URL{
|
||||
Path: destPath + "/",
|
||||
}
|
||||
r, err := url.Parse(remaining)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := u.ResolveReference(r)
|
||||
dest.Path = strings.TrimSuffix(dest.Path, "/")
|
||||
return dest.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user