Skip to content

CustomHeaders: any HTTP content header throws InvalidOperationException ("Misused header name") instead of being applied or ignored #528

Description

@polyglotAI-bot

Describe the bug

Setting any of .NET's known content headers via the public CustomHeaders option — either
per-query (QueryOptions.CustomHeaders) or client-level (ClickHouseClientSettings.CustomHeaders) —
makes every request throw before it is sent:

System.InvalidOperationException: Misused header name, 'Content-Type'. Make sure request headers are
used with HttpRequestMessage, response headers with HttpResponseMessage, and content headers with
HttpContent objects.

The XML docs on both properties say custom headers "are applied after the default headers, allowing
you to override most headers" and that only Connection, Authorization and User-Agent "cannot be
overridden and will be silently ignored". A whole class of header names instead escapes as an
unhandled framework exception, which is neither documented nor caught.

All 11 headers that .NET classifies as content headers reproduce it: Allow, Content-Disposition,
Content-Encoding, Content-Language, Content-Length, Content-Location, Content-MD5,
Content-Range, Content-Type, Expires, Last-Modified. Non-content headers are unaffected
(X-*, Accept-Encoding, Cache-Control, Referer all work).

It affects every entry point that goes through AddDefaultHttpHeaders — verified on
ExecuteScalarAsync, ExecuteNonQueryAsync, ExecuteReaderAsync and ExecuteRawResultAsync,
at both the client and per-query level.

Steps to reproduce

  1. Create a client (or QueryOptions) with a content header in CustomHeaders.
  2. Run any query.
  3. The call throws InvalidOperationException before any request reaches the server.

Expected behaviour

One of the documented outcomes, not an unhandled framework exception:

  • the header is applied, or
  • the header is silently ignored like the other non-overridable headers (and the docs list it), or
  • the option is rejected up front with a clear ArgumentException naming the header and why.

Code example

// per-query
using var client = new ClickHouseClient("Host=localhost");
var options = new QueryOptions
{
    CustomHeaders = new Dictionary<string, string> { ["Content-Type"] = "text/plain" },
};
await client.ExecuteScalarAsync("SELECT 1", options: options);   // throws

// client-level — same result
var settings = new ClickHouseClientSettings("Host=localhost")
{
    CustomHeaders = new Dictionary<string, string> { ["Content-Encoding"] = "gzip" },
};
using var client2 = new ClickHouseClient(settings);
await client2.ExecuteScalarAsync("SELECT 1");                    // throws

Error log

System.InvalidOperationException: Misused header name, 'Content-Encoding'. Make sure request headers
are used with HttpRequestMessage, response headers with HttpResponseMessage, and content headers with
HttpContent objects.

Root cause

ClickHouseClient.ApplyCustomHeaders (ClickHouse.Driver/ClickHouseClient.cs:1124-1137) applies every
non-blocked custom header to the request's HttpRequestHeaders:

if (!IsBlockedHeader(kvp.Key))
{
    requestHeaders.Remove(kvp.Key);                              // <-- throws here
    requestHeaders.TryAddWithoutValidation(kvp.Key, kvp.Value);
}

The throwing call is the Remove, not the add. Probed directly on .NET 10:

  • HttpRequestHeaders.Remove("Content-Type") → throws InvalidOperationException (Contains throws too)
  • HttpRequestHeaders.TryAddWithoutValidation("Content-Type", …) → returns false, no throw

So TryAddWithoutValidation already degrades gracefully; the Remove that was added to give custom
headers "last write wins" semantics is what turns a no-op into an exception.

IsBlockedHeader (ClickHouseClient.cs:1334-1339) only covers Connection, Authorization,
User-Agent.

Suggested fix

Smallest change that matches the documented contract: make the removal tolerant of names that
HttpRequestHeaders refuses to own, so a content header degrades to "silently ignored" like the other
non-overridable headers — e.g. guard the Remove/Contains pair (try/catch on
InvalidOperationException, or a known-content-header check) and let TryAddWithoutValidation's
false be the no-op it already is. Then extend the XML docs on both CustomHeaders properties to say
content headers are not settable through this option.

Contrast cases that must keep their current behavior:

  • non-content custom headers (X-*, Cache-Control, Referer) still override the defaults — the
    Remove must keep working for them;
  • Accept-Encoding via CustomHeaders keeps its documented precedence (it currently overrides the
    client-level value and the driver default, and is in turn outranked by QueryOptions.AcceptEncoding).

Alternatively, throwing an ArgumentException at options validation would also be defensible — it is a
behavior change but a clearer one. Actually routing content headers onto HttpContent.Headers is a
feature rather than a bug fix and is out of scope here.

Configuration

Environment

ClickHouse server

  • ClickHouse Server version: 26.5.1.882
  • Non-default settings: none
  • No tables involved (SELECT 1).

Found by automated analysis of the client while working on an unrelated compression change, and
verified empirically against a live server (not by inspection alone): all 11 content headers were swept
at both the client and per-query level across four entry points, with non-content headers as controls.
No fix is attached — filing for maintainer triage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions