Skip to content

Commit 7d414f4

Browse files
authored
Add support for httpCookieFile (#1251)
Add [`httpCookieFile`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpcookieFile) support. If `httpCookieFile` is set in `gitconfig`, GCM will add cookie headers to requests, such as OAuth2 requests. This does not add support for [`http.saveCookies`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpsaveCookies).
2 parents b550293 + 3501afd commit 7d414f4

File tree

7 files changed

+288
-0
lines changed

7 files changed

+288
-0
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using GitCredentialManager;
5+
using GitCredentialManager.Tests.Objects;
6+
using Xunit;
7+
8+
namespace Core.Tests;
9+
10+
public class CurlCookieParserTests
11+
{
12+
[Fact]
13+
public void CurlCookieParser_EmptyFile_ReturnsNoCookies()
14+
{
15+
const string content = "";
16+
17+
var trace = new NullTrace();
18+
var parser = new CurlCookieParser(trace);
19+
20+
IList<Cookie> actual = parser.Parse(content);
21+
22+
Assert.Empty(actual);
23+
}
24+
25+
[Fact]
26+
public void CurlCookieParser_Parse_MissingFields_SkipsInvalidLines()
27+
{
28+
const string content =
29+
// Valid cookie
30+
".example.com\tTRUE\t/path/here\tTRUE\t0\tcookie1\tvalue1\n" +
31+
32+
// Missing several fields - not a valid cookie so should be skipped
33+
".example.com\tTRUE\tTRUE\tcookie1\tvalue1\n";
34+
35+
var trace = new NullTrace();
36+
var parser = new CurlCookieParser(trace);
37+
38+
IList<Cookie> actual = parser.Parse(content);
39+
40+
Assert.Equal(1, actual.Count);
41+
AssertCookie(actual[0], ".example.com", "/path/here", true, 0, "cookie1", "value1");
42+
}
43+
44+
[Fact]
45+
public void CurlCookieParser_Parse_MissingFields_ReturnsValidCookiesWithDefaults()
46+
{
47+
const string content =
48+
// Empty path field (default "/")
49+
".example.com\tTRUE\t\tTRUE\t852852\tcookie1\tvalue1\n" +
50+
51+
// Empty expiration field (default 0)
52+
".example.com\tTRUE\t/path/here\tTRUE\t\tcookie1\tvalue1";
53+
54+
var trace = new NullTrace();
55+
var parser = new CurlCookieParser(trace);
56+
57+
IList<Cookie> actual = parser.Parse(content);
58+
59+
Assert.Equal(2, actual.Count);
60+
AssertCookie(actual[0], ".example.com", "/", true, 852852, "cookie1", "value1");
61+
AssertCookie(actual[1], ".example.com", "/path/here", true, 0, "cookie1", "value1");
62+
}
63+
64+
[Fact]
65+
public void CurlCookieParser_Parse_ValidFields_ReturnsValidCookies()
66+
{
67+
const string content =
68+
".example.com\tTRUE\t/path\tTRUE\t0\tcookie1\tvalue1\n" +
69+
".example.com\tfAlSe\t/path\ttRuE\t0\tcookie1\tvalue1\n" +
70+
".example.com\tTRUE\t/path\tTRUE\t0\tcookie1 with spaces\tvalue1 with spaces\n" +
71+
".example.com\tFALSE\t/path\tTRUE\t0\tcookie1\tvalue1\n" +
72+
"example.com\tFALSE\t/path\tTRUE\t0\tcookie1\tvalue1\n" +
73+
"example.com\tTRUE\t/path\tTRUE\t0\tcookie1\tvalue1\n" +
74+
".example.com\tTRUE\t/path\tFALSE\t0\tcookie1\tvalue1\n" +
75+
".example.com\tTRUE\t/path\tFALSE\t401654\tcookie1\tvalue1\n";
76+
77+
var trace = new NullTrace();
78+
var parser = new CurlCookieParser(trace);
79+
80+
IList<Cookie> actual = parser.Parse(content);
81+
82+
Assert.Equal(8, actual.Count);
83+
AssertCookie(actual[0], ".example.com", "/path", true, 0, "cookie1", "value1");
84+
AssertCookie(actual[1], "example.com", "/path", true, 0, "cookie1", "value1");
85+
AssertCookie(actual[2], ".example.com", "/path", true, 0, "cookie1 with spaces", "value1 with spaces");
86+
AssertCookie(actual[3], "example.com", "/path", true, 0, "cookie1", "value1");
87+
AssertCookie(actual[4], "example.com", "/path", true, 0, "cookie1", "value1");
88+
AssertCookie(actual[5], "example.com", "/path", true, 0, "cookie1", "value1");
89+
AssertCookie(actual[6], ".example.com", "/path", false, 0, "cookie1", "value1");
90+
AssertCookie(actual[7], ".example.com", "/path", false, 401654, "cookie1", "value1");
91+
}
92+
93+
[Fact]
94+
public void CurlCookieParser_Parse_Comments_ReturnsCookies()
95+
{
96+
const string content =
97+
// Comment block
98+
"# This is a cookie file with various comments!\n" +
99+
"# Lines starting with # are comments, except those that\n" +
100+
"# start with exactly '#HttpOnly_'.. two #s is a comment still!\n" +
101+
102+
// This is still a comment!
103+
"##HttpOnly_ <-- this is a comment still!\n" +
104+
105+
// Valid line
106+
".example.com\tTRUE\t/\tTRUE\t0\tcookie1\tvalue1\n" +
107+
108+
// Commented out cookie line
109+
"#.example.com\tTRUE\t/\tTRUE\t0\tcookie1\tvalue1\n" +
110+
111+
// Valid cookie but HTTP only
112+
"#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tcookie1\tvalue1\n";
113+
114+
var trace = new NullTrace();
115+
var parser = new CurlCookieParser(trace);
116+
117+
IList<Cookie> actual = parser.Parse(content);
118+
119+
Assert.Equal(2, actual.Count);
120+
AssertCookie(actual[0], ".example.com", "/", true, 0, "cookie1", "value1");
121+
AssertCookie(actual[1], ".example.com", "/", true, 0, "cookie1", "value1");
122+
}
123+
124+
private static void AssertCookie(Cookie cookie, string domain, string path, bool secureOnly, long expires, string name, string value)
125+
{
126+
Assert.Equal(name, cookie.Name);
127+
Assert.Equal(value, cookie.Value);
128+
Assert.Equal(domain, cookie.Domain);
129+
Assert.Equal(path, cookie.Path);
130+
Assert.Equal(secureOnly, cookie.Secure);
131+
Assert.Equal(expires, cookie.Expires.Subtract(DateTime.UnixEpoch).TotalSeconds);
132+
}
133+
}

src/shared/Core.Tests/HttpClientFactoryTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,36 @@ public void HttpClientFactory_GetClient_ChecksCertBundleOnlyIfEnabled(string cus
311311
fileSystemMock.Verify(fs => fs.FileExists(It.IsAny<string>()), expectBundleChecked ? Times.Once : Times.Never);
312312
}
313313

314+
[Theory]
315+
[InlineData(null, false, null)]
316+
[InlineData("~/.git-cookie", true, "# Netscape HTTP Cookie File\n" +
317+
"# https://curl.haxx.se/rfc/cookie_spec.html\n" +
318+
"# This is a generated file! Do not edit.\n" +
319+
"\n" +
320+
".example.com\tTRUE\t/\tTRUE\t0\tcookie1\tvalue1\n" +
321+
".example.com\tTRUE\t/\tTRUE\t0\tcookie2\tvalue2\n" +
322+
"#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tcookie3\tvalue3\n")]
323+
public void HttpClientFactory_GetClient_SetCookieOnlyIfEnabled(string cookieFilePath, bool expectCookieChecked, string cookieFileContent)
324+
{
325+
var fileSystemMock = new Mock<IFileSystem>();
326+
fileSystemMock.Setup(fs => fs.FileExists(It.IsAny<string>())).Returns(true);
327+
if (!string.IsNullOrWhiteSpace(cookieFileContent))
328+
{
329+
fileSystemMock.Setup(fs => fs.ReadAllText(cookieFilePath)).Returns(cookieFileContent);
330+
}
331+
332+
var settings = new TestSettings()
333+
{
334+
CustomCookieFilePath = cookieFilePath
335+
};
336+
337+
var factory = new HttpClientFactory(fileSystemMock.Object, Mock.Of<ITrace>(), Mock.Of<ITrace2>(), settings, new TestStandardStreams());
338+
339+
HttpClient client = factory.CreateClient();
340+
341+
fileSystemMock.Verify(fs => fs.FileExists(It.IsAny<string>()), expectCookieChecked ? Times.AtLeastOnce : Times.Never);
342+
}
343+
314344
private static void AssertDefaultCredentials(ICredentials credentials)
315345
{
316346
var netCred = (NetworkCredential) credentials;

src/shared/Core/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ public static class Http
171171
public const string SslVerify = "sslVerify";
172172
public const string SslCaInfo = "sslCAInfo";
173173
public const string SslAutoClientCert = "sslAutoClientCert";
174+
public const string CookieFile = "cookieFile";
174175
}
175176

176177
public static class Remote

src/shared/Core/CurlCookie.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using GitCredentialManager;
5+
6+
namespace GitCredentialManager
7+
{
8+
public class CurlCookieParser
9+
{
10+
private readonly ITrace _trace;
11+
12+
public CurlCookieParser(ITrace trace)
13+
{
14+
_trace = trace;
15+
}
16+
17+
public IList<Cookie> Parse(string content)
18+
{
19+
if (string.IsNullOrWhiteSpace(content))
20+
{
21+
return Array.Empty<Cookie>();
22+
}
23+
24+
const string HttpOnlyPrefix = "#HttpOnly_";
25+
26+
var cookies = new List<Cookie>();
27+
28+
// Parse the cookie file content
29+
var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
30+
foreach (var line in lines)
31+
{
32+
var parts = line.Split(new[] { '\t' }, StringSplitOptions.None);
33+
if (parts.Length >= 7 && (!parts[0].StartsWith("#") || parts[0].StartsWith(HttpOnlyPrefix)))
34+
{
35+
var domain = parts[0].StartsWith(HttpOnlyPrefix) ? parts[0].Substring(HttpOnlyPrefix.Length) : parts[0];
36+
var includeSubdomains = StringComparer.OrdinalIgnoreCase.Equals(parts[1], "TRUE");
37+
if (!includeSubdomains)
38+
{
39+
domain = domain.TrimStart('.');
40+
}
41+
var path = string.IsNullOrWhiteSpace(parts[2]) ? "/" : parts[2];
42+
var secureOnly = parts[3].Equals("TRUE", StringComparison.OrdinalIgnoreCase);
43+
var expires = ParseExpires(parts[4]);
44+
var name = parts[5];
45+
var value = parts[6];
46+
47+
cookies.Add(new Cookie()
48+
{
49+
Domain = domain,
50+
Path = path,
51+
Expires = expires,
52+
HttpOnly = true,
53+
Secure = secureOnly,
54+
Name = name,
55+
Value = value,
56+
});
57+
}
58+
else
59+
{
60+
_trace.WriteLine($"Invalid cookie line: {line}");
61+
}
62+
}
63+
64+
return cookies;
65+
}
66+
67+
private static DateTime ParseExpires(string expires)
68+
{
69+
#if NETFRAMEWORK
70+
DateTime epoch = new DateTime(1970, 01, 01, 0, 0, 0, DateTimeKind.Utc);
71+
#else
72+
DateTime epoch = DateTime.UnixEpoch;
73+
#endif
74+
75+
if (long.TryParse(expires, out long i))
76+
{
77+
return epoch.AddSeconds(i);
78+
}
79+
80+
return epoch;
81+
}
82+
}
83+
}

src/shared/Core/HttpClientFactory.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,34 @@ public HttpClient CreateClient()
197197
#endif
198198
}
199199

