Skip to content

Commit b3df97f

Browse files
fix: Validate quota project header is only set once
1 parent e02d17a commit b3df97f

4 files changed

Lines changed: 93 additions & 28 deletions

File tree

Src/Support/Google.Apis.Auth.Tests/OAuth2/AccessTokenWithHeadersTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,72 @@ public void AccessTokenWithHeaders_WithQuotaProject()
5454
Assert.Single(values);
5555
Assert.Contains("FAKE_QUOTA_PROJECT", values);
5656
}
57+
58+
[Fact]
59+
public void AddHeaders_SucceedsOnRetry()
60+
{
61+
var token = new AccessTokenWithHeaders.Builder { QuotaProject = "RETRY_QUOTA_PROJECT" }.Build("FAKE_TOKEN");
62+
var request = new System.Net.Http.HttpRequestMessage();
63+
64+
// First call (first try)
65+
token.AddHeaders(request);
66+
67+
// Second call (retry) - should not throw and should not duplicate the header
68+
token.AddHeaders(request);
69+
70+
var values = request.Headers.GetValues("x-goog-user-project");
71+
Assert.Single(values);
72+
Assert.Contains("RETRY_QUOTA_PROJECT", values);
73+
}
74+
75+
[Fact]
76+
public void AddHeaders_ConflictThrows()
77+
{
78+
var token = new AccessTokenWithHeaders.Builder { QuotaProject = "NEW_QUOTA_PROJECT" }.Build("FAKE_TOKEN");
79+
var request = new System.Net.Http.HttpRequestMessage();
80+
request.Headers.Add("x-goog-user-project", "OLD_QUOTA_PROJECT");
81+
82+
Assert.Throws<System.InvalidOperationException>(() => token.AddHeaders(request));
83+
}
84+
85+
[Fact]
86+
public void AddHeaders_MatchingSucceeds()
87+
{
88+
var token = new AccessTokenWithHeaders.Builder { QuotaProject = "SAME_QUOTA_PROJECT" }.Build("FAKE_TOKEN");
89+
var request = new System.Net.Http.HttpRequestMessage();
90+
request.Headers.Add("x-goog-user-project", "SAME_QUOTA_PROJECT");
91+
92+
// Should not throw
93+
token.AddHeaders(request);
94+
95+
var values = request.Headers.GetValues("x-goog-user-project");
96+
Assert.Single(values);
97+
Assert.Contains("SAME_QUOTA_PROJECT", values);
98+
}
99+
100+
[Fact]
101+
public void AddHeaders_MultipleExistingValuesThrows()
102+
{
103+
var token = new AccessTokenWithHeaders.Builder { QuotaProject = "NEW_PROJECT" }.Build("TOKEN");
104+
var request = new System.Net.Http.HttpRequestMessage();
105+
request.Headers.Add("x-goog-user-project", "VAL1");
106+
request.Headers.Add("x-goog-user-project", "VAL2");
107+
108+
Assert.Throws<System.InvalidOperationException>(() => token.AddHeaders(request));
109+
}
110+
111+
[Fact]
112+
public void AddHeaders_MultipleIncomingValuesThrows()
113+
{
114+
var headers = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.IReadOnlyList<string>>
115+
{
116+
{ "x-goog-user-project", new System.Collections.Generic.List<string> { "VAL1", "VAL2" }.AsReadOnly() }
117+
};
118+
var token = new AccessTokenWithHeaders("TOKEN", new System.Collections.ObjectModel.ReadOnlyDictionary<string, System.Collections.Generic.IReadOnlyList<string>>(headers));
119+
var request = new System.Net.Http.HttpRequestMessage();
120+
request.Headers.Add("x-goog-user-project", "VAL1");
121+
122+
Assert.Throws<System.InvalidOperationException>(() => token.AddHeaders(request));
123+
}
57124
}
58125
}

Src/Support/Google.Apis.Auth/OAuth2/AccessTokenWithHeaders.cs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ limitations under the License.
1515
*/
1616

