Skip to content

Commit c44b7d4

Browse files
smolo-desmolny-marko
andauthored
~fix tenant regex validation (#304)
* ~fix tenant regex validation +add method to set defaultrequestheader +add testcases * ~ change test to inlinedata * +add validation of header keys and new testcases ~ change testcases to inlinedata * ~ correct xml documentation failures and bracing and opening brace --------- Co-authored-by: smolny-marko <[email protected]>
1 parent a954321 commit c44b7d4

2 files changed

Lines changed: 241 additions & 9 deletions

File tree

src/Serilog.Sinks.Grafana.Loki/HttpClients/BaseLokiHttpClient.cs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ public abstract class BaseLokiHttpClient : ILokiHttpClient
3333
/// <summary>
3434
/// Regex for Tenant ID validation.
3535
/// </summary>
36-
private static readonly Regex TenantIdValueRegex = new Regex(@"^[a-zA-Z0-9]*$");
36+
private static readonly Regex TenantIdValueRegex = new(@"^(?!.*\.\.)(?!\.$)[a-zA-Z0-9!._*'()\-\u005F]*$", RegexOptions.Compiled);
37+
38+
/// <summary>
39+
/// RFC7230 token characters: letters, digits and these symbols: ! # $ % &amp; ' * + - . ^ _ ` | ~
40+
/// </summary>
41+
private static readonly Regex HeaderKeyRegEx = new(@"^[A-Za-z0-9!#$%&'*+\-\.\^_`|~]+$", RegexOptions.Compiled);
3742

3843
/// <summary>
3944
/// Initializes a new instance of the <see cref="BaseLokiHttpClient"/> class.
@@ -91,6 +96,47 @@ public virtual void SetTenant(string? tenant)
9196
headers.Add(TenantHeader, tenant);
9297
}
9398

99+
/// <summary>
100+
/// Sets default headers for the HTTP client.
101+
/// Existing headers with the same key will not be overwritten.
102+
/// </summary>
103+
/// <param name="defaultHeaders">A dictionary of headers to set as default.</param>
104+
public virtual void SetDefaultHeaders(IDictionary<string, string> defaultHeaders)
105+
{
106+
if (defaultHeaders == null)
107+
{
108+
throw new ArgumentNullException(nameof(defaultHeaders), "Default headers cannot be null.");
109+
}
110+
111+
foreach (var header in defaultHeaders)
112+
{
113+
if (string.IsNullOrWhiteSpace(header.Key))
114+
{
115+
throw new ArgumentException("Header name cannot be null, empty, or whitespace.", nameof(defaultHeaders));
116+
}
117+
118+
if (!HeaderKeyRegEx.IsMatch(header.Key))
119+
{
120+
throw new ArgumentException($"Header name '{header.Key}' contains invalid characters.", nameof(defaultHeaders));
121+
}
122+
123+
if (header.Value == null)
124+
{
125+
throw new ArgumentException($"Header value for '{header.Key}' cannot be null.", nameof(defaultHeaders));
126+
}
127+
128+
if (header.Value.Length == 0)
129+
{
130+
throw new ArgumentException($"Header value for '{header.Key}' cannot be empty.", nameof(defaultHeaders));
131+
}
132+
133+
if (!HttpClient.DefaultRequestHeaders.Contains(header.Key))
134+
{
135+
HttpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
136+
}
137+
}
138+
}
139+
94140
/// <inheritdoc/>
95141
public virtual void Dispose() => HttpClient.Dispose();
96142

test/Serilog.Sinks.Grafana.Loki.Tests/HttpClientsTests/BaseLokiHttpClientTests.cs

Lines changed: 194 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public void HttpClientShouldBeCreatedIfNotProvider()
2727
[Fact]
2828
public void BasicAuthHeaderShouldBeCorrect()
2929
{
30-
var credentials = new LokiCredentials {Login = "Billy", Password = "Herrington"};
30+
var credentials = new LokiCredentials { Login = "Billy", Password = "Herrington" };
3131
using var client = new TestLokiHttpClient();
3232

3333
client.SetCredentials(credentials);
@@ -48,16 +48,96 @@ public void AuthorizationHeaderShouldNotBeSetWithoutCredentials()
4848
client.Client.DefaultRequestHeaders.Authorization.ShouldBeNull();
4949
}
5050

