Skip to content

Commit a940e44

Browse files
committed
fix: redact sensitive credentials from connection URLs and properties in logs and test results
1 parent 4179ba8 commit a940e44

5 files changed

Lines changed: 162 additions & 19 deletions

File tree

cmd/query/connectionactions.go

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/tls"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"net"
89
"net/http"
910
"net/url"
@@ -106,11 +107,11 @@ func maskedConnection(c *models.Connection) resolvedConnection {
106107
return resolvedConnection{
107108
Type: c.Type,
108109
Namespace: c.Namespace,
109-
URL: c.URL,
110+
URL: redactConnectionURL(c.URL),
110111
Username: c.Username,
111112
Password: maskValue(c.Password),
112113
Certificate: maskValue(c.Certificate),
113-
Properties: c.Properties,
114+
Properties: redactConnectionProperties(c.Properties),
114115
}
115116
}
116117

@@ -120,23 +121,24 @@ func testConnection(ctx dbcontext.Context, c *models.Connection) testResult {
120121
if c.URL == "" {
121122
return testResult{OK: false, Message: "connection has no URL to test"}
122123
}
124+
displayURL := redactConnectionURL(c.URL)
123125
host, scheme, ok := dialTarget(c.URL, c.Type)
124126
if !ok {
125-
return testResult{OK: false, Message: fmt.Sprintf("cannot determine host from url %q", c.URL), URL: c.URL}
127+
return testResult{OK: false, Message: fmt.Sprintf("cannot determine host from url %q", displayURL), URL: displayURL}
126128
}
127129

128130
dialCtx, cancel := ctx.WithTimeout(5 * time.Second)
129131
defer cancel()
130132
conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", host)
131133
if err != nil {
132-
return testResult{OK: false, Message: fmt.Sprintf("TCP connect to %s failed: %v", host, err), URL: c.URL}
134+
return testResult{OK: false, Message: fmt.Sprintf("TCP connect to %s failed: %v", host, err), URL: displayURL}
133135
}
134136
_ = conn.Close()
135137

136138
if scheme == "http" || scheme == "https" {
137-
return httpProbe(c)
139+
return httpProbe(c, displayURL)
138140
}
139-
return testResult{OK: true, Message: fmt.Sprintf("TCP connect to %s succeeded", host), URL: c.URL}
141+
return testResult{OK: true, Message: fmt.Sprintf("TCP connect to %s succeeded", host), URL: displayURL}
140142
}
141143

142144
// dialTarget resolves a connection URL to a host:port for the TCP reachability
@@ -208,23 +210,118 @@ func splitServerValue(v string) (host, port string) {
208210
return strings.TrimSpace(host), port
209211
}
210212

