Skip to content

Commit

Permalink
add support for url matches in embedded clients (#189)
Browse files Browse the repository at this point in the history
  • Loading branch information
maxlaverse authored Nov 18, 2024
1 parent aefc9bc commit 51870df
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 3 deletions.
1 change: 0 additions & 1 deletion internal/bitwarden/bwcli/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,4 @@ func TestVaultOfUser(t *testing.T) {
assert.Equal(t, test.expectedResult, match)
})
}

}
96 changes: 94 additions & 2 deletions internal/bitwarden/embedded/password_manager_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"time"
Expand All @@ -16,6 +18,7 @@ import (
"github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/crypto/symmetrickey"
"github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/models"
"github.com/maxlaverse/terraform-provider-bitwarden/internal/bitwarden/webapi"
"golang.org/x/net/publicsuffix"
)

var (
Expand Down Expand Up @@ -87,7 +90,7 @@ func (v *baseVault) ListObjects(ctx context.Context, objType models.ObjectType,
continue
}

if !objMatchFilter(obj, filter) {
if !objMatchFilter(ctx, obj, filter) {
continue
}

Expand Down Expand Up @@ -634,7 +637,7 @@ func objKey(obj models.Object) string {
return fmt.Sprintf("%s___%s", obj.Object, obj.ID)
}

func objMatchFilter(obj models.Object, filters bitwarden.ListObjectsFilterOptions) bool {
func objMatchFilter(ctx context.Context, obj models.Object, filters bitwarden.ListObjectsFilterOptions) bool {
if len(filters.OrganizationFilter) > 0 && obj.OrganizationID != filters.OrganizationFilter {
return false
}
Expand All @@ -655,6 +658,29 @@ func objMatchFilter(obj models.Object, filters bitwarden.ListObjectsFilterOption
}
}

if len(filters.UrlFilter) > 0 {
matchUrl := false
for _, u := range obj.Login.URIs {
if u.Match == nil {
// When selecting 'default' match in the CLI, it results in a
// 'nil' match which we default to 'base_domain' here.
u.Match = models.URIMatchBaseDomain.ToPointer()
}

matched, err := urlsMatch(u, filters.UrlFilter)
if err != nil {
tflog.Trace(ctx, "Error matching URL", map[string]interface{}{"object_id": obj.ID, "url": u.URI, "error": err})
continue
}
if matched {
matchUrl = true
}
}
if !matchUrl {
return false
}
}

if len(filters.SearchFilter) > 0 {
foundSomething := false
if strings.Contains(obj.Name, filters.SearchFilter) {
Expand All @@ -666,13 +692,79 @@ func objMatchFilter(obj models.Object, filters bitwarden.ListObjectsFilterOption
if strings.Contains(obj.Notes, filters.SearchFilter) {
foundSomething = true
}

if !foundSomething {
return false
}
}
return len(filters.SearchFilter) > 0
}

func urlsMatch(u models.LoginURI, searchedUrl string) (bool, error) {
if u.Match == nil {
return false, nil
}

switch *u.Match {
case models.URIMatchBaseDomain:
// TODO: Support equivalent domains
return domainsMatch(u.URI, searchedUrl)
case models.URIMatchHost:
return matchHost(u.URI, searchedUrl)
case models.URIMatchStartWith:
return strings.HasPrefix(searchedUrl, u.URI), nil
case models.URIMatchExact:
return searchedUrl == u.URI, nil
case models.URIMatchRegExp:
matched, err := regexp.MatchString(u.URI, searchedUrl)
if err != nil {
return false, fmt.Errorf("error matching regex: %w", err)
}
return matched, nil
case models.URIMatchNever:
return false, nil
default:
return false, fmt.Errorf("unsupported URIMatch: %d", *u.Match)
}
}

func domainsMatch(url1, url2 string) (bool, error) {
parsedUrl1, err := url.Parse(url1)
if err != nil {
return false, fmt.Errorf("error parsing url1: %w", err)
}

parsedUrl1Domain, err := publicsuffix.EffectiveTLDPlusOne(parsedUrl1.Host)
if err != nil {
return false, fmt.Errorf("error getting url1 TLD+1: %w", err)
}

parsedUrl2, err := url.Parse(url2)
if err != nil {
return false, fmt.Errorf("error parsing url2: %w", err)
}

parsedUrl2Domain, err := publicsuffix.EffectiveTLDPlusOne(parsedUrl2.Host)
if err != nil {
return false, fmt.Errorf("error getting url2 TLD+1: %w", err)
}

return len(parsedUrl1Domain) > 0 && parsedUrl1Domain == parsedUrl2Domain, nil
}

func matchHost(url1, url2 string) (bool, error) {
parsedUrl1, err := url.Parse(url1)
if err != nil {
return false, fmt.Errorf("error parsing url1: %w", err)
}

parsedUrl2, err := url.Parse(url2)
if err != nil {
return false, fmt.Errorf("error parsing url2: %w", err)
}
return len(parsedUrl1.Host) > 0 && parsedUrl1.Host == parsedUrl2.Host, nil
}

func compareObjects[T any](obj1, obj2 T) error {
out1, err := json.Marshal(obj1)
if err != nil {
Expand Down
56 changes: 56 additions & 0 deletions internal/bitwarden/embedded/password_manager_base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -33,6 +34,61 @@ func TestCompareObjects(t *testing.T) {
assert.Error(t, compareObjects(obj1, obj3))
}

func TestMatchUrl(t *testing.T) {
testCases := []struct {
loginUri models.LoginURI
matchingUrls []string
notMatchingUrls []string
}{
{
loginUri: models.LoginURI{URI: "https://sub.mydomain1.com", Match: models.URIMatchBaseDomain.ToPointer()},
matchingUrls: []string{"https://mydomain1.com", "https://else.mydomain1.com"},
notMatchingUrls: []string{"https://mydomain1bis.com"},
},
{
loginUri: models.LoginURI{URI: "https://mydomain2.com", Match: models.URIMatchHost.ToPointer()},
matchingUrls: []string{"https://mydomain2.com"},
notMatchingUrls: []string{"https://mydomain2bis.com", "https://test.mydomain2.com"},
},
{
loginUri: models.LoginURI{URI: "https://mydomain3.com/product", Match: models.URIMatchStartWith.ToPointer()},
matchingUrls: []string{"https://mydomain3.com/product/page"},
notMatchingUrls: []string{"https://mydomain3.com/otherproduct/product"},
},
{
loginUri: models.LoginURI{URI: "https://mydomain4.com/page", Match: models.URIMatchExact.ToPointer()},
matchingUrls: []string{"https://mydomain4.com/page"},
notMatchingUrls: []string{"https://mydomain4.com/page-other"},
},
{
loginUri: models.LoginURI{URI: "https://mydomain5.com/([a-z]+)/test", Match: models.URIMatchRegExp.ToPointer()},
matchingUrls: []string{"https://mydomain5.com/mypage/test"},
notMatchingUrls: []string{"https://mydomain5.com/mypage2/test"},
},
{
loginUri: models.LoginURI{URI: "https://mydomain6.com", Match: models.URIMatchNever.ToPointer()},
notMatchingUrls: []string{"https://mydomain6.com"},
},
}

for _, test := range testCases {
for _, m := range test.matchingUrls {
t.Run(fmt.Sprintf("%s ? %s", test.loginUri.URI, m), func(t *testing.T) {
match, err := urlsMatch(test.loginUri, m)
assert.NoError(t, err)
assert.Equal(t, true, match)
})
}
for _, m := range test.notMatchingUrls {
t.Run(fmt.Sprintf("%s ? %s", test.loginUri.URI, m), func(t *testing.T) {
match, err := urlsMatch(test.loginUri, m)
assert.NoError(t, err)
assert.Equal(t, false, match)
})
}
}
}

func TestDecryptAccountSecretPbkdf2(t *testing.T) {
accountSecrets, err := decryptAccountSecrets(AccountPbkdf2, TestPassword)
if !assert.NoError(t, err) {
Expand Down
16 changes: 16 additions & 0 deletions internal/provider/data_source_item_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ func TestAccDataSourceItemLoginBySearch(t *testing.T) {
Config: tfConfigPasswordManagerProvider() + tfConfigResourceItemLogin("search") + tfConfigDataItemLoginWithSearchAndOrg("missing-item"),
ExpectError: regexp.MustCompile("Error: no object found matching the filter"),
},
// Test: differentiate between items with the same username based on URL
{
Config: tfConfigPasswordManagerProvider() + tfConfigResourceItemLogin("search") + tfConfigResourceItemLoginDuplicate() + tfConfigDataItemLoginWithSearchAndUrl("test-username", "https://host"),
Check: checkItemLogin("data.bitwarden_item_login.foo_data"),
},
// Test: search for a secure note item with a login data source should fail
{
Config: tfConfigPasswordManagerProvider(),
Expand All @@ -100,6 +105,17 @@ data "bitwarden_item_login" "foo_data" {
`, search, testOrganizationID)
}

func tfConfigDataItemLoginWithSearchAndUrl(search, url string) string {
return fmt.Sprintf(`
data "bitwarden_item_login" "foo_data" {
provider = bitwarden
search = "%s"
filter_url = "%s"
}
`, search, url)
}

func tfConfigDataItemLoginWithSearchOnly(search string) string {
return fmt.Sprintf(`
data "bitwarden_item_login" "foo_data" {
Expand Down

0 comments on commit 51870df

Please sign in to comment.