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

Remove an incomplete workaround for database connection_url formatting #22224

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/database/mysql/connection_producer.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (c *mySQLConnectionProducer) Init(ctx context.Context, conf map[string]inte
password := c.Password

// QueryHelper doesn't do any SQL escaping, but if it starts to do so
// then maybe we won't be able to use it to do URL substitution any more.
// then maybe we won't be able to use it to do URL substitution anymore.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Grammar fix. Also, note that this is a forked copy of the sdk's SQLConnectionProducer that did not contain the workaround.

c.ConnectionURL = dbutil.QueryHelper(c.ConnectionURL, map[string]string{
"username": url.PathEscape(c.Username),
"password": password,
Expand Down
21 changes: 5 additions & 16 deletions sdk/database/helper/connutil/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,30 +59,19 @@ func (c *SQLConnectionProducer) Init(ctx context.Context, conf map[string]interf
return nil, fmt.Errorf("connection_url cannot be empty")
}

// Do not allow the username or password template pattern to be used as
// part of the user-supplied username or password
if strings.Contains(c.Username, "{{username}}") ||
strings.Contains(c.Username, "{{password}}") ||
strings.Contains(c.Password, "{{username}}") ||
strings.Contains(c.Password, "{{password}}") {

return nil, fmt.Errorf("username and/or password cannot contain the template variables")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the workaround being removed.


// Don't escape special characters for MySQL password
// Also don't escape special characters for the username and password if
// the disable_escaping parameter is set to true
// Don't escape special characters for the username and password if the
// disable_escaping parameter is set to true. A user does this if the
// connection_url is actually not a URL, but database-specific other
// format, typically space or semicolon separated key=value pairs.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Add clarity to comment on why disable_escaping exists.

username := c.Username
password := c.Password
if !c.DisableEscaping {
username = url.PathEscape(c.Username)
}
if (c.Type != "mysql") && !c.DisableEscaping {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This mysql special case can be deleted - the mysql plugin is no longer using this code at all. As can be seen in the first file touched in this PR, it now has its own forked copy.

password = url.PathEscape(c.Password)
}

// QueryHelper doesn't do any SQL escaping, but if it starts to do so
// then maybe we won't be able to use it to do URL substitution any more.
// then maybe we won't be able to use it to do URL substitution anymore.
c.ConnectionURL = dbutil.QueryHelper(c.ConnectionURL, map[string]string{
"username": username,
"password": password,
Expand Down
47 changes: 4 additions & 43 deletions sdk/database/helper/connutil/sql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"net/url"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestSQLPasswordChars(t *testing.T) {
Expand All @@ -22,6 +20,9 @@ func TestSQLPasswordChars(t *testing.T) {
{"postgres", "pass/word"},
{"postgres", "p@ssword"},
{"postgres", "pass\"word\""},
// Much to my surprise, CREATE USER "{{password}}" PASSWORD 'foo' worked.
{"{{password}}", "foo"},
{"user", "{{username}}"},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Re-add test cases removed in #13414.

}
for _, tc := range testCases {
t.Logf("username %q password %q", tc.Username, tc.Password)
Expand Down Expand Up @@ -73,7 +74,7 @@ func TestSQLDisableEscaping(t *testing.T) {
{"ms'sq;l", "pass'wor;d", false},
}
for _, tc := range testCases {
t.Logf("username %q password %q disable_escaling %t", tc.Username, tc.Password, tc.DisableEscaping)
t.Logf("username %q password %q disable_escaping %t", tc.Username, tc.Password, tc.DisableEscaping)

sql := &SQLConnectionProducer{}
ctx := context.Background()
Expand All @@ -99,43 +100,3 @@ func TestSQLDisableEscaping(t *testing.T) {
}
}
}

func TestSQLDisallowTemplates(t *testing.T) {
testCases := []struct {
Username string
Password string
}{
{"{{username}}", "pass"},
{"{{password}}", "pass"},
{"user", "{{username}}"},
{"user", "{{password}}"},
{"{{username}}", "{{password}}"},
{"abc{username}xyz", "123{password}789"},
{"abc{{username}}xyz", "123{{password}}789"},
{"abc{{{username}}}xyz", "123{{{password}}}789"},
}
for _, disableEscaping := range []bool{true, false} {
for _, tc := range testCases {
t.Logf("username %q password %q disable_escaping %t", tc.Username, tc.Password, disableEscaping)

sql := &SQLConnectionProducer{}
ctx := context.Background()
conf := map[string]interface{}{
"connection_url": "server=localhost;port=1433;user id={{username}};password={{password}};database=mydb;",
"username": tc.Username,
"password": tc.Password,
"disable_escaping": disableEscaping,
}
_, err := sql.Init(ctx, conf, false)
if disableEscaping {
if err != nil {
if !assert.EqualError(t, err, "username and/or password cannot contain the template variables") {
t.Errorf("Init error on %q %q: %+v", tc.Username, tc.Password, err)
}
} else {
assert.Equal(t, sql.ConnectionURL, "server=localhost;port=1433;user id=abc{username}xyz;password=123{password}789;database=mydb;")
}
}
}
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Remove test that tests the workaround being removed.

25 changes: 18 additions & 7 deletions sdk/database/helper/dbutil/dbutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package dbutil

import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/hashicorp/vault/sdk/database/dbplugin"
Expand All @@ -18,13 +18,24 @@ var (
ErrEmptyRotationStatement = errors.New("empty rotation statements")
)

// Query templates a query for us.
func QueryHelper(tpl string, data map[string]string) string {
for k, v := range data {
tpl = strings.ReplaceAll(tpl, fmt.Sprintf("{{%s}}", k), v)
}
var queryHelperRegex = regexp.MustCompile(`{{[^{}]+}}`)
Copy link
Contributor

@tomhjp tomhjp Aug 8, 2023

Choose a reason for hiding this comment

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

I don't have the original context so I won't do a full review, but if we do go with a regex I think we should be extremely prescriptive with it to more precisely match what we're looking for, e.g. here's a little Go playground test case for a key that has a } in it (which probably isn't valid for other reasons, but it's just to illustrate the point) https://go.dev/play/p/qUXqc397xkY.

So I'd rather see a regex that specifically looks for the keys we know how to replace, e.g.

fmt.Sprintf(`{{(%s)}}`, strings.Join(keys, "|"))

Copy link
Contributor Author

@maxb maxb Aug 8, 2023

Choose a reason for hiding this comment

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

The major point of this change is to do only one pass over the input string, not one per replaceable parameter, so the regex needs to stay as is.

However, I recognise I should make it clearer in the documentation that replacement names may not contain { or } themselves.

EDIT: Oops, responding from phone, and half the suggested code was off screen... I'll have a think about it. I'm not sure this is a case we really need to care about though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Having thought on this some more, I am of the opinion that we should not make this change, because it makes the code more complex (and less efficient, though this is not a particularly a hotspot), only to support an edge case of placeholder keys containing { or } which they realistically never will.

The keys here are all specified by the coders of plugins and never by users, and it would be mad to define a {{xxx}} substitution that contained internal { or } as part of the xxx - it would be a supremely confusing UI.

Also, if we dynamically build the regex, we then need to account for quoting regex metacharacters in key names - the . character could plausibly appear in a future key, for example.

I do, though, see that I should document the limitation of not using { } within substitution keys, and add an example of it to the tests.

Would that be an acceptable way to proceed?


return tpl
// QueryHelper evaluates a simple string template syntax by replacing {{value}}
// placeholders with the values from the supplied data map. Despite the name,
// it is NOT only used to template queries - it is also used for connection
// URIs or DSNs. Since it has no idea of the specific syntax into which it is
// templating, it does not perform any escaping. Unbalanced opening and closing
// brace sequences are passed through as is, as are any placeholders for which
// there is no key found in the map.
func QueryHelper(tpl string, data map[string]string) string {
return queryHelperRegex.ReplaceAllStringFunc(tpl, func(s string) string {
replacement, ok := data[s[2:len(s)-2]]
if ok {
return replacement
} else {
return s
}
})
}

// StatementCompatibilityHelper will populate the statements fields to support
Expand Down
43 changes: 43 additions & 0 deletions sdk/database/helper/dbutil/dbutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

"github.com/hashicorp/vault/sdk/database/dbplugin"
"github.com/stretchr/testify/assert"
)

func TestStatementCompatibilityHelper(t *testing.T) {
Expand Down Expand Up @@ -62,3 +63,45 @@ func TestStatementCompatibilityHelper(t *testing.T) {
t.Fatalf("mismatch: %#v, %#v", expectedStatements3, statements3)
}
}

func TestQueryHelper(t *testing.T) {
data := map[string]string{
// These are typical keys you find in a data map used with QueryHelper
"username": "hello",
"name": "hello",
"password": "world",
"expiration": "24h",
}
for _, tc := range []struct {
tpl string
expected string
}{
{"", ""},
{"somedb://{{username}}:{{password}}@something", "somedb://hello:world@something"},
// Unknown placeholders pass through as is
{"user={{name}} other={{unknown}}", "user=hello other={{unknown}}"},
// Various incorrect delimiters pass through as is
{"{{{{{{{{", "{{{{{{{{"},
{"{{username}} {{incomplete", "hello {{incomplete"},
{"VALID UNTIL '{{expiration}}'; {{", "VALID UNTIL '24h'; {{"},
// This case tests whether `{{!{{password}}` successfully looks past the earlier unmatched {{
{"}}backwards{{!{{password}}!", "}}backwards{{!world!"},
} {
assert.Equal(t, tc.expected, QueryHelper(tc.tpl, data),
"template processing produced unexpected result")
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test passes before and after the change.


// TestQueryHelper_Recursion confirms QueryHelper does not replace placeholders that were themselves added as part of
// a replacement value.
func TestQueryHelper_Recursion(t *testing.T) {
data := map[string]string{
"a": "A{{a}}{{b}}{{c}}{{d}}",
"b": "B{{a}}{{b}}{{c}}{{d}}",
"c": "C{{a}}{{b}}{{c}}{{d}}",
"d": "D{{a}}{{b}}{{c}}{{d}}",
}
assert.Equal(t, "A{{a}}{{b}}{{c}}{{d}}B{{a}}{{b}}{{c}}{{d}}C{{a}}{{b}}{{c}}{{d}}D{{a}}{{b}}{{c}}{{d}}",
QueryHelper("{{a}}{{b}}{{c}}{{d}}", data),
"template processing produced unexpected result")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test fails when applied to the existing implementation of QueryHelper - it showcases the issues just discussed at the end of #13414.

Loading