Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to parameterize unauthenticated paths #12668

Merged
merged 25 commits into from Oct 13, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
49a6c4b
store unauthenticated path wildcards in map
fairclothjm Sep 21, 2021
f46cacc
working unauthenticated paths with basic unit tests
fairclothjm Sep 28, 2021
c287044
refactor wildcard logic
fairclothjm Sep 28, 2021
e8ef63d
add parseUnauthenticatedPaths unit tests
fairclothjm Sep 28, 2021
a3b77aa
use parseUnauthenticatedPaths when reloading backend
fairclothjm Sep 29, 2021
9eb4fb4
add more wildcard test cases
fairclothjm Sep 29, 2021
cf3a93d
update special paths doc; add changelog
fairclothjm Sep 29, 2021
329f214
remove buggy prefix check; add test cases
fairclothjm Sep 30, 2021
3bbe5b0
prevent false positives for prefix matches
fairclothjm Sep 30, 2021
dbad5eb
refactor switch and add more test cases
fairclothjm Sep 30, 2021
ae8b73a
remove comment leftover from debug session
fairclothjm Sep 30, 2021
4ba8baa
add more wildcard path validation and test cases
fairclothjm Oct 1, 2021
bbb0156
update changelong; feature -> improvement
fairclothjm Oct 1, 2021
ef652a6
simplify wildcard segment matching logic
fairclothjm Oct 5, 2021
1d18b42
refactor wildcard matching into func
fairclothjm Oct 5, 2021
5ff9e51
Merge remote-tracking branch 'origin/main' into unauth-paths-wildcard
fairclothjm Oct 5, 2021
c2352c0
fix glob matching, add more wildcard validation, refactor
fairclothjm Oct 6, 2021
3d7cfd7
refactor common wildcard errors to func
fairclothjm Oct 6, 2021
8ffb279
move doc comment to logical.Paths
fairclothjm Oct 6, 2021
b9c7d33
optimize wildcard paths storage with pre-split slices
fairclothjm Oct 6, 2021
d8070f0
fix comment typo
fairclothjm Oct 6, 2021
b824939
fix test case after changing wildcard paths storage type
fairclothjm Oct 6, 2021
44186ba
move prefix check to parseUnauthenticatedPaths
fairclothjm Oct 6, 2021
42d86dd
tweak regex, remove unneeded array copy, refactor
fairclothjm Oct 7, 2021
e87339c
add test case around wildcard and glob matching
fairclothjm Oct 8, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/12668.txt
@@ -0,0 +1,3 @@
```release-note:improvement
sdk/framework: The '+' wildcard is now supported for parameterizing unauthenticated paths.
```
6 changes: 2 additions & 4 deletions sdk/framework/backend.go
Expand Up @@ -41,10 +41,8 @@ type Backend struct {
// paths, including adding or removing, is not allowed once the
// backend is in use).
//
// PathsSpecial is the list of path patterns that denote the
// paths above that require special privileges. These can't be
// regular expressions, it is either exact match or prefix match.
// For prefix match, append '*' as a suffix.
// PathsSpecial is the list of path patterns that denote the paths above
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
// that require special privileges.
Paths []*Path
PathsSpecial *logical.Paths

Expand Down
4 changes: 4 additions & 0 deletions sdk/logical/logical.go
Expand Up @@ -117,6 +117,10 @@ type Paths struct {
Root []string

// Unauthenticated are the paths that can be accessed without any auth.
// These can't be regular expressions, it is either exact match, a prefix
// match or a wildcard match. For prefix match, append '*' as a suffix.
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
// For a wildcard match, use '+' in the segment to match any identifier
// (e.g. 'foo/+/bar'). Note that '+' can't be adjacent to a non-slash.
Unauthenticated []string

// LocalStorage are paths (prefixes) that are local to this instance; this
Expand Down
2 changes: 2 additions & 0 deletions vault/identity_store.go
Expand Up @@ -89,6 +89,8 @@ func NewIdentityStore(ctx context.Context, core *Core, config *logical.BackendCo
PathsSpecial: &logical.Paths{
Unauthenticated: []string{
"oidc/.well-known/*",
"oidc/provider/+/.well-known/*",
"oidc/provider/+/token",
},
},
PeriodicFunc: func(ctx context.Context, req *logical.Request) error {
Expand Down
6 changes: 5 additions & 1 deletion vault/plugin_reload.go
Expand Up @@ -188,7 +188,11 @@ func (c *Core) reloadBackendCommon(ctx context.Context, entry *MountEntry, isAut
paths := backend.SpecialPaths()
if paths != nil {
re.rootPaths.Store(pathsToRadix(paths.Root))
re.loginPaths.Store(pathsToRadix(paths.Unauthenticated))
loginPathsEntry, err := parseUnauthenticatedPaths(paths.Unauthenticated)
if err != nil {
return err
}
re.loginPaths.Store(loginPathsEntry)
}
}

Expand Down
135 changes: 125 additions & 10 deletions vault/router.go
Expand Up @@ -3,6 +3,7 @@ package vault
import (
"context"
"fmt"
"regexp"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -59,6 +60,14 @@ type routeEntry struct {
l sync.RWMutex
}

// loginPathsEntry is used to hold the routeEntry loginPaths
type loginPathsEntry struct {
paths *radix.Tree
// this sits in the hot path of requests so we are micro-optimizing by
// storing pre-split slices of path segments
wildcardPaths [][]string
}

type ValidateMountResponse struct {
MountType string `json:"mount_type" structs:"mount_type" mapstructure:"mount_type"`
MountAccessor string `json:"mount_accessor" structs:"mount_accessor" mapstructure:"mount_accessor"`
Expand Down Expand Up @@ -137,7 +146,11 @@ func (r *Router) Mount(backend logical.Backend, prefix string, mountEntry *Mount
storageView: storageView,
}
re.rootPaths.Store(pathsToRadix(paths.Root))
re.loginPaths.Store(pathsToRadix(paths.Unauthenticated))
loginPathsEntry, err := parseUnauthenticatedPaths(paths.Unauthenticated)
if err != nil {
return err
}
re.loginPaths.Store(loginPathsEntry)

switch {
case prefix == "":
Expand Down Expand Up @@ -802,20 +815,122 @@ func (r *Router) LoginPath(ctx context.Context, path string) bool {
remain := strings.TrimPrefix(adjustedPath, mount)

// Check the loginPaths of this backend
loginPaths := re.loginPaths.Load().(*radix.Tree)
match, raw, ok := loginPaths.LongestPrefix(remain)
if !ok {
pe := re.loginPaths.Load().(*loginPathsEntry)
match, raw, ok := pe.paths.LongestPrefix(remain)
if !ok && len(pe.wildcardPaths) == 0 {
// no match found
return false
}
prefixMatch := raw.(bool)

// Handle the prefix match case
if prefixMatch {
return strings.HasPrefix(remain, match)
// Matching Priority
// 1. prefix
// 2. exact
// 3. wildcard
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
if ok {
prefixMatch := raw.(bool)
if prefixMatch {
// Handle the prefix match case
return strings.HasPrefix(remain, match)
}
if match == remain {
// Handle the exact match case
return true
}
}

// Handle the exact match case
return match == remain
// check Login Paths containing wildcards
reqPathParts := strings.Split(remain, "/")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any ambiguity about whether remain will or won't have a trailing slash?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it could have one or it could not.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that not a concern for the len comparison below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I don't think so.

I might not be understanding your concern correctly, but I setup a table to show what matches for foo/+ (which will have a length of 2 when split):

path split length matches on foo/+?
foo {"foo"} 1 no
foo/ {"foo", ""} 2 yes
foo/bar {"foo", "bar"} 2 yes
foo/bar/ {"foo", "bar", ""} 3 no

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering if we might see different values for the request path - that map to the same vault path - depending on how the user constructed their request. I'm pretty sure the answer is "no", however, I just haven't read the relevant code recently enough to be certain.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What relevant code should I be taking a look at? Do you mean checking all the Unauthenticated paths defined for each backend?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppose that we have path foo/+/bar in Unauthenticated, and we get requests for LIST foo/a/bar and LIST foo/a/bar/. Should those be treated the same or not? I think buildLogicalRequestNoAuth will ensure, at least for the LIST case, that there is always a trailing slash. But I would prefer that the dev populating Unauthenticated not to have to know to put a trailing slash in their path for it to match.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a valid concern and I am still trying to test it out and think my way through it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ncabatoff Testing Vault 1.8.2 with the sys/health Unauthenticated endpoint the trailing slash does matter:

No trailing /

$ curl -s http://127.0.0.1:8200/v1/sys/health | jq
{
  "initialized": true,
  "sealed": false,
  "standby": false,
  "performance_standby": false,
  "replication_performance_mode": "disabled",
  "replication_dr_mode": "disabled",
  "server_time_utc": 1633701879,
  "version": "1.8.2",
  "cluster_name": "vault-cluster-5f6116e2",
  "cluster_id": "7c4fdeeb-da75-fbea-fce6-bbfac063c2bc"
}

With trailing /

$ curl -s http://127.0.0.1:8200/v1/sys/health/ | jq
{
  "errors": [
    "missing client token"
  ]
}

My PR as it currently is maintains this behavior.

For Create/Update operations we are prohibiting a trailing slash:

(req.Operation == logical.UpdateOperation ||
req.Operation == logical.CreateOperation) {
return logical.ErrorResponse("cannot write to a path ending in '/'"), nil

Please let me know if the above fully addresses your concern.

for _, wcPath := range pe.wildcardPaths {
if pathMatchesWildcardPath(reqPathParts, wcPath) {
return true
}
}
return false
}

// pathMatchesWildcardPath returns true if the path made up of the pathParts
// slice matches the given wildcard path string
func pathMatchesWildcardPath(pathParts, wcPath []string) bool {
if len(wcPath) == 0 {
return false
}
isPrefix := false

currWCPath := make([]string, len(wcPath))
copy(currWCPath, wcPath)
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
if strings.Contains(currWCPath[len(currWCPath)-1], "*") {
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
isPrefix = true
currWCPath[len(currWCPath)-1] = strings.Replace(currWCPath[len(currWCPath)-1], "*", "", -1)
}

if len(pathParts) < len(currWCPath) {
// check if the path coming in is shorter; if so it can't match
return false
}
if !isPrefix && len(currWCPath) != len(pathParts) {
// If it's not a prefix we expect the same number of segments
return false
}

for i, wcPathPart := range currWCPath {
switch {
case wcPathPart == "+":
case wcPathPart == pathParts[i]:
case isPrefix && i == len(currWCPath)-1 && strings.HasPrefix(pathParts[i], wcPathPart):
default:
// we encounted segments that did not match
return false
}
}
return true
}

func wildcardError(path, msg string) error {
return fmt.Errorf("path %q: invalid use of wildcards %s", path, msg)
}

func isValidWildcardPath(path string) (bool, error) {
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
re := regexp.MustCompile(`\++\w|\w\++`)
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved

switch {
case strings.Count(path, "*") > 1:
return false, wildcardError(path, "(multiple '*' is forbidden)")
case strings.Contains(path, "+*"):
return false, wildcardError(path, "('+*' is forbidden)")
case strings.Contains(path, "*") && path[len(path)-1] != '*':
return false, wildcardError(path, "('*' is only allowed at the end of a path)")
case re.MatchString(path):
return false, wildcardError(path, "('+' is not allowed next to a non-slash)")
}
return true, nil
}

// parseUnauthenticatedPaths converts a list of special paths to a
// loginPathsEntry
func parseUnauthenticatedPaths(paths []string) (*loginPathsEntry, error) {
var tempPaths []string
tempWildcardPaths := make([][]string, 0)
for _, path := range paths {
if ok, err := isValidWildcardPath(path); !ok {
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
return nil, err
}

if strings.Contains(path, "+") {
// Paths with wildcards are not stored in the radix tree because
// the radix tree does not handle wildcards in the middle of strings.
// We are micro-optimizing by storing pre-split slices of path segments
tempWildcardPaths = append(tempWildcardPaths, strings.Split(path, "/"))
} else {
// accumulate paths that do not contain wildcards
// to be stored in the radix tree
tempPaths = append(tempPaths, path)
}
}

return &loginPathsEntry{
paths: pathsToRadix(tempPaths),
wildcardPaths: tempWildcardPaths,
}, nil
}

// pathsToRadix converts a list of special paths to a radix tree.
Expand Down
145 changes: 145 additions & 0 deletions vault/router_test.go
Expand Up @@ -348,6 +348,15 @@ func TestRouter_LoginPath(t *testing.T) {
Login: []string{
"login",
"oauth/*",
"glob1*",
"+/wildcard/glob2*",
"end1/+",
"end2/+/",
"end3/+/*",
"middle1/+/bar",
"middle2/+/+/bar",
"+/begin",
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved
"+/around/+/",
},
}
err = r.Mount(n, "auth/foo/", &MountEntry{UUID: meUUID, Accessor: "authfooaccessor", NamespaceID: namespace.RootNamespaceID, namespace: namespace.RootNamespace}, view)
Expand All @@ -363,8 +372,69 @@ func TestRouter_LoginPath(t *testing.T) {
{"random", false},
{"auth/foo/bar", false},
{"auth/foo/login", true},
{"auth/foo/login/", false},
{"auth/foo/oauth", false},
{"auth/foo/oauth/", true},
{"auth/foo/oauth/redirect", true},
{"auth/foo/oauth/redirect/", true},
{"auth/foo/oauth/redirect/bar", true},
{"auth/foo/glob1", true},
{"auth/foo/glob1/", true},
{"auth/foo/glob1/redirect", true},

// Wildcard cases
fairclothjm marked this conversation as resolved.
Show resolved Hide resolved

// "+/wildcard/glob2*"
{"auth/foo/bar/wildcard/glo", false},
{"auth/foo/bar/wildcard/glob2", true},
{"auth/foo/bar/wildcard/glob2/", true},
{"auth/foo/bar/wildcard/glob2/baz", true},

// "end1/+"
{"auth/foo/end1", false},
{"auth/foo/end1/", true},
{"auth/foo/end1/bar", true},
{"auth/foo/end1/bar/", false},
{"auth/foo/end1/bar/baz", false},
// "end2/+/"
{"auth/foo/end2", false},
{"auth/foo/end2/", false},
{"auth/foo/end2/bar", false},
{"auth/foo/end2/bar/", true},
{"auth/foo/end2/bar/baz", false},
// "end3/+/*"
{"auth/foo/end3", false},
{"auth/foo/end3/", false},
{"auth/foo/end3/bar", false},
{"auth/foo/end3/bar/", true},
{"auth/foo/end3/bar/baz", true},
{"auth/foo/end3/bar/baz/", true},
{"auth/foo/end3/bar/baz/qux", true},
{"auth/foo/end3/bar/baz/qux/qoo", true},
{"auth/foo/end3/bar/baz/qux/qoo/qaa", true},
// "middle1/+/bar",
{"auth/foo/middle1/bar", false},
{"auth/foo/middle1/bar/", false},
{"auth/foo/middle1/bar/qux", false},
{"auth/foo/middle1/bar/bar", true},
{"auth/foo/middle1/bar/bar/", false},
// "middle2/+/+/bar",
{"auth/foo/middle2/bar", false},
{"auth/foo/middle2/bar/", false},
{"auth/foo/middle2/bar/baz", false},
{"auth/foo/middle2/bar/baz/", false},
{"auth/foo/middle2/bar/baz/bar", true},
{"auth/foo/middle2/bar/baz/bar/", false},
// "+/begin"
{"auth/foo/bar/begin", true},
{"auth/foo/bar/begin/", false},
{"auth/foo/begin", false},
// "+/around/+/"
{"auth/foo/bar/around", false},
{"auth/foo/bar/around/", false},
{"auth/foo/bar/around/baz", false},
{"auth/foo/bar/around/baz/", true},
{"auth/foo/bar/around/baz/qux", false},
}

for _, tc := range tcases {
Expand Down Expand Up @@ -477,3 +547,78 @@ func TestPathsToRadix(t *testing.T) {
t.Fatalf("bad: %v (sub/bar)", raw)
}
}

func TestParseUnauthenticatedPaths(t *testing.T) {
// inputs
paths := []string{
"foo",
"foo/*",
"sub/bar*",
}
wildcardPaths := []string{
"end/+",
"+/begin/*",
"middle/+/bar*",
}
allPaths := append(paths, wildcardPaths...)

p, err := parseUnauthenticatedPaths(allPaths)
if err != nil {
t.Fatal(err)
}

// outputs
wildcardPathsEntry := [][]string{
{"end/+"},
{"+/begin/*"},
{"middle/+/bar*"},
}
expected := &loginPathsEntry{
paths: pathsToRadix(paths),
wildcardPaths: wildcardPathsEntry,
}

if !reflect.DeepEqual(expected, p) {
t.Fatalf("expected: %#v\n actual: %#v\n", expected, p)
}
}

func TestParseUnauthenticatedPaths_Error(t *testing.T) {
type tcase struct {
paths []string
err string
}
tcases := []tcase{
{
[]string{"/foo/+*"},
"path \"/foo/+*\": invalid use of wildcards ('+*' is forbidden)",
},
{
[]string{"/foo/*/*"},
"path \"/foo/*/*\": invalid use of wildcards (multiple '*' is forbidden)",
},
{
[]string{"*/foo/*"},
"path \"*/foo/*\": invalid use of wildcards (multiple '*' is forbidden)",
},
{
[]string{"*/foo/"},
"path \"*/foo/\": invalid use of wildcards ('*' is only allowed at the end of a path)",
},
{
[]string{"/foo+"},
"path \"/foo+\": invalid use of wildcards ('+' is not allowed next to a non-slash)",
},
{
[]string{"/+foo"},
"path \"/+foo\": invalid use of wildcards ('+' is not allowed next to a non-slash)",
},
}

for _, tc := range tcases {
_, err := parseUnauthenticatedPaths(tc.paths)
if err == nil || err != nil && !strings.Contains(err.Error(), tc.err) {
t.Fatalf("bad: path: %s expect: %v got %v", tc.paths, tc.err, err)
}
}
}