200+
// If CustomCookieFilePath is set, set Cookie header from cookie file, which is written by libcurl
201+
if (!string.IsNullOrWhiteSpace(_settings.CustomCookieFilePath) && _fileSystem.FileExists(_settings.CustomCookieFilePath))
202+
{
203+
// get the filename from gitconfig
204+
string cookieFilePath = _settings.CustomCookieFilePath;
205+
_trace.WriteLine($"Custom cookie file has been enabled with {cookieFilePath}");
206+
207+
// get cookie from cookie file
208+
string cookieFileContents = _fileSystem.ReadAllText(cookieFilePath);
209+
210+
var cookieParser = new CurlCookieParser(_trace);
211+
var cookies = cookieParser.Parse(cookieFileContents);
212+
213+
// Set the cookie
214+
var cookieContainer = new CookieContainer();
215+
foreach (var cookie in cookies)
216+
{
217+
var schema = cookie.Secure ? "https" : "http";
218+
var uri = new UriBuilder(schema, cookie.Domain.TrimStart('.')).Uri;
219+
cookieContainer.Add(uri, new Cookie(cookie.Name, cookie.Value));
220+
}
221+
handler.CookieContainer = cookieContainer;
222+
handler.UseCookies = true;
223+
224+
_trace.WriteLine("Configured to automatically send cookie header.");
225+
226+
}
227+
200228
var client = new HttpClient(handler);
201229

