Redact MongoDB URI credentials before logging them - #1347
Conversation
The exporter injects the monitoring user's credentials into the MongoDB connection URI, and several log statements then printed that URI verbatim, leaking user:password in cleartext. URIs supplied through --mongodb.uri or MONGODB_URI can carry credentials of their own. Add internal/redact, a package both main and exporter can import, and route the six leaking call sites through it: - main.go "Connection URI" debug log - main.go --split-cluster parse failure - exporter/seedlist.go SRV parse failure, lookup failure, empty result - exporter/server.go buildServerMap parse failure redact.MongoURI uses (*url.URL).Redacted when the URI parses. Three of the sites log precisely because url.Parse failed, so no parsed URL is available there; those fall back to a regexp that rewrites the userinfo of the raw string, which also covers the escaped socket path PMM generates. Those same three sites leaked the URI twice over: url.Parse quotes the offending URI verbatim in the error it returns, so the error argument alone disclosed the password. redact.Error scrubs the error message as well. Fixes #1345
MongoDB permits punctuation in a password that a URI cannot carry unescaped. The tests used a single alphanumeric fixture throughout, which hid a gap: the fallback expression excluded "/", "?", "#" and whitespace, so a password containing any of them failed to match and was logged in full. Widen the password group and anchor it on the last "@", the same way url.Parse delimits the userinfo, and exercise the helpers with a varied set of passwords covering sub-delimiters, ":", "@", "/", "?", "#", space, quotes and percent-encoded input, over plain, SRV, multi-host and socket URIs. Two limits are now pinned by tests rather than left to be rediscovered: a credential-free URI containing a later "@" is over-redacted, and a parse error truncated at "?" or "#" still quotes the password prefix.
There was a problem hiding this comment.
Pull request overview
Adds shared MongoDB URI credential redaction and applies it to logging paths.
Changes:
- Introduces URI and error-message redaction helpers.
- Redacts six affected log sites.
- Adds comprehensive redaction tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
main.go |
Redacts connection and parse-failure logs. |
internal/redact/redact.go |
Implements shared redaction helpers. |
internal/redact/redact_test.go |
Tests URI and error redaction cases. |
exporter/server.go |
Redacts invalid server addresses. |
exporter/seedlist.go |
Redacts SRV-related URI logs. |
Suppressed comments (1)
internal/redact/redact.go:58
Errordoes not fully scrub every*url.Error: parsing splits at?/#before constructing the error, so the regex never sees the trailing@. The addedTestErrorLeavesTruncatedURIAloneconfirms that a password prefix such aspais still emitted by the three default-level parse-failure logs. This contradicts the helper's safety contract and the issue requirement; for*url.Error, log only the underlying parse diagnostic (or pass the original URI into the redactor) and update the known-gap test.
return credentialsRE.ReplaceAllString(err.Error(), replacement)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // would silently pass the password through. Being greedy anchors the match on the last "@", which | ||
| // is what url.Parse itself treats as the end of the userinfo. The cost is over-redaction when a | ||
| // credential-free URI happens to contain a later "@" — harmless next to leaking a password. | ||
| var credentialsRE = regexp.MustCompile(`(mongodb(?:\+srv)?://)([^:@]*):(.*)@`) |
JiriCtvrtka
left a comment
There was a problem hiding this comment.
Looks good.
Two places in exporter/pbm_collector.go I think are worth another look. Both are outside this diff, so I couldn't leave inline comments.
exporter/pbm_collector.go:90 — logger.Warn("failed to create PBM client", "error", err.Error())
The Not changed section excludes this one because it "logs only err". But that's the case redact.Error was added for — per The error argument leaked too, redacting only the URI argument "would have fixed nothing there". Same shape here: the URI goes into sdk.NewClient, and pbm/connect.Connect wraps a *url.Error that renders it back out.
exporter/pbm_collector.go:139 — l.Error("failed to get cluster status", "error", err.Error())
This one isn't mentioned. cli.ClusterStatus(ctx, pbmClient, cli.RSConfGetter(p.mongoURI)) hands the URI to PBM, and at the pinned version (percona-backup-mongodb@v1.8.1-0.20251124214042-d06cab743541, sdk/cli/status.go:168-180):
curi, err := url.Parse(string(g))
if err != nil {
return nil, errors.Wrapf(err, "parse mongo-uri '%s'", g)
}
g is the full URI. It fails on exactly the escaped-socket-path URI this PR uses as its motivating example, so the password appears twice in the line — once inside the *url.Error, once in PBM's own '%s'. Unlike :90, this fires on every scrape at Error level whenever --collector.pbm is on.
On "residual third-party risk outside this repo's control" — that's true of who builds the message, but redact.Error(err) scrubs it at the log site regardless of origin, which is what makes it work for the url.Parse cases already covered.
Both look like one-line changes:
logger.Warn("failed to create PBM client", "error", redact.Error(err))
l.Error("failed to get cluster status", "error", redact.Error(err))
Could be there's a reason these differ from the six you converted — happy to be corrected.
url.Parse cuts the URI at the byte it rejects and quotes the fragment in the error it returns. For a password containing "?" or "#" that fragment keeps the part before the delimiter, which no pattern can separate from a host and port. Where the exporter builds the message, drop the parse error and log only the redacted URI; the URI already identifies which target failed. percona-backup-mongodb builds its own message: RSConfGetter.Get wraps the URI in "parse mongo-uri '%s'" and then wraps the *url.Error, which quotes it again, so the password appears twice. That reaches a default-level log on every scrape when --collector.pbm is on. Redact both PBM errors rather than dropping them, since the wrapped text is the only diagnostic there. sdk.NewClient was not observed to disclose the URI -- the driver reports the option that failed, not the connection string -- but it is handed the same credential-bearing URI, so it is redacted on the same rule.
|
Thanks — you were right about
|
| URI defect | error |
|---|---|
| escaped socket path | ping: server selection error: ... Addr: /tmp/mongodb.sock ... |
?ssl=notabool |
invalid connection string option: error parsing uri: invalid value for "ssl": "notabool" |
host:notaport |
... port must be an integer: strconv.Atoi: parsing "notaport" |
| no host | ... must have at least 1 host |
raw space / # in password |
ping: server selection error: ... dial tcp: lookup host: no such host |
mongodb+srv:// |
... unescaped colon in password |
That matches the source: Connect reaches errors.Wrap(err, "parse mongo-uri") at pbm/connect/connect.go:245 with no %s, and everything before it comes from the driver's connstring parser, which names the offending option rather than echoing the URI.
Converted it anyway. Not because I found a leak, but because "we handed this function a credential-bearing URI, so we redact what it hands back" is an invariant that survives a dependency bump, whereas "I read PBM 1.8.1's error strings and they looked fine" has to be re-audited every time. It is one line.
What redact.Error does and does not fix there
It removes the full password from both copies:
red = parse mongo-uri 'mongodb://monitor:xxxxx@%2Ftmp%2Fmongodb.sock/admin": invalid URL escape "%2F"
It does not close the truncation gap in the wrapped *url.Error. For a password containing ? or #, url.Parse cuts the URI at that byte, so the inner quote reads parse "mongodb://monitor:pa" with no @ left to anchor on and the prefix pa survives. Nothing in the pattern can tell monitor:pa from host:27017.
So the two groups are handled differently, deliberately:
- Where we build the message (the three
url.Parsesites inmain.go,exporter/seedlist.go,exporter/server.go) the parse error is now dropped entirely and only the redacted URI is logged. That closes the gap rather than narrowing it, and costs little — the redacted URI already says which target failed. This also resolves the Copilot comment on this PR, which flagged the same thing. - Where PBM builds it, dropping the error would throw away the only diagnostic, so
redact.Errorstays and the prefix residue remains a known limit.TestErrorLeavesTruncatedURIAlonepins it, andTestErrorRedactsURIWrappedByThirdPartycovers PBM's double-quoted shape.
That is also what keeps redact.Error in the tree: dropping the error at the three parse sites left it with no callers, and these two sites are now its reason to exist.
golangci-lint run --new-from-rev=origin/main is clean; internal/redact, exporter/dsn_fix and root tests pass. make check and go test ./... still fail the same way they do on main — 347 pre-existing lint findings, and the ./exporter / internal/tu suites needing make test-cluster.
Fixes #1345.
MongoDB connection URIs carrying credentials were written to the logs verbatim.
buildURI(main.go:300) injects the monitoring user's password into the URI, and a URI supplied via--mongodb.uri/MONGODB_URImay already embed one, so several log statements discloseduser:passwordin cleartext.What changed
A new
internal/redactpackage holds the two helpers. The leaking call sites straddlepackage mainandpackage exporter, so neither could host a shared helper;internal/utilis MongoDB-command code and pulls in the driver, which made it a poor home for a pure string function.redact.MongoURI(uri)—(*url.URL).Redacted()on the happy path, keeping the username and substitutingxxxxx.redact.Error(err)— scrubs credentials out of an error's message (see below).Six call sites now redact:
main.go:143—"Connection URI"main.go:259—--split-clusterparse failureexporter/seedlist.go:31— URI parse failureexporter/seedlist.go:36— SRV lookup failureexporter/seedlist.go:41— no SRV recordsexporter/server.go:181—buildServerMapparse failureOnly the first is Debug-level; the other five fire at the default log level, on error paths reachable through ordinary operational failures such as a DNS hiccup or a malformed URI.
The error argument leaked too
Three of those sites log because
url.Parsefailed.url.Parsereturns a*url.Error, whoseError()quotes the offending URL verbatim — so theerrargument alone disclosed the full password, and redacting only the URI argument would have fixed nothing there. Henceredact.Error.This is not hypothetical. The URI PMM generates with an escaped socket path is exactly what makes
url.Parsefail:Where parsing failed there is no
*url.URLto redact, soMongoURIfalls back to a regexp that rewrites the userinfo of the raw string. The URI is kept in the message rather than dropped, because with multiple targets configured it is the only way to tell which one is malformed.Tests
internal/redact/redact_test.gois table-driven over credentialed, uncredentialed, percent-encoded, SRV, multi-host and unparseable inputs. The assertion that matters is that the password substring does not survive.TestErrorHidesURIQuotedByParseErrorfirst asserts that the rawurl.Parseerror really does contain the password, then that the redacted form does not — so the test would catch a future Go release that changed this behaviour.Also verified manually against the built binary for three scenarios (credentials via
--mongodb.user/--mongodb.password, credentials embedded in--mongodb.uri, and the socket URI), grepping the logs for the password in each.Not changed
buildURIandbuildURIManuallyare untouched — injecting the credentials is intended; only logging them was wrong.Two spots were examined and left alone:
exporter/pbm_collector.go:88passes the URI to the third-party PBM SDK and logs onlyerr, andexporter/exporter.go:426wraps a driver validation error whose messages quote individual option keys rather than the whole URI. Both carry residual third-party risk outside this repo's control.Pre-existing lint findings in the touched files are left in place to keep the diff surgical.
golangci-lint run --new-from-rev=origin/mainreports 0 issues, which matches what CI's reviewdog gate (filter_mode: added) evaluates.Conflict with #1336
PR #1336 adds an unexported
redactMongoURItopackage mainand redactsmain.go:143as incidental cleanup. Whichever lands second needs a small rebase there — drop the local helper in favour ofinternal/redact, or re-apply that one line. No other overlap.