Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/docs/configure-headers.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,30 @@ Use the bulk overloads when you already have headers in a collection (e.g. from

:::

## Reserved Headers

FluentHttpClient intentionally restricts a small set of HTTP headers that are controlled by the underlying `HttpClient` and its transport layers. These headers define wire-level framing and routing behavior, and overriding them can produce ambiguous requests, protocol violations, or security issues.

Because of this, the fluent header extensions do **not** allow setting the following headers:

* `Host`
* `Content-Length`
* `Transfer-Encoding`

These values are determined automatically based on the request URI, the configured `HttpContent`, and the negotiated HTTP version. Preventing them from being set through the fluent API helps avoid accidental misuse - such as combining `Content-Length` with `Transfer-Encoding: chunked` - while keeping request construction predictable.

### Advanced Usage

This restriction only applies to the high-level fluent extensions. If advanced scenarios require manual control of these headers, you can still modify the underlying `HttpRequestMessage` using a configuration delegate (for example, via [`When`](./conditional-configuration.md) with an always-true bool or predicate). This opt-in approach allows experienced users to take full control without exposing casual users to common footguns.

In short, the fluent API keeps the safe path safe, while still leaving the door open for expert customization or tom-foolery when needed.

:::tip Indirect Control

When you need indirect control over `Content-Length` or chunked transfer behavior, your lever is [`WithBufferedContent`](./configure-content.md#buffering-request-content). Buffered content *usually* produces a `Content-Length` header, while unbuffered or unknown-length content lets the runtime fall back to chunked transfer for HTTP/1.1. Nevertheless, FluentHttpClient itself **never sets these headers explicitly**.

:::

## Authentication Headers

For authentication, FluentHttpClient provides dedicated extensions that set the `Authorization` header.
Expand Down
81 changes: 73 additions & 8 deletions src/FluentHttpClient.Tests/FluentHeaderExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ public void WithHeader_ThrowsArgumentNullException_WhenKeyIsNull()
ex.ParamName.ShouldBe("key");
}

[Theory]
[InlineData("Host")]
[InlineData("Content-Length")]
[InlineData("Transfer-Encoding")]
public void WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key)
{
var builder = CreateBuilder();

var ex = Should.Throw<ArgumentException>(() =>
builder.WithHeader(key, "value"));

ex.ParamName.ShouldBe("key");
}

[Fact]
public void WithHeader_ThrowsArgumentNullException_WhenValueIsNull()
{
Expand Down Expand Up @@ -95,6 +109,21 @@ public void WithHeader_ThrowsArgumentNullException_WhenKeyIsNull()
ex.ParamName.ShouldBe("key");
}

[Theory]
[InlineData("Host")]
[InlineData("Content-Length")]
[InlineData("Transfer-Encoding")]
public void WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key)
{
var builder = CreateBuilder();
var values = new[] { "one", "two" };

var ex = Should.Throw<ArgumentException>(() =>
builder.WithHeader(key, values));

ex.ParamName.ShouldBe("key");
}

[Fact]
public void WithHeader_ThrowsArgumentNullException_WhenValuesIsNull()
{
Expand Down Expand Up @@ -175,8 +204,27 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull()
var ex = Should.Throw<ArgumentException>(() =>
configurator(message.Headers));

ex.ParamName.ShouldBe("headers");
ex.Message.ShouldStartWith("Header name cannot be null.");
ex.ParamName.ShouldBe("key");
}

[Theory]
[InlineData("Host")]
[InlineData("Content-Length")]
[InlineData("Transfer-Encoding")]
public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key)
{
var builder = CreateBuilder();
var headers = new[]
{
new KeyValuePair<string, string>(key, "1")
};

builder.WithHeaders(headers);

var ex = await Should.ThrowAsync<ArgumentException>(async () =>
await builder.BuildRequest(HttpMethod.Get, CancellationToken.None));

ex.ParamName.ShouldBe("key");
}

[Fact]
Expand All @@ -195,8 +243,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderValueIsNull()
var ex = Should.Throw<ArgumentException>(() =>
configurator(message.Headers));

ex.ParamName.ShouldBe("headers");
ex.Message.ShouldStartWith("Header values for 'X-One' cannot be null.");
ex.ParamName.ShouldBe("value");
}
}

Expand Down Expand Up @@ -252,6 +299,26 @@ public void WithHeaders_ThrowsArgumentNullException_WhenHeadersIsNull()
ex.ParamName.ShouldBe("headers");
}

