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)); + } + } +}