202230
// Add default headers

src/shared/Core/Settings.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ public interface ISettings : IDisposable
154154
/// <remarks>The default value is null if unset.</remarks>
155155
string CustomCertificateBundlePath { get; }
156156

157+
/// <summary>
158+
// Optional path to a file containing one or more cookies.
159+
/// </summary>
160+
/// <remarks>The default value is null if unset.</remarks>
161+
string CustomCookieFilePath { get; }
162+
157163
/// <summary>
158164
/// The SSL/TLS backend.
159165
/// </summary>
@@ -626,6 +632,9 @@ public bool IsCertificateVerificationEnabled
626632
public string CustomCertificateBundlePath =>
627633
TryGetPathSetting(KnownEnvars.GitSslCaInfo, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslCaInfo, out string value) ? value : null;
628634

635+
public string CustomCookieFilePath =>
636+
TryGetPathSetting(null, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.CookieFile, out string value) ? value : null;
637+
629638
public TlsBackend TlsBackend =>
630639
TryGetSetting(null, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslBackend, out string config)
631640
? (Enum.TryParse(config, true, out TlsBackend backend) ? backend : GitCredentialManager.TlsBackend.Other)

src/shared/TestInfrastructure/Objects/TestSettings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public class TestSettings : ISettings
4343

4444
public string CustomCertificateBundlePath { get; set; }
4545

46+
public string CustomCookieFilePath { get; set; }
47+
4648
public TlsBackend TlsBackend { get; set; }
4749

4850
public bool UseCustomCertificateBundleWithSchannel { get; set; }
@@ -175,6 +177,8 @@ ProxyConfiguration ISettings.GetProxyConfiguration()
175177

176178
string ISettings.CustomCertificateBundlePath => CustomCertificateBundlePath;
177179

180+
string ISettings.CustomCookieFilePath => CustomCookieFilePath;
181+
178182
TlsBackend ISettings.TlsBackend => TlsBackend;
179183

180184
bool ISettings.UseCustomCertificateBundleWithSchannel => UseCustomCertificateBundleWithSchannel;

0 commit comments

Comments
 (0)