[Theory]
[InlineData("Host")]
[InlineData("Content-Length")]
[InlineData("Transfer-Encoding")]
public async Task WithHeader_ThrowsArgumentException_WhenKeyIsReserved(string key)
{
var builder = CreateBuilder();
var headers = new[]
{
new KeyValuePair<string, IEnumerable<string>>(key, new[] { "1" })
};

builder.WithHeaders(headers);

var ex = await Should.ThrowAsync<ArgumentException>(async () =>
await builder.BuildRequest(HttpMethod.Get, CancellationToken.None));

ex.ParamName.ShouldBe("key");
}

[Fact]
public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull()
{
Expand All @@ -268,8 +335,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderKeyIsNull()
var ex = Should.Throw<ArgumentException>(() =>
configurator(message.Headers));

ex.ParamName.ShouldBe("headers");
ex.Message.ShouldStartWith("Header name cannot be null.");
ex.ParamName.ShouldBe("key");
}

[Fact]
Expand All @@ -288,8 +354,7 @@ public void WithHeaders_ThrowsArgumentException_WhenHeaderValuesIsNull()
var ex = Should.Throw<ArgumentException>(() =>
configurator(message.Headers));

ex.ParamName.ShouldBe("headers");
ex.Message.ShouldStartWith("Header values for 'X-One' cannot be null.");
ex.ParamName.ShouldBe("values");
}
}

Expand Down
50 changes: 36 additions & 14 deletions src/FluentHttpClient/FluentHeaderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ namespace FluentHttpClient;
/// </summary>
public static class FluentHeaderExtensions
{
private static readonly HashSet<string> _reservedHeaders =
new(StringComparer.OrdinalIgnoreCase)
{
"Host",
"Content-Length",
"Transfer-Encoding"
};

/// <summary>
/// Adds the specified header and its value to the request.
/// </summary>
Expand All @@ -16,6 +24,14 @@ public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, str
Guard.AgainstNull(key, nameof(key));
Guard.AgainstNull(value, nameof(value));

if (_reservedHeaders.Contains(key))
{
throw new ArgumentException(
$"Header '{key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " +
"Configure the request URI or content instead.",
nameof(key));
}

builder.HeaderConfigurators.Add(target =>
target.TryAddWithoutValidation(key, value));

Expand All @@ -33,6 +49,14 @@ public static HttpRequestBuilder WithHeader(this HttpRequestBuilder builder, str
Guard.AgainstNull(key, nameof(key));
Guard.AgainstNull(values, nameof(values));

if (_reservedHeaders.Contains(key))
{
throw new ArgumentException(
$"Header '{key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " +
"Configure the request URI or content instead.",
nameof(key));
}

builder.HeaderConfigurators.Add(target =>
target.TryAddWithoutValidation(key, values));

Expand All @@ -52,16 +76,15 @@ public static HttpRequestBuilder WithHeaders(this HttpRequestBuilder builder, IE
{
foreach (var header in headers)
{
if (header.Key is null)
{
throw new ArgumentException("Header name cannot be null.", nameof(headers));
}
Guard.AgainstNull(header.Key, "key");
Guard.AgainstNull(header.Value, "value");

if (header.Value is null)
if (_reservedHeaders.Contains(header.Key))
{
throw new ArgumentException(
$"Header values for '{header.Key}' cannot be null.",
nameof(headers));
$"Header '{header.Key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " +
"Configure the request URI or content instead.",
"key");
}

target.TryAddWithoutValidation(header.Key, header.Value);
Expand All @@ -86,16 +109,15 @@ public static HttpRequestBuilder WithHeaders(
{
foreach (var header in headers)
{
if (header.Key is null)
{
throw new ArgumentException("Header name cannot be null.", nameof(headers));
}
Guard.AgainstNull(header.Key, "key");
Guard.AgainstNull(header.Value, "values");

if (header.Value is null)
if (_reservedHeaders.Contains(header.Key))
{
throw new ArgumentException(
$"Header values for '{header.Key}' cannot be null.",
nameof(headers));
$"Header '{header.Key}' is managed by HttpClient/HttpContent and cannot be set using FluentHttpClient. " +
"Configure the request URI or content instead.",
"key");
}

target.TryAddWithoutValidation(header.Key, header.Value);
Expand Down
2 changes: 1 addition & 1 deletion src/version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "5.0.0-rc1",
"version": "5.0.0-rc2",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/v\\d+(?:\\.\\d+)?$"
Expand Down