1717
using Google.Apis.Util;
18+
using System;
1819
using System.Collections.Generic;
1920
using System.Collections.ObjectModel;
21+
using System.Linq;
22+
using System.Net;
2023
using System.Net.Http;
2124
using System.Net.Http.Headers;
2225

@@ -75,10 +78,31 @@ private AccessTokenWithHeaders(string token, string quotaProject = null)
7578
public void AddHeaders(HttpRequestHeaders requestHeaders)
7679
{
7780
requestHeaders.ThrowIfNull(nameof(requestHeaders));
78-
7981
foreach (var header in Headers)
8082
{
81-
requestHeaders.Add(header.Key, header.Value);
83+
// In the case it's a single value header we will not add it if already present, just validate we match
84+
// what's already there.
85+
if (IsSingleValueHeader(header.Key) && requestHeaders.TryGetValues(header.Key, out var existingValues))
86+
{
87+
ValidateSingleValueHeader(header.Key, existingValues, header.Value);
88+
continue;
89+
}
90+
else
91+
{
92+
requestHeaders.Add(header.Key, header.Value);
93+
}
94+
}
95+
96+
bool IsSingleValueHeader(string key) => key == QuotaProjectHeaderName;
97+
98+
void ValidateSingleValueHeader(string key, IEnumerable<string> existing, IEnumerable<string> incoming)
99+
{
100+
bool isValidInitialization = !existing.Any() && incoming.Count() == 1;
101+
bool isNoChange = existing.Count() == 1 && incoming.Count() == 1 && existing.First() == incoming.First();
102+
if (!(isValidInitialization || isNoChange))
103+
{
104+
throw new InvalidOperationException($"Only a single header value may be specified for key {key}.");
105+
}
82106
}
83107
}
84108

Src/Support/Google.Apis.Core/Http/ConfigurableMessageHandler.cs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ namespace Google.Apis.Http
3838
/// </summary>
3939
public class ConfigurableMessageHandler : DelegatingHandler
4040
{
41-
private const string QuotaProjectHeaderName = "x-goog-user-project";
42-
4341
/// <summary>The class logger.</summary>
4442
private static readonly ILogger Logger = ApplicationContext.Logger.ForType<ConfigurableMessageHandler>();
4543

@@ -464,10 +462,6 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
464462
await interceptor.InterceptAsync(request, cancellationToken).ConfigureAwait(false);
465463
}
466464

467-
// Before having the credential intercept the call, check that quota project hasn't
468-
// been added as a header. Quota project cannot be added except through the credential.
469-
CheckValidAfterInterceptors(request);
470-
471465
await CredentialInterceptAsync(request, cancellationToken).ConfigureAwait(false);
472466

473467
if (loggable)
@@ -657,14 +651,6 @@ bool DisposeAndReturnFalse(IDisposable disposable)
657651
return response;
658652
}
659653

660-
private void CheckValidAfterInterceptors(HttpRequestMessage request)
661-
{
662-
if (request.Headers.Contains(QuotaProjectHeaderName))
663-
{
664-
throw new InvalidOperationException($"{QuotaProjectHeaderName} header can only be added through the credential or through the <Product>ClientBuilder.");
665-
}
666-
}
667-
668654
private async Task CredentialInterceptAsync(HttpRequestMessage request, CancellationToken cancellationToken)
669655
{
670656
var effectiveCredential = GetEffectiveCredential(request);

Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,18 +1100,6 @@ public Task InterceptAsync(HttpRequestMessage request, CancellationToken cancell
11001100
}
11011101
}
11021102

1103-
[Fact]
1104-
public async Task FailsIfQuotaProjectSetWithInterceptors()
1105-
{
1106-
var configurableHandler = new ConfigurableMessageHandler(new HttpClientHandler());
1107-
configurableHandler.AddExecuteInterceptor(new AddsQuotaProject());
1108-
1109-
using (var client = new HttpClient(configurableHandler))
1110-
{
1111-
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetAsync("http://will.be.ignored"));
1112-
}
1113-
}
1114-
11151103
[Fact]
11161104
public async Task AcceptsQuotaProjectFromCredential()
11171105
{

0 commit comments

Comments
 (0)