diff --git a/src/Serilog.Sinks.Grafana.Loki/HttpClients/BaseLokiHttpClient.cs b/src/Serilog.Sinks.Grafana.Loki/HttpClients/BaseLokiHttpClient.cs
index 731da17..77aac17 100644
--- a/src/Serilog.Sinks.Grafana.Loki/HttpClients/BaseLokiHttpClient.cs
+++ b/src/Serilog.Sinks.Grafana.Loki/HttpClients/BaseLokiHttpClient.cs
@@ -33,7 +33,12 @@ public abstract class BaseLokiHttpClient : ILokiHttpClient
///
/// Regex for Tenant ID validation.
///
- private static readonly Regex TenantIdValueRegex = new Regex(@"^[a-zA-Z0-9]*$");
+ private static readonly Regex TenantIdValueRegex = new Regex(@"^(?!.*\.\.)(?!\.$)[a-zA-Z0-9!._*'()\-\u005F]*$", RegexOptions.Compiled);
+
+ ///
+ /// RFC7230 token characters: letters, digits and these symbols: ! # $ % & ' * + - . ^ _ ` | ~
+ ///
+ private static readonly Regex HeaderKeyRegEx = new Regex(@"^[A-Za-z0-9!#$%&'*+\-\.\^_`|~]+$", RegexOptions.Compiled);
///
/// Initializes a new instance of the class.
@@ -91,6 +96,48 @@ public virtual void SetTenant(string? tenant)
headers.Add(TenantHeader, tenant);
}
+ ///
+ /// Sets default headers for the HTTP client.
+ /// Existing headers with the same key will not be overwritten.
+ ///
+ /// A dictionary of headers to set as default.
+ public virtual void SetDefaultHeaders(IDictionary defaultHeaders)
+ {
+
+ if (defaultHeaders == null)
+ {
+ throw new ArgumentNullException(nameof(defaultHeaders), "Default headers cannot be null.");
+ }
+
+ foreach (var header in defaultHeaders)
+ {
+ if (string.IsNullOrWhiteSpace(header.Key))
+ {
+ throw new ArgumentException("Header name cannot be null, empty, or whitespace.", nameof(defaultHeaders));
+ }
+
+ if (!HeaderKeyRegEx.IsMatch(header.Key))
+ {
+ throw new ArgumentException($"Header name '{header.Key}' contains invalid characters.", nameof(defaultHeaders));
+ }
+
+ if (header.Value == null)
+ {
+ throw new ArgumentException($"Header value for '{header.Key}' cannot be null.", nameof(defaultHeaders));
+ }
+
+ if (header.Value.Length == 0)
+ {
+ throw new ArgumentException($"Header value for '{header.Key}' cannot be empty.", nameof(defaultHeaders));
+ }
+
+ if (!HttpClient.DefaultRequestHeaders.Contains(header.Key))
+ {
+ HttpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
+ }
+ }
+ }
+
///
public virtual void Dispose() => HttpClient.Dispose();
diff --git a/test/Serilog.Sinks.Grafana.Loki.Tests/HttpClientsTests/BaseLokiHttpClientTests.cs b/test/Serilog.Sinks.Grafana.Loki.Tests/HttpClientsTests/BaseLokiHttpClientTests.cs
index 1265ac2..8214570 100644
--- a/test/Serilog.Sinks.Grafana.Loki.Tests/HttpClientsTests/BaseLokiHttpClientTests.cs
+++ b/test/Serilog.Sinks.Grafana.Loki.Tests/HttpClientsTests/BaseLokiHttpClientTests.cs
@@ -27,7 +27,7 @@ public void HttpClientShouldBeCreatedIfNotProvider()
[Fact]
public void BasicAuthHeaderShouldBeCorrect()
{
- var credentials = new LokiCredentials {Login = "Billy", Password = "Herrington"};
+ var credentials = new LokiCredentials { Login = "Billy", Password = "Herrington" };
using var client = new TestLokiHttpClient();
client.SetCredentials(credentials);
@@ -48,16 +48,96 @@ public void AuthorizationHeaderShouldNotBeSetWithoutCredentials()
client.Client.DefaultRequestHeaders.Authorization.ShouldBeNull();
}
- [Fact]
- public void TenantHeaderShouldBeCorrect()
+ [Theory]
+ [InlineData("tenant123", true)] // only alphanumeric
+ [InlineData("tenant-123", true)] // allowed hyphen
+ [InlineData("tenant..123", false)] // double period not allowed
+ [InlineData(".", false)] // single period not allowed
+ [InlineData("tenant!_*.123'()", true)] // allowed special characters
+ [InlineData("tenant-123...", false)] // ends with multiple periods
+ [InlineData("tenant123456...test", false)] // ends with period
+ [InlineData("tenant1234567890!@", false)] // '@' is not allowed
+ [InlineData("a", true)] // minimal length
+ [InlineData("tenant_with_underscores", true)] // underscores
+ [InlineData("tenant..", false)] // ends with double period
+ [InlineData("..tenant", false)] // starts with double period
+ [InlineData("tenant-.-test", true)] // single periods inside are ok
+ public void TenantHeaderShouldBeCorrect(string tenantId, bool isValid)
+ {
+ using var client = new TestLokiHttpClient();
+
+ if (isValid)
+ {
+ // Act
+ client.SetTenant(tenantId);
+
+ // Assert header is correctly set
+ var tenantHeaders = client.Client.DefaultRequestHeaders
+ .GetValues("X-Scope-OrgID")
+ .ToList();
+
+ tenantHeaders.ShouldBeEquivalentTo(new List { tenantId });
+ }
+ else
+ {
+ // Act & Assert: invalid tenant IDs throw ArgumentException
+ Should.Throw(() => client.SetTenant(tenantId));
+ }
+ }
+
+ // Allowed special characters
+ [Theory]
+ [InlineData('!', true)]
+ [InlineData('.', true)]
+ [InlineData('_', true)]
+ [InlineData('*', true)]
+ [InlineData('\'', true)]
+ [InlineData('(', true)]
+ [InlineData(')', true)]
+ [InlineData('-', true)]
+
+ // Disallowed special characters
+ [InlineData('@', false)]
+ [InlineData('#', false)]
+ [InlineData('&', false)]
+ [InlineData('$', false)]
+ [InlineData('%', false)]
+ [InlineData('^', false)]
+ [InlineData('=', false)]
+ [InlineData('+', false)]
+ [InlineData('[', false)]
+ [InlineData(']', false)]
+ [InlineData('{', false)]
+ [InlineData('}', false)]
+ [InlineData('<', false)]
+ [InlineData('>', false)]
+ [InlineData('?', false)]
+ [InlineData('/', false)]
+ [InlineData('\\', false)]
+ [InlineData('|', false)]
+ [InlineData('~', false)]
+ [InlineData('"', false)]
+ public void TenantSpecialCharacterShouldValidateCorrectly(char specialChar, bool isValid)
{
- var tenantId = "lokitenant";
using var client = new TestLokiHttpClient();
+ string tenantId = "tenant" + specialChar + "123";
- client.SetTenant(tenantId);
+ if (isValid)
+ {
+ // Should succeed
+ client.SetTenant(tenantId);
- var tenantHeaders = client.Client.DefaultRequestHeaders.GetValues("X-Scope-OrgID").ToList();
- tenantHeaders.ShouldBeEquivalentTo(new List {"lokitenant"});
+ var tenantHeaders = client.Client.DefaultRequestHeaders
+ .GetValues("X-Scope-OrgID")
+ .ToList();
+
+ tenantHeaders.ShouldBeEquivalentTo(new List { tenantId });
+ }
+ else
+ {
+ // Should throw
+ Should.Throw(() => client.SetTenant(tenantId));
+ }
}
[Fact]
@@ -78,4 +158,110 @@ public void TenantHeaderShouldThrowAnExceptionOnTenantIdAgainstRule()
Should.Throw(() => client.SetTenant(tenantId));
}
-}
\ No newline at end of file
+
+ [Theory]
+ [InlineData("Custom-Header", "HeaderValue", true)]
+ [InlineData("X-Test", "12345", true)]
+ [InlineData("X-Correlation-ID", "abcd-1234", true)]
+ [InlineData("X-Feature-Flag", "enabled", true)]
+ [InlineData("", "value", false)]
+ [InlineData(" ", "value", false)]
+ [InlineData(null, "value", false)]
+ [InlineData("Invalid Header", "value", false)]
+ [InlineData("X-Test", "", false)]
+ [InlineData("X-Test", null, false)]
+ public void SetDefaultHeadersShouldValidateCorrectly(string headerKey, string headerValue, bool isValid)
+ {
+ using var httpClient = new HttpClient();
+ var client = new TestLokiHttpClient(httpClient);
+
+ if (isValid)
+ {
+ var headersToSet = new Dictionary
+ {
+ { headerKey, headerValue }
+ };
+
+ client.SetDefaultHeaders(headersToSet);
+
+ httpClient.DefaultRequestHeaders.Contains(headerKey).ShouldBeTrue();
+ httpClient.DefaultRequestHeaders
+ .GetValues(headerKey)
+ .ShouldBe(new[] { headerValue });
+ }
+ else
+ {
+ Should.Throw(() =>
+ {
+ var headersToSet = new Dictionary
+ {
+ { headerKey, headerValue }
+ };
+ client.SetDefaultHeaders(headersToSet);
+ });
+ }
+ }
+
+ [Theory]
+ [InlineData('!', true)]
+ [InlineData('#', true)]
+ [InlineData('$', true)]
+ [InlineData('%', true)]
+ [InlineData('&', true)]
+ [InlineData('\'', true)]
+ [InlineData('*', true)]
+ [InlineData('+', true)]
+ [InlineData('-', true)]
+ [InlineData('.', true)]
+ [InlineData('^', true)]
+ [InlineData('_', true)]
+ [InlineData('`', true)]
+ [InlineData('|', true)]
+ [InlineData('~', true)]
+ [InlineData('A', true)]
+ [InlineData('z', true)]
+ [InlineData(' ', false)]
+ [InlineData('(', false)]
+ [InlineData(')', false)]
+ [InlineData('<', false)]
+ [InlineData('>', false)]
+ [InlineData('@', false)]
+ [InlineData(',', false)]
+ [InlineData(';', false)]
+ [InlineData(':', false)]
+ [InlineData('"', false)]
+ [InlineData('/', false)]
+ [InlineData('[', false)]
+ [InlineData(']', false)]
+ [InlineData('?', false)]
+ [InlineData('=', false)]
+ [InlineData('{', false)]
+ [InlineData('}', false)]
+ [InlineData('\\', false)]
+ [InlineData('\t', false)]
+ public void DefaultHeaderCharactersShouldValidateCorrectly(char character, bool isValid) // Valid token characters according to RFC 7230
+ {
+ using var httpClient = new HttpClient();
+ var client = new TestLokiHttpClient(httpClient);
+
+ string headerKey = "X-Test" + character;
+ var headersToSet = new Dictionary
+ {
+ { headerKey, "value" }
+ };
+
+ if (isValid)
+ {
+ // Should succeed
+ client.SetDefaultHeaders(headersToSet);
+
+ httpClient.DefaultRequestHeaders.Contains(headerKey).ShouldBeTrue();
+ httpClient.DefaultRequestHeaders.GetValues(headerKey).ShouldBe(new[] { "value" });
+ }
+ else
+ {
+ // Should throw exception
+ Should.Throw(() => client.SetDefaultHeaders(headersToSet));
+ }
+ }
+}