Skip to content

Commit 939242d

Browse files
committed
Only erase credentials that match user/pass when present
Only erase stored credentials when the user name and password values match those passed on standard input, if any. When no user name and/or password are given on standard input we skip checking the stored value and just erase the credential. This is an important but possibly subtle behaviour to help prevent erroneous erasure of credentials. Calls to `git-credential fill` should immediately be followed by `git-credential approve` or `reject`. If there are concurrent processes that have called `fill` but not yet `approve` or `reject`, there's a gap when another (faster) process could have completed the `fill+approve` combination and have stored a different credential value than what the first process got from `fill`. If the first process fails and calls `reject` the valid credential as `approve`-ed by the second process would be deleted. This change helps prevent such a scenario (although there is still a small gap inside the GCM erase command itself between reading the OS's credential store and deleting from it - we could look at some system-wide lock around the credential store if this is deemed a problem later).
1 parent f54a51b commit 939242d

File tree

2 files changed

+122
-3
lines changed

2 files changed

+122
-3
lines changed

common/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
3+
using System;
34
using System.Threading.Tasks;
5+
using Microsoft.Git.CredentialManager.SecureStorage;
46

57
namespace Microsoft.Git.CredentialManager.Commands
68
{
@@ -16,13 +18,46 @@ public EraseCommand(IHostProviderRegistry hostProviderRegistry)
1618

1719
protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey)
1820
{
19-
context.Trace.WriteLine("Erasing credential...");
21+
// Try to locate an existing credential with the computed key
22+
context.Trace.WriteLine("Looking for existing credential in store...");
23+
ICredential credential = context.CredentialStore.Get(credentialKey);
24+
if (credential == null)
25+
{
26+
context.Trace.WriteLine("No stored credential was found.");
27+
return Task.CompletedTask;
28+
}
29+
else
30+
{
31+
context.Trace.WriteLine("Existing credential found.");
32+
}
33+
34+
// If we've been given a specific username and/or password we should only proceed
35+
// to erase the stored credential if they match exactly
36+
if (!string.IsNullOrWhiteSpace(input.UserName) && !StringComparer.Ordinal.Equals(input.UserName, credential.UserName))
37+
{
38+
context.Trace.WriteLine("Stored username does not match specified username - not erasing credential.");
39+
context.Trace.WriteLine($"\tInput username={input.UserName}");
40+
context.Trace.WriteLine($"\tStored username={credential.UserName}");
41+
return Task.CompletedTask;
42+
}
43+
44+
if (!string.IsNullOrWhiteSpace(input.Password) && !StringComparer.Ordinal.Equals(input.Password, credential.Password))
45+
{
46+
context.Trace.WriteLine("Stored password does not match specified password - not erasing credential.");
47+
context.Trace.WriteLineSecrets("\tInput password={0}", new object[] {input.Password});
48+
context.Trace.WriteLineSecrets("\tStored password={0}", new object[] {credential.Password});
49+
return Task.CompletedTask;
50+
}
2051

21-
// Delete the credential in the store.
52+
context.Trace.WriteLine("Erasing stored credential...");
2253
if (context.CredentialStore.Remove(credentialKey))
2354
{
2455
context.Trace.WriteLine("Credential was successfully erased.");
2556
}
57+
else
58+
{
59+
context.Trace.WriteLine("Credential erase failed.");
60+
}
2661

2762
return Task.CompletedTask;
2863
}

common/tests/Microsoft.Git.CredentialManager.Tests/Commands/EraseCommandTests.cs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public void EraseCommand_CanExecuteAsync(string argString, bool expected)
3030
}
3131

3232
[Fact]
33-
public async Task EraseCommand_ExecuteAsync_CredentialExists_ErasesCredential()
33+
public async Task EraseCommand_ExecuteAsync_NoInputUserPass_CredentialExists_ErasesCredential()
3434
{
3535
const string testCredentialKey = "test-cred-key";
3636

@@ -57,6 +57,90 @@ public async Task EraseCommand_ExecuteAsync_CredentialExists_ErasesCredential()
5757
Assert.True(context.CredentialStore.ContainsKey("git:credential2"));
5858
}
5959

