Add browser search and richer URL matching

This commit is contained in:
Joe Julian
2026-04-23 20:36:17 -07:00
parent c7d35927f3
commit 4afbc3c933
12 changed files with 418 additions and 25 deletions
+108 -9
View File
@@ -275,7 +275,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
var matches []rankedBrowserMatch
for _, entry := range displayModel.Entries {
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
quality, score := classifyBrowserEntry(pageHost, entry)
if score == 0 {
continue
}
@@ -390,7 +390,7 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
if _, score := classifyBrowserEntry(pageHost, entry); score == 0 {
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
}
}
@@ -446,19 +446,22 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
}
displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
model = displayModel
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
results := model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
entries, err = s.authorizedSearchEntries(ctx, model, token, internalPath, results)
if err != nil {
return nil, err
}
} else {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
entries = model.EntriesInPath(internalPath)
}
@@ -472,6 +475,49 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
return resp, nil
}
func (s *Server) authorizedSearchEntries(ctx context.Context, model vault.Model, token apitokens.Token, path []string, results []vault.SearchResult) ([]vault.Entry, error) {
entries := make([]vault.Entry, 0, len(results))
var promptResource *apitokens.Resource
for _, result := range results {
entry := result.Entry
if !hasPathPrefix(path, entry.Path) {
continue
}
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
switch evaluateAuthorization(model, token, apitokens.OperationListEntries, resource) {
case apitokens.DecisionAllow:
entries = append(entries, entry)
case apitokens.DecisionPrompt:
if promptResource == nil {
candidate := resource
promptResource = &candidate
}
}
}
if len(entries) != 0 || promptResource == nil {
return entries, nil
}
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, *promptResource); err != nil {
return nil, err
}
return authorizedSearchEntriesWithinPath(path, promptResource.Path, results), nil
}
func authorizedSearchEntriesWithinPath(requestPath, approvedPath []string, results []vault.SearchResult) []vault.Entry {
entries := make([]vault.Entry, 0, len(results))
for _, result := range results {
entry := result.Entry
if !hasPathPrefix(requestPath, entry.Path) {
continue
}
if !hasPathPrefix(approvedPath, entry.Path) {
continue
}
entries = append(entries, entry)
}
return entries
}
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
model, locked := s.snapshotModel()
if locked {
@@ -1063,6 +1109,52 @@ func normalizedBrowserEntryHost(raw string) string {
return ""
}
func browserURLFieldKey(key string) bool {
if len(key) <= len("URL") || !strings.EqualFold(key[:len("URL")], "URL") {
return false
}
for _, r := range key[len("URL"):] {
if r < '0' || r > '9' {
return false
}
}
return true
}
func browserEntryURLs(entry vault.Entry) []string {
urls := make([]string, 0, 1+len(entry.Fields))
if raw := strings.TrimSpace(entry.URL); raw != "" {
urls = append(urls, raw)
}
if len(entry.Fields) == 0 {
return urls
}
keys := slices.Collect(maps.Keys(entry.Fields))
slices.Sort(keys)
for _, key := range keys {
if !browserURLFieldKey(key) {
continue
}
if raw := strings.TrimSpace(entry.Fields[key]); raw != "" {
urls = append(urls, raw)
}
}
return urls
}
func classifyBrowserEntry(pageHost string, entry vault.Entry) (string, int) {
bestQuality := ""
bestScore := 0
for _, rawURL := range browserEntryURLs(entry) {
quality, score := classifyBrowserEntryMatch(pageHost, rawURL)
if score > bestScore {
bestQuality = quality
bestScore = score
}
}
return bestQuality, bestScore
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
entryHost := normalizedBrowserEntryHost(rawEntryURL)
if entryHost == "" {
@@ -1078,6 +1170,13 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
}
}
func hasPathPrefix(prefix, path []string) bool {
if len(prefix) > len(path) {
return false
}
return slices.Equal(prefix, path[:len(prefix)])
}
func visibleModel(model vault.Model) vault.Model {
out := model
out.Entries = nil
+94
View File
@@ -294,6 +294,55 @@ func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
}
}
func TestVaultServiceFindsBrowserLoginsForCustomURLFields(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "night-fox-gitlab",
Title: "Night Fox GitLab",
Username: "nightfox",
Password: "vault-code",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"URL1": "gitlab.com",
},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "night-fox-gitlab" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want night-fox-gitlab", resp.Matches[0].Id)
}
credential, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "night-fox-gitlab",
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("GetBrowserCredential() error = %v", err)
}
if credential.GetId() != "night-fox-gitlab" {
t.Fatalf("GetBrowserCredential().Id = %q, want night-fox-gitlab", credential.GetId())
}
}
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
t.Parallel()
@@ -1203,6 +1252,51 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceSearchesEntriesWithinAuthorizedScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "turk-codex",
Title: "Turk Codex GitLab",
Username: "basher",
Password: "chip-stack",
URL: "https://gitlab.com",
Path: []string{"keepass", "Joe", "codex"},
},
{
ID: "rusty-internet",
Title: "Rusty Internet GitLab",
Username: "rusty",
Password: "bellagio-stack",
URL: "https://gitlab.com",
Path: []string{"keepass", "Joe", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
),
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Query: "GitLab",
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if got := resp.Entries[0].Id; got != "turk-codex" {
t.Fatalf("ListEntries().Entries[0].Id = %q, want turk-codex", got)
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
t.Parallel()