Repository navigation
Harden handling of git credential fill output - #2101
Tyrie Vella (tyrielv) wants to merge 5 commits into
Conversation
7ee174a to
cf4d639
Compare
cf4d639 to
1b593fe
Compare
Nathan Baird (ShiningMassXAcc)
left a comment
There was a problem hiding this comment.
Reviewed the diff, built it, and ran the full unit suite. The core security property holds: I verified it two independent ways — hand-tracing every path, and separately by executing the exact method body against adversarial inputs and running real git credential fill (2.55.0.vfs.0.10) against a deliberately hostile credential helper. I found no secret-leakage path, and I'm stating that explicitly rather than manufacturing one.
The returned string's alphabet is closed: every element is either a compile-time literal (<malformed>, <other>, ,) or a key that passed CredentialOutputKeyAllowlist.Contains under StringComparer.Ordinal — byte-exact, so no culture or case folding can admit a crafted key. Things I specifically tried that all degrade safely: leading whitespace before the key, trailing whitespace before =, = at index 0, no = at all, multiple = (first wins, value never emitted), a 200-char key, and homoglyph keys (Kelvin sign, accented characters) — <other> or <malformed> in every case. Line splitting is correct too: RemoveEmptyEntries drops the zero-length run between \r and \n, so CRLF and bare-CR both parse cleanly.
Ordinal rather than OrdinalIgnoreCase is the right call — git's credential keys are lowercase by spec, and an uppercase variant degrading to <other> fails in the conservative direction.
The tests are a good shape: AssertNoSecret sweeping all recorded metadata rather than just the one field, and putting the sentinel in the key position in the first case to prove free text before = is suppressed. The MockTracer change is purely additive and I confirmed nothing existing regressed.
Validation on my end: clean build, 0 warnings, and 985/990 unit tests passing versus 977/982 on master — so +8 tests, all green, zero regressions (the 5 PostIndexChangedHookTests failures are environmental and identical on both branches).
Four things below. None of them is a regression this PR introduces, so I don't consider any of it merge-blocking — but the first one is worth a decision before this goes in.
1. GVFS/GVFS.Common/Git/GitProcess.cs:300 (and :367) — the redaction landed on the log-only sink, not the telemetry one
This is the one I'd most like your read on, because I think the asymmetry inverts the intent slightly.
The path this PR sanitizes is activity.Stop(metadata), on an activity started by StartActivity("TryGetCredential", EventLevel.Informational). That overload resolves to Keywords.None (JsonTracer.cs:255), so OutputKeys was only ever being written to the local log file. Meanwhile the failure path a few lines up is the one that leaves the machine:
tracer.RelatedWarning(
errorData,
"Git could not get credentials: " + gitCredentialOutput.Errors,
Keywords.Network | Keywords.Telemetry);TelemetryDaemonEventListener gates on that keyword, so this stderr text ships off-box while the now-sanitized OutputKeys does not.
That matters because git itself will put secret-bearing text into that stderr. credential_read reflects any line it cannot parse back verbatim as warning: invalid credential line: <raw line>. So a helper that emits a password containing an embedded newline produces a secret fragment on stderr and exits 128 — which is ExitCodeIsFailure, i.e. exactly this branch. Reproduced locally against git version 2.55.0.vfs.0.10 with a helper emitting a bare line:
EXITCODE=128
--- STDERR ---
fatal: Cannot prompt because user interactivity has been disabled.
warning: invalid credential line: <raw helper line appears here verbatim>
fatal: unable to get password from user
The same text is also assigned to errorMessage (:302 / :364), which flows on to GitAuthentication.cs:295 and into the GitObjectsHttpException message at HttpRequestor.cs:117.
Small related note: the new doc comment at :1101 says values "never reach telemetry or the log" — true of this method, but the sibling failure path undercuts the sentence as written.
I'm not asking you to solve the whole stderr problem here; the narrow scope is the right call. But two things would be good before this merges:
- Dropping
Keywords.Telemetryfrom these two call sites is a very small change and closes the off-box half of the stated goal. - Either way — could we get a tracking issue? I searched and there's no existing issue covering credential stderr redaction, and the description says "no follow-up is planned yet", so the remaining half would otherwise have nothing holding it.
For completeness: :231 (reject) and :259 (approve) also trace result.Errors, but through the format-string RelatedWarning overload, which routes to Keywords.None — local log only, so materially lower risk than these two.
2. GVFS/GVFS.Common/Git/GitProcess.cs:1106-1129 — GetCredentialOutputKeys is an unbounded, amplifying transform
credential fill runs with parseStdOutLine: null, so stdout is buffered whole through BoundedGitOutputBuffer(MaxCapturedStdOutChars) — and MaxCapturedStdOutChars is 134,217,728 (:38). The helper doesn't consult OutputTruncated.
Each non-whitespace line becomes up to 12 output chars (<malformed> plus separator), so a 2-char line amplifies 6x. I measured it by running the method body over a 400,000-char input of repeated single-char lines: 2,399,999 chars out, ratio 5.99997. Projected at the cap that's roughly 768M chars (~1.5 GiB as UTF-16) for the joined string, plus an intermediate Split array of ~67M string references.
Worth flagging that this is a net worsening versus the previous behaviour: metadata.Add("Output", ...) stored an existing reference and allocated nothing new, whereas this allocates a fresh object graph several times the size of the source. It also runs against the convention this file documents at :18-37 and implements in BoundedGitOutputBuffer (:1288+), whose stated rationale is preventing "a noisy git command from growing an unbounded StringBuilder until GVFS.Mount hits an OutOfMemoryException". And it's on the parse-failure path, which is precisely where unusually noisy output is most likely in the first place.
Suggestion: cap the lines summarized (.Take(N) with a +K more marker) and/or short-circuit when OutputTruncated is set, mirroring the truncation-marker pattern already in BoundedGitOutputBuffer. A handful of key names is all the diagnostic value this field really carries.
3. GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs — the suite can't observe the path in finding 1
Two gaps worth closing:
AssertNoSecretscans onlyStartActivityTracer.StoppedActivityMetadata. It never scansRelatedWarningEvents/RelatedErrorEvents/RelatedInfoEvents— which is exactly where the stderr warning from finding 1 lands. Every case also passesstring.Emptyfor stderr andResult.SuccessCode, so theExitCodeIsFailurebranch is never exercised at all. The suite proves the fixed path is clean but asserts nothing about the unfixed one. A case with a failing exit code and secret-bearing stderr, plus extendingAssertNoSecretto the related-event collections, would cover it.- The "success path omits
OutputKeys" assertion exists forTryGetCredentialbut not forTryGetCertificatePassword. Both call sites got the same treatment, so it'd be good to pin both.
Also currently unprotected by regression tests, though I confirmed the method handles them correctly: leading/trailing whitespace around a key, and uppercase key variants. Those are the load-bearing guards, so they're worth locking in.
4. GVFS/GVFS.Common/Git/GitProcess.cs:1113 — IEnumerable<string> keys
CONTRIBUTING.md (lines 190-192) asks for explicit types over interfaces: "Prefer concrete/explicit types to interfaces (e.g. prefer List to IList)." Materializing to List<string> would also give a natural place to apply the bound from finding 2.
523a0a1 to
541a5d7
Compare
|
Note Machine-drafted reply, reviewed and approved by Tyrie Vella (@tyrielv) before posting. Thank you for the careful review. All four points were right. I addressed them in
I also made |
Nathan Baird (ShiningMassXAcc)
left a comment
There was a problem hiding this comment.
Review pass over this PR. 3 comment(s).
These findings concern code the diff does not touch directly, so GitHub cannot anchor them inline:
GVFS/GVFS.Common/Git/GitProcess.cs (around line 1276) - LOW
ParseValue returns the first line-start match. Git's credential_read overwrites the field on each repeated key, so the last value wins. A helper emitting password= twice gets different answers from git and from GVFS.
Probably not worth changing behavior over, but worth either matching git or stating the first-wins choice in the comment and locking it with a test, since this is now the authoritative parser for this output.
541a5d7 to
4039723
Compare
|
Note Machine-drafted reply, reviewed and approved by Tyrie Vella (@tyrielv) before posting. Thank you, all three were right. I fixed them in
|
Nathan Baird (ShiningMassXAcc)
left a comment
There was a problem hiding this comment.
Re-review of 40397235. Both of my earlier threads are genuinely fixed here — the fix is explained in a top-level comment, which means the threads still read as unanswered in the Files view. Replying inline on each thread (even just "fixed in 4039723") would make that visible to anyone scanning the PR.
One High finding below: I believe the raw-stderr-to-telemetry property the PR sets out to close is still open on one path. The rest are smaller.
Credential-leak audit of the new code — clean except one: TraceCredentialFillFailure no leak · TraceCredentialFillMissingPassword no leak (closed OutputKeys alphabet) · timeout branch no leak · activity.Stop(metadata) no leak (the old raw-stdout dump is gone) · GitProcess.cs ~403 leaks via the errorMessage out-param (detail inline).
CI is green across every leg.
Trace only allow-listed credential keys on parse failure. Map unknown or malformed output lines to placeholders so arbitrary helper text cannot appear in telemetry as a key name. Add tests that use a secret sentinel in both a password value and a malformed helper line. Assert that neither value appears in activity metadata. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
ParseValue added the prefix length to IndexOf without checking for -1. When the key was absent, the start index landed a few characters into the output and the method returned an unrelated substring. For example, output with no "username=" line gave a username of "=https". The caller treated this as success, so malformed credential output could be cached and used for authentication. Return null when the key is absent. A helper that returns only a token does not send a username, so TryGetCredential now treats an absent username as empty and requires only the password. GitAuthentication accepts an empty username and builds the basic-auth string as ":<password>". Output without a password is rejected. Add tests for output without a username, without a password, and the certificate-password path, plus authentication tests for both cases. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Git can echo an unparseable credential helper line to stderr, including a secret. The failure warning for "git credential fill" carried that stderr with the telemetry keyword. Report the failure in the telemetry event without stderr, and write the stderr text to the local log only. The console-visibility diagnostic stays in the telemetry event. Bound the traced key summary. It lists at most 32 lines and reports "<truncated>" when the captured output was truncated, so a broken helper cannot make the summary larger than the output. Recognize the other key names in the git credential protocol, so a helper that returns a bearer token without a password is easy to diagnose. Match "username=" and "password=" only at the start of a line, so a longer key such as "oauth_password=" or a value that contains the text cannot supply the value. Return a reason when a credential has no password. Split a stored credential at the first ":" so a password that contains ":" can be approved. Add tests for each case, including stderr, which now cover every traced event instead of only the activity metadata. Fix a test that waited for the 120 second initialization timeout. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
The activity stop event for a credential fill uses no telemetry keyword, so the output keys that explain a rejected credential never left the machine. A helper whose output shape the parser rejects was a silent failure. Report a rejected credential in a warning that carries the telemetry keyword, with the allowed key names, the exit code, and the length and truncation of stderr. Add the exit code and the stderr length and truncation to the failure warning as well, so the failures of a helper can be told apart without sending stderr. Make the parser and the key summary agree on what a line is, and match how git reads credential output. A line ends at a newline, the last line need not end with one, a carriage return is not a separator, and a repeated key takes its last value. Git writes each key once and ends every line, so this does not change what a real helper returns. It makes the parser no stricter than git. Test failures with an absent password instead of a missing final newline, which git never produces. Use the stop metadata that the mock tracer already records. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
A failed "git credential fill" returned its stderr as the error message. The caller puts that message in an exception, and the retry handler reports the final failure of a request with the telemetry keyword, so a secret that git echoed to stderr could still leave the machine. Return a message that names the exit code, and keep the stderr text in the local log only. Treat an empty password like a missing one, so the failure has the same reason and the same telemetry warning on both layers. Write the captured stderr of a credential manager timeout to the local log as well, which is the case where it is most likely to explain the hang. Encode and decode the stored credential as UTF-8 instead of ASCII. ASCII replaced every other character with "?", which sent a corrupted credential to the server and to "git credential approve". Document that the parser reads all of the output because the last value wins, and that it skips empty lines where git stops reading at the first one. Add tests at the authentication boundary for the returned message and for non-ASCII credentials, and tests for an empty password and a timeout. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
4039723 to
0a3f274
Compare
|
Note Machine-drafted reply, reviewed and approved by Tyrie Vella (@tyrielv) before posting. Thank you for the re-review. I addressed all eight comments in
|
Problem and Context
git credential fillwrites its result askey=valuelines:Four problems let credential data reach logs and telemetry, or let the wrong value be used.
GitProcess.TryGetCredentialandGitProcess.TryGetCertificatePasswordadded the raw stdout to the activity metadata. A helper can return a secret and still fail the parse, for example when the last line has no terminating newline. The secret then reached the log.warning: invalid credential line: <raw line>, so a secret fragment could leave the machine.ParseValuedid not check for an absent key. It added the prefix length to a failedIndexOf(-1), so output with nousername=line gave a username of=https. The caller treated that as success. It also matched a key anywhere in the output, so a longer key such asoauth_password=or a value that containspassword=could supply the value.Changes
OutputKeysinstead ofOutput.GetCredentialOutputKeysmaps each output line to a key name from the git credential protocol,<other>for any other key, or<malformed>for a line with no key. A value, or free text before the first=, cannot reach the metadata. The summary lists at most 32 lines and reports<truncated>when the captured output was truncated.OutputKeys, the exit code, and the length and truncation flag of stderr. The failure with a nonzero exit reports the same exit code and stderr length. No stderr text goes to telemetry. ThehasVisibleConsoleWindowdiagnostic stays in these events.ParseValuereturnsnullwhen the key is absent. It reads output like git: a line ends at\n, the last line need not end with one, a repeated key takes its last value, and a key matches only at the start of a line.GetCredentialOutputKeysuses the same definition of a line.TryGetCredentialtreats an absent username as empty and requires a non-empty password, because a helper that returns only a token does not send a username.GitAuthenticationaccepts an empty username and builds the basic-auth string as:<password>. A credential without a password fails with the reason "Git credential fill returned no password".GitAuthenticationsplits a stored credential at the first:, so a password that contains:can be approved. It encodes and decodes the credential as UTF-8. ASCII replaced every other character with?.GitAuthentication: a failing exit with secret-bearing stderr and the returned error message, a timeout, CRLF and bare-CR separators, empty output, unknown, whitespace and uppercase keys, the key limit, truncated output, the success path, line-start matching, repeated keys, a missing or empty password, a missing username, non-ASCII credentials, and the approval of a credential with an empty username or a:in the password.Known gaps and deliberate decisions
credential fillis in the local log file. The error message that a user sees no longer includes it, so a user must read the log to see what the credential helper wrote.<other>. A key that is not in the git credential protocol list does not appear by name. This keeps free text out of the output at the cost of some diagnostic detail.Outputis nowOutputKeys. No consumer of the old field exists in this repository.username=line, or none, now authenticates with:<password>. Before, an explicit empty username failed with "Got back empty credentials", and an absent username produced a wrong value that was used for authentication. Azure DevOps and a GVFS cache server both returned success (HTTP 200) for a valid token with an empty username and with a non-empty one. Neither treated an invalid token as success. The checks used a Microsoft Entra token, not a PAT.ParseValueskips empty lines and keeps reading, so it accepts every output that git accepts.