60+
[Fact]
61+
public async Task EraseCommand_ExecuteAsync_InputUserPass_CredentialExists_UserNotMatch_DoesNothing()
62+
{
63+
const string testUserName = "john.doe";
64+
const string testPassword = "letmein123";
65+
const string testCredentialKey = "test-cred-key";
66+
string stdIn = $"username={testUserName}\npassword={testPassword}\n\n";
67+
68+
var provider = new TestHostProvider {CredentialKey = testCredentialKey};
69+
var providerRegistry = new TestHostProviderRegistry {Provider = provider};
70+
var context = new TestCommandContext
71+
{
72+
StdIn = stdIn,
73+
CredentialStore =
74+
{
75+
[$"git:{testCredentialKey}"] = new GitCredential("different-username", testPassword),
76+
}
77+
};
78+
79+
string[] cmdArgs = {"erase"};
80+
var command = new EraseCommand(providerRegistry);
81+
82+
await command.ExecuteAsync(context, cmdArgs);
83+
84+
Assert.Equal(1, context.CredentialStore.Count);
85+
Assert.True(context.CredentialStore.ContainsKey($"git:{testCredentialKey}"));
86+
}
87+
88+
[Fact]
89+
public async Task EraseCommand_ExecuteAsync_InputUserPass_CredentialExists_PassNotMatch_DoesNothing()
90+
{
91+
const string testUserName = "john.doe";
92+
const string testPassword = "letmein123";
93+
const string testCredentialKey = "test-cred-key";
94+
string stdIn = $"username={testUserName}\npassword={testPassword}\n\n";
95+
96+
var provider = new TestHostProvider {CredentialKey = testCredentialKey};
97+
var providerRegistry = new TestHostProviderRegistry {Provider = provider};
98+
var context = new TestCommandContext
99+
{
100+
StdIn = stdIn,
101+
CredentialStore =
102+
{
103+
[$"git:{testCredentialKey}"] = new GitCredential(testUserName, "different-password"),
104+
}
105+
};
106+
107+
string[] cmdArgs = {"erase"};
108+
var command = new EraseCommand(providerRegistry);
109+
110+
await command.ExecuteAsync(context, cmdArgs);
111+
112+
Assert.Equal(1, context.CredentialStore.Count);
113+
Assert.True(context.CredentialStore.ContainsKey($"git:{testCredentialKey}"));
114+
}
115+
116+
[Fact]
117+
public async Task EraseCommand_ExecuteAsync_InputUserPass_CredentialExists_UserPassMatch_ErasesCredential()
118+
{
119+
const string testUserName = "john.doe";
120+
const string testPassword = "letmein123";
121+
const string testCredentialKey = "test-cred-key";
122+
string stdIn = $"username={testUserName}\npassword={testPassword}\n\n";
123+
124+
var provider = new TestHostProvider {CredentialKey = testCredentialKey};
125+
var providerRegistry = new TestHostProviderRegistry {Provider = provider};
126+
var context = new TestCommandContext
127+
{
128+
StdIn = stdIn,
129+
CredentialStore =
130+
{
131+
[$"git:{testCredentialKey}"] = new GitCredential(testUserName, testPassword),
132+
}
133+
};
134+
135+
string[] cmdArgs = {"erase"};
136+
var command = new EraseCommand(providerRegistry);
137+
138+
await command.ExecuteAsync(context, cmdArgs);
139+
140+
Assert.Equal(0, context.CredentialStore.Count);
141+
Assert.False(context.CredentialStore.ContainsKey($"git:{testCredentialKey}"));
142+
}
143+
60144
[Fact]
61145
public async Task EraseCommand_ExecuteAsync_NoCredential_DoesNothing()
62146
{

0 commit comments

Comments
 (0)