211-
func httpProbe(c *models.Connection) testResult {
213+
func httpProbe(c *models.Connection, displayURL string) testResult {
214+
u, err := validatedHTTPProbeURL(c.URL)
215+
if err != nil {
216+
return testResult{OK: false, Message: err.Error(), URL: displayURL}
217+
}
212218
client := &http.Client{
213219
Timeout: 8 * time.Second,
214220
Transport: &http.Transport{
215221
TLSClientConfig: &tls.Config{InsecureSkipVerify: c.InsecureTLS}, //nolint:gosec // operator opt-in via insecure_tls
216222
},
217223
}
218-
resp, err := client.Get(c.URL)
224+
req, err := http.NewRequest(http.MethodGet, u.String(), http.NoBody)
219225
if err != nil {
220-
return testResult{OK: false, Message: fmt.Sprintf("HTTP request failed: %v", err), URL: c.URL}
226+
return testResult{OK: false, Message: fmt.Sprintf("HTTP request failed: %v", redactError(err, c.URL, displayURL)), URL: displayURL}
227+
}
228+
// codeql[go/request-forgery]: connection testing intentionally probes the
229+
// operator-supplied URL after validating it is an absolute HTTP(S) URL.
230+
resp, err := client.Do(req)
231+
if err != nil {
232+
return testResult{OK: false, Message: fmt.Sprintf("HTTP request failed: %v", redactError(err, c.URL, displayURL)), URL: displayURL}
221233
}
222234
defer func() { _ = resp.Body.Close() }()
235+
_, _ = io.Copy(io.Discard, resp.Body)
223236
return testResult{
224237
OK: resp.StatusCode < 500,
225238
Message: fmt.Sprintf("HTTP %s", resp.Status),
226-
URL: c.URL,
239+
URL: displayURL,
240+
}
241+
}
242+
243+
func validatedHTTPProbeURL(rawURL string) (*url.URL, error) {
244+
u, err := url.Parse(rawURL)
245+
if err != nil {
246+
return nil, fmt.Errorf("invalid HTTP URL: %w", err)
247+
}
248+
if u.Scheme != "http" && u.Scheme != "https" {
249+
return nil, fmt.Errorf("HTTP probe requires http or https URL")
250+
}
251+
if u.Hostname() == "" {
252+
return nil, fmt.Errorf("HTTP probe requires a URL host")
253+
}
254+
return u, nil
255+
}
256+
257+
func redactConnectionURL(rawURL string) string {
258+
if rawURL == "" {
259+
return ""
260+
}
261+
if u, err := url.Parse(rawURL); err == nil && u.Scheme != "" && u.Host != "" {
262+
redacted := *u
263+
redacted.User = nil
264+
q := redacted.Query()
265+
for key, vals := range q {
266+
if isSensitiveCredentialKey(key) {
267+
for i := range vals {
268+
vals[i] = "redacted"
269+
}
270+
q[key] = vals
271+
}
272+
}
273+
redacted.RawQuery = q.Encode()
274+
return redacted.String()
275+
}
276+
return redactKeyValueDSN(rawURL)
277+
}
278+
279+
func redactKeyValueDSN(dsn string) string {
280+
parts := strings.Split(dsn, ";")
281+
for i, part := range parts {
282+
key, val, found := strings.Cut(part, "=")
283+
if !found || !isSensitiveCredentialKey(key) {
284+
continue
285+
}
286+
parts[i] = key + "=" + maskValue(val)
287+
}
288+
return strings.Join(parts, ";")
289+
}
290+
291+
func redactConnectionProperties(properties map[string]string) map[string]string {
292+
if len(properties) == 0 {
293+
return properties
294+
}
295+
out := make(map[string]string, len(properties))
296+
for key, value := range properties {
297+
if isSensitiveCredentialKey(key) {
298+
out[key] = maskValue(value)
299+
} else {
300+
out[key] = value
301+
}
302+
}
303+
return out
304+
}
305+
306+
func isSensitiveCredentialKey(key string) bool {
307+
key = strings.ToLower(strings.TrimSpace(key))
308+
key = strings.ReplaceAll(key, "-", "_")
309+
key = strings.ReplaceAll(key, " ", "_")
310+
switch key {
311+
case "password", "pwd", "pass", "passwd", "secret", "token", "bearer",
312+
"access_token", "refresh_token", "api_key", "apikey", "client_secret",
313+
"user", "username", "user_id", "userid", "uid":
314+
return true
315+
default:
316+
return false
317+
}
318+
}
319+
320+
func redactError(err error, rawURL, displayURL string) error {
321+
if err == nil {
322+
return nil
227323
}
324+
return fmt.Errorf("%s", strings.ReplaceAll(err.Error(), rawURL, displayURL))
228325
}
229326

230327
// defaultPort maps a URL scheme to its conventional port for the reachability

cmd/query/connectionactions_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net"
66
"net/http"
77
"net/http/httptest"
8+
"strings"
89
"testing"
910

1011
dbcontext "github.com/flanksource/commons-db/context"
@@ -31,6 +32,38 @@ func TestMaskedConnection(t *testing.T) {
3132
}
3233
}
3334