51-
[Fact]
52-
public void TenantHeaderShouldBeCorrect()
51+
[Theory]
52+
[InlineData("tenant123", true)] // only alphanumeric
53+
[InlineData("tenant-123", true)] // allowed hyphen
54+
[InlineData("tenant..123", false)] // double period not allowed
55+
[InlineData(".", false)] // single period not allowed
56+
[InlineData("tenant!_*.123'()", true)] // allowed special characters
57+
[InlineData("tenant-123...", false)] // ends with multiple periods
58+
[InlineData("tenant123456...test", false)] // ends with period
59+
[InlineData("tenant1234567890!@", false)] // '@' is not allowed
60+
[InlineData("a", true)] // minimal length
61+
[InlineData("tenant_with_underscores", true)] // underscores
62+
[InlineData("tenant..", false)] // ends with double period
63+
[InlineData("..tenant", false)] // starts with double period
64+
[InlineData("tenant-.-test", true)] // single periods inside are ok
65+
public void TenantHeaderShouldBeCorrect(string tenantId, bool isValid)
66+
{
67+
using var client = new TestLokiHttpClient();
68+
69+
if (isValid)
70+
{
71+
// Act
72+
client.SetTenant(tenantId);
73+
74+
// Assert header is correctly set
75+
var tenantHeaders = client.Client.DefaultRequestHeaders
76+
.GetValues("X-Scope-OrgID")
77+
.ToList();
78+
79+
tenantHeaders.ShouldBeEquivalentTo(new List<string> { tenantId });
80+
}
81+
else
82+
{
83+
// Act & Assert: invalid tenant IDs throw ArgumentException
84+
Should.Throw<ArgumentException>(() => client.SetTenant(tenantId));
85+
}
86+
}
87+
88+
// Allowed special characters
89+
[Theory]
90+
[InlineData('!', true)]
91+
[InlineData('.', true)]
92+
[InlineData('_', true)]
93+
[InlineData('*', true)]
94+
[InlineData('\'', true)]
95+
[InlineData('(', true)]
96+
[InlineData(')', true)]
97+
[InlineData('-', true)]
98+
99+
// Disallowed special characters
100+
[InlineData('@', false)]
101+
[InlineData('#', false)]
102+
[InlineData('&', false)]
103+
[InlineData('$', false)]
104+
[InlineData('%', false)]
105+
[InlineData('^', false)]
106+
[InlineData('=', false)]
107+
[InlineData('+', false)]
108+
[InlineData('[', false)]
109+
[InlineData(']', false)]
110+
[InlineData('{', false)]
111+
[InlineData('}', false)]
112+
[InlineData('<', false)]
113+
[InlineData('>', false)]
114+
[InlineData('?', false)]
115+
[InlineData('/', false)]
116+
[InlineData('\\', false)]
117+
[InlineData('|', false)]
118+
[InlineData('~', false)]
119+
[InlineData('"', false)]
120+
public void TenantSpecialCharacterShouldValidateCorrectly(char specialChar, bool isValid)
53121
{
54-
var tenantId = "lokitenant";
55122
using var client = new TestLokiHttpClient();
123+
string tenantId = "tenant" + specialChar + "123";
56124

57-
client.SetTenant(tenantId);
125+
if (isValid)
126+
{
127+
// Should succeed
128+
client.SetTenant(tenantId);
58129

59-
var tenantHeaders = client.Client.DefaultRequestHeaders.GetValues("X-Scope-OrgID").ToList();
60-
tenantHeaders.ShouldBeEquivalentTo(new List<string> {"lokitenant"});
130+
var tenantHeaders = client.Client.DefaultRequestHeaders
131+
.GetValues("X-Scope-OrgID")
132+
.ToList();
133+
134+
tenantHeaders.ShouldBeEquivalentTo(new List<string> { tenantId });
135+
}
136+
else
137+
{
138+
// Should throw
139+
Should.Throw<ArgumentException>(() => client.SetTenant(tenantId));
140+
}
61141
}
62142

63143
[Fact]
@@ -78,4 +158,110 @@ public void TenantHeaderShouldThrowAnExceptionOnTenantIdAgainstRule()
78158