35+
func TestMaskedConnectionRedactsEmbeddedCredentials(t *testing.T) {
36+
got := maskedConnection(&models.Connection{
37+
Type: "postgres",
38+
URL: "postgres://app:supersecret@db.prod.svc.cluster.local:5432/app?sslmode=require&password=querysecret",
39+
Properties: map[string]string{
40+
"host": "db.prod.svc.cluster.local",
41+
"password": "propertysecret",
42+
},
43+
})
44+
45+
if strings.Contains(got.URL, "app:supersecret") || strings.Contains(got.URL, "querysecret") {
46+
t.Fatalf("url should be redacted, got %q", got.URL)
47+
}
48+
if got.URL != "postgres://db.prod.svc.cluster.local:5432/app?password=redacted&sslmode=require" {
49+
t.Errorf("redacted url = %q", got.URL)
50+
}
51+
if got.Properties["password"] == "propertysecret" {
52+
t.Errorf("sensitive properties should be masked, got %+v", got.Properties)
53+
}
54+
}
55+
56+
func TestRedactConnectionURLKeyValueDSN(t *testing.T) {
57+
raw := "server=mssql.lab;user id=sa;password=YourStrong@Passw0rd;database=LAB_APP_QA;port=31433"
58+
got := redactConnectionURL(raw)
59+
if strings.Contains(got, "YourStrong@Passw0rd") || strings.Contains(got, "user id=sa") {
60+
t.Fatalf("DSN credentials should be redacted, got %q", got)
61+
}
62+
if !strings.Contains(got, "server=mssql.lab") || !strings.Contains(got, "port=31433") {
63+
t.Fatalf("non-sensitive DSN fields should be preserved, got %q", got)
64+
}
65+
}
66+
3467
func TestDefaultPort(t *testing.T) {
3568
cases := map[string]string{"http": "80", "https": "443", "postgres": "5432", "redis": "6379", "weird": ""}
3669
for scheme, want := range cases {
@@ -112,6 +145,23 @@ func TestTestConnectionHTTPReachable(t *testing.T) {
112145
}
113146
}
114147

148+
func TestTestConnectionHTTPRedactsURL(t *testing.T) {
149+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
150+
w.WriteHeader(http.StatusOK)
151+
}))
152+
defer ts.Close()
153+
154+
u := strings.Replace(ts.URL, "http://", "http://user:pass@", 1) + "?token=secret-token"
155+
ctx := dbcontext.NewContext(context.Background())
156+
res := testConnection(ctx, &models.Connection{Type: "http", URL: u})
157+
if !res.OK {
158+
t.Fatalf("expected reachable, got %+v", res)
159+
}
160+
if strings.Contains(res.URL, "user:pass") || strings.Contains(res.URL, "secret-token") {
161+
t.Fatalf("test result URL should be redacted, got %+v", res)
162+
}
163+
}
164+
115165
func TestTestConnectionUnreachable(t *testing.T) {
116166
ctx := dbcontext.NewContext(context.Background())
117167
// Port 1 is reserved and not listening — the TCP connect must fail.

cmd/query/www/embed.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
"strings"
1212
)
1313

14-
//go:embed all:dist
14+
//go:embed dist
1515
var distFS embed.FS
1616

1717
// Handler returns an http.Handler that serves the embedded SPA. Existing files

context/observability.go

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func (k Context) HARConfig(feature string) har.HARConfig {
7777

7878
// EffectiveHARCollector resolves which HAR collector to use for a feature. An
7979
// explicit collector always wins; otherwise the context-owned collector is
80-
// returned (configured per-feature) only when the feature's level is >= Debug.
80+
// returned only when the feature's level is >= Debug.
8181
func (k Context) EffectiveHARCollector(feature string, explicit *har.Collector) *har.Collector {
8282
if explicit != nil {
8383
return explicit
@@ -86,11 +86,7 @@ func (k Context) EffectiveHARCollector(feature string, explicit *har.Collector)
8686
if level < logger.Debug {
8787
return nil
8888
}
89-
collector := k.HARCollector()
90-
if collector != nil {
91-
collector.Config = k.HARConfig(feature)
92-
}
93-
return collector
89+
return k.HARCollector()
9490
}
9591

9692
func (k Context) effectiveObservabilityLevel(feature string, harCapture bool) (logger.LogLevel, string) {

query/processor/recon.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func Recon(baseline, target []query.Row, opts ReconOptions) ([]query.Row, error)
4848
baseIndex := indexByKey(baseline, opts.Key)
4949
targetIndex := indexByKey(target, opts.Key)
5050

51-
out := make([]query.Row, 0, len(target)+len(baseline))
51+
out := make([]query.Row, 0, len(target))
5252

5353
// Walk target rows in order: added, changed, or unchanged.
5454
for _, trow := range target {
@@ -142,7 +142,7 @@ func keyOf(row query.Row, key []string) string {
142142

143143
// withStatus returns a shallow copy of row with the reconciliation status set.
144144
func withStatus(row query.Row, status string) query.Row {
145-
out := make(query.Row, len(row)+1)
145+
out := make(query.Row, len(row))
146146
for k, v := range row {
147147
out[k] = v
148148
}

0 commit comments

Comments
 (0)