79159
Should.Throw<ArgumentException>(() => client.SetTenant(tenantId));
80160
}
81-
}
161+
162+
[Theory]
163+
[InlineData("Custom-Header", "HeaderValue", true)]
164+
[InlineData("X-Test", "12345", true)]
165+
[InlineData("X-Correlation-ID", "abcd-1234", true)]
166+
[InlineData("X-Feature-Flag", "enabled", true)]
167+
[InlineData("", "value", false)]
168+
[InlineData(" ", "value", false)]
169+
[InlineData(null, "value", false)]
170+
[InlineData("Invalid Header", "value", false)]
171+
[InlineData("X-Test", "", false)]
172+
[InlineData("X-Test", null, false)]
173+
public void SetDefaultHeadersShouldValidateCorrectly(string? headerKey, string? headerValue, bool isValid)
174+
{
175+
using var httpClient = new HttpClient();
176+
var client = new TestLokiHttpClient(httpClient);
177+
178+
if (isValid)
179+
{
180+
var headersToSet = new Dictionary<string, string>
181+
{
182+
{ headerKey!, headerValue! }
183+
};
184+
185+
client.SetDefaultHeaders(headersToSet);
186+
187+
httpClient.DefaultRequestHeaders.Contains(headerKey!).ShouldBeTrue();
188+
httpClient.DefaultRequestHeaders
189+
.GetValues(headerKey!)
190+
.ShouldBe(new[] { headerValue });
191+
}
192+
else
193+
{
194+
Should.Throw<ArgumentException>(() =>
195+
{
196+
var headersToSet = new Dictionary<string, string>
197+
{
198+
{ headerKey!, headerValue! }
199+
};
200+
client.SetDefaultHeaders(headersToSet);
201+
});
202+
}
203+
}
204+
205+
[Theory]
206+
[InlineData('!', true)]
207+
[InlineData('#', true)]
208+
[InlineData('$', true)]
209+
[InlineData('%', true)]
210+
[InlineData('&', true)]
211+
[InlineData('\'', true)]
212+
[InlineData('*', true)]
213+
[InlineData('+', true)]
214+
[InlineData('-', true)]
215+
[InlineData('.', true)]
216+
[InlineData('^', true)]
217+
[InlineData('_', true)]
218+
[InlineData('`', true)]
219+
[InlineData('|', true)]
220+
[InlineData('~', true)]
221+
[InlineData('A', true)]
222+
[InlineData('z', true)]
223+
[InlineData(' ', false)]
224+
[InlineData('(', false)]
225+
[InlineData(')', false)]
226+
[InlineData('<', false)]
227+
[InlineData('>', false)]
228+
[InlineData('@', false)]
229+
[InlineData(',', false)]
230+
[InlineData(';', false)]
231+
[InlineData(':', false)]
232+
[InlineData('"', false)]
233+
[InlineData('/', false)]
234+
[InlineData('[', false)]
235+
[InlineData(']', false)]
236+
[InlineData('?', false)]
237+
[InlineData('=', false)]
238+
[InlineData('{', false)]
239+
[InlineData('}', false)]
240+
[InlineData('\\', false)]
241+
[InlineData('\t', false)]
242+
public void DefaultHeaderCharactersShouldValidateCorrectly(char character, bool isValid) // Valid token characters according to RFC 7230
243+
{
244+
using var httpClient = new HttpClient();
245+
var client = new TestLokiHttpClient(httpClient);
246+
247+
string headerKey = "X-Test" + character;
248+
var headersToSet = new Dictionary<string, string>
249+
{
250+
{ headerKey, "value" }
251+
};
252+
253+
if (isValid)
254+
{
255+
// Should succeed
256+
client.SetDefaultHeaders(headersToSet);
257+
258+
httpClient.DefaultRequestHeaders.Contains(headerKey).ShouldBeTrue();
259+
httpClient.DefaultRequestHeaders.GetValues(headerKey).ShouldHaveSingleItem().ShouldBe("value");
260+
}
261+
else
262+
{
263+
// Should throw exception
264+
Should.Throw<ArgumentException>(() => client.SetDefaultHeaders(headersToSet));
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)