← eval4_submission_v1b

fastapi_15589

resolved RESOLVED PASS · 22 tool calls · 82 s · fastapi/fastapi

Task input

♻️ Do not accept underscore headers when using `convert_underscores=True` (the default)

## Pull Request

<!--
Please start with a GitHub Discussion.

Once a team member asks you to open a PR, create it and link the discussion here.

Obvious typo fixes can be made in a PR without starting a discussion.
-->

Discussion: <!-- Link to the GitHub Discussion -->

## Description

♻️ Do not accept underscore headers when using `convert_underscores=True` (the default)

This means it would only accept header names separated with hyphens by default, and to accept header names separated with underscores, an app would need to explicitly set `convert_underscores=False`.

<!-- Write the description of your PR here -->

## AI Disclaimer

<!-- If using AI, write here the prompt and model used -->

<details>
<summary>AI transcript</summary>

<!-- Paste here the entire AI transcript -->

</details>

## Checklist

- [ ] This PR is an obvious typo fix, or it links to a GitHub Discussion for the proposed code change.
- [ ] I added tests for the change.
- [ ] The new or updated tests fail on the main branch and pass on this PR.
- [ ] Coverage stays at 100%.
- [ ] The documentation explains the change if needed.

Tool calls (23)

#ToolArgumentsResult
1run_command
{
 "command": "grep -rn \"convert_underscores\" ."
}
{"status": "ok", "stdout": "./fastapi/dependencies/utils.py:536: if not field_info.alias and getattr(field_info, \"convert_underscores\", None):\n./fastapi/depe
full
{"status": "ok", "stdout": "./fastapi/dependencies/utils.py:536:        if not field_info.alias and getattr(field_info, \"convert_underscores\", None):\n./fastapi/dependencies/utils.py:797:    default_convert_underscores = True\n./fastapi/dependencies/utils.py:803:        # If headers are in a Pydantic model, the way to disable convert_underscores\n./fastapi/dependencies/utils.py:804:        # would be with Header(convert_underscores=False) at the Pydantic model level\n./fastapi/dependencies/utils.py:805:        default_convert_underscores = getattr(\n./fastapi/dependencies/utils.py:806:            first_field.field_info, \"convert_underscores\", True\n./fastapi/dependencies/utils.py:817:            # doesn't have a FieldInfo of type Header with the default convert_underscores=True\n./fastapi/dependencies/utils.py:818:            convert_underscores = getattr(\n./fastapi/dependencies/utils.py:819:                field.field_info, \"convert_underscores\", default_convert_underscores\n./fastapi/dependencies/utils.py:821:            if convert_underscores:\n./fastapi/param_functions.py:761:    convert_underscores: Annotated[\n./fastapi/param_functions.py:991:        convert_underscores=convert_underscores,\n./fastapi/params.py:316:        convert_underscores: bool = True,\n./fastapi/params.py:352:        self.convert_underscores = convert_underscores\n./fastapi/openapi/utils.py:128:    default_convert_underscores = True\n./fastapi/openapi/utils.py:132:            default_convert_underscores = getattr(\n./fastapi/openapi/utils.py:133:                first_field.field_info, \"convert_underscores\", True\n./fastapi/openapi/utils.py:148:            convert_underscores = getattr(\n./fastapi/openapi/utils.py:150:                \"convert_underscores\",\n./fastapi/openapi/utils.py:151:                default_convert_underscores,\n./fastapi/openapi/utils.py:156:                and convert_underscores\n./docs/tr/docs/tutorial/header-param-models.md:66:`convert_underscores` de\u
2read_file
{
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 790,
 "end_line": 830
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "\n if not fields:\n return values, errors\n\n first_field = fields[0]\n fields_to_extr
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "\n    if not fields:\n        return values, errors\n\n    first_field = fields[0]\n    fields_to_extract = fields\n    single_not_embedded_field = False\n    default_convert_underscores = True\n    if len(fields) == 1 and lenient_issubclass(\n        first_field.field_info.annotation, BaseModel\n    ):\n        fields_to_extract = get_cached_model_fields(first_field.field_info.annotation)\n        single_not_embedded_field = True\n        # If headers are in a Pydantic model, the way to disable convert_underscores\n        # would be with Header(convert_underscores=False) at the Pydantic model level\n        default_convert_underscores = getattr(\n            first_field.field_info, \"convert_underscores\", True\n        )\n\n    params_to_process: dict[str, Any] = {}\n\n    processed_keys = set()\n\n    for field in fields_to_extract:\n        alias = None\n        if isinstance(received_params, Headers):\n            # Handle fields extracted from a Pydantic Model for a header, each field\n            # doesn't have a FieldInfo of type Header with the default convert_underscores=True\n            convert_underscores = getattr(\n                field.field_info, \"convert_underscores\", default_convert_underscores\n            )\n            if convert_underscores:\n                alias = get_validation_alias(field)\n                if alias == field.name:\n                    alias = alias.replace(\"_\", \"-\")\n        value = _get_multidict_value(field, received_params, alias=alias)\n        if value is not None:\n            params_to_process[get_validation_alias(field)] = value\n        processed_keys.add(alias or get_validation_alias(field))\n\n    for key in received_params.keys():\n", "start_line": 790, "end_line": 830, "total_lines": 1057, "is_truncated": false}
3read_file
{
 "end_line": 550,
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 520
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": " # It's a field_info, not a dependency\n if field_info is not None:\n # Handle field_i
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "    # It's a field_info, not a dependency\n    if field_info is not None:\n        # Handle field_info.in_\n        if is_path_param:\n            assert isinstance(field_info, params.Path), (\n                f\"Cannot use `{field_info.__class__.__name__}` for path param\"\n                f\" {param_name!r}\"\n            )\n        elif (\n            isinstance(field_info, params.Param)\n            and getattr(field_info, \"in_\", None) is None\n        ):\n            field_info.in_ = params.ParamTypes.query\n        use_annotation_from_field_info = use_annotation\n        if isinstance(field_info, params.Form):\n            ensure_multipart_is_installed()\n        if not field_info.alias and getattr(field_info, \"convert_underscores\", None):\n            alias = param_name.replace(\"_\", \"-\")\n        else:\n            alias = field_info.alias or param_name\n        field_info.alias = alias\n        field = create_model_field(\n            name=param_name,\n            type_=use_annotation_from_field_info,\n            default=field_info.default,\n            alias=alias,\n            field_info=field_info,\n        )\n        if is_path_param:\n            assert is_scalar_field(field=field), (\n                \"Path params must be of one of the supported types\"\n", "start_line": 520, "end_line": 550, "total_lines": 1057, "is_truncated": false}
4run_command
{
 "command": "grep -rn \"Header\" fastapi/params.py"
}
{"status": "ok", "stdout": "303:class Header(Param): # type: ignore[misc]\n", "exit_code": 0}
full
{"status": "ok", "stdout": "303:class Header(Param):  # type: ignore[misc]\n", "exit_code": 0}
5read_file
{
 "end_line": 360,
 "filepath": "fastapi/params.py",
 "start_line": 300
}
{"status": "ok", "filepath": "fastapi/params.py", "content": " )\n\n\nclass Header(Param): # type: ignore[misc]\n in_ = ParamTypes.header\n\n def __init__(\n se
full
{"status": "ok", "filepath": "fastapi/params.py", "content": "        )\n\n\nclass Header(Param):  # type: ignore[misc]\n    in_ = ParamTypes.header\n\n    def __init__(\n        self,\n        default: Any = Undefined,\n        *,\n        default_factory: Callable[[], Any] | None = _Unset,\n        annotation: Any | None = None,\n        alias: str | None = None,\n        alias_priority: int | None = _Unset,\n        validation_alias: str | AliasPath | AliasChoices | None = None,\n        serialization_alias: str | None = None,\n        convert_underscores: bool = True,\n        title: str | None = None,\n        description: str | None = None,\n        gt: float | None = None,\n        ge: float | None = None,\n        lt: float | None = None,\n        le: float | None = None,\n        min_length: int | None = None,\n        max_length: int | None = None,\n        pattern: str | None = None,\n        regex: Annotated[\n            str | None,\n            deprecated(\n                \"Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead.\"\n            ),\n        ] = None,\n        discriminator: str | None = None,\n        strict: bool | None = _Unset,\n        multiple_of: float | None = _Unset,\n        allow_inf_nan: bool | None = _Unset,\n        max_digits: int | None = _Unset,\n        decimal_places: int | None = _Unset,\n        examples: list[Any] | None = None,\n        example: Annotated[\n            Any | None,\n            deprecated(\n                \"Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, \"\n                \"although still supported. Use examples instead.\"\n            ),\n        ] = _Unset,\n        openapi_examples: dict[str, Example] | None = None,\n        deprecated: deprecated | str | bool | None = None,\n        include_in_schema: bool = True,\n        json_schema_extra: dict[str, Any] | None = None,\n        **extra: Any,\n    ):\n        self.convert_underscores = convert_underscores\n      
6read_file
{
 "end_line": 840,
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 810
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "\n processed_keys = set()\n\n for field in fields_to_extract:\n alias = None\n if isin
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "\n    processed_keys = set()\n\n    for field in fields_to_extract:\n        alias = None\n        if isinstance(received_params, Headers):\n            # Handle fields extracted from a Pydantic Model for a header, each field\n            # doesn't have a FieldInfo of type Header with the default convert_underscores=True\n            convert_underscores = getattr(\n                field.field_info, \"convert_underscores\", default_convert_underscores\n            )\n            if convert_underscores:\n                alias = get_validation_alias(field)\n                if alias == field.name:\n                    alias = alias.replace(\"_\", \"-\")\n        value = _get_multidict_value(field, received_params, alias=alias)\n        if value is not None:\n            params_to_process[get_validation_alias(field)] = value\n        processed_keys.add(alias or get_validation_alias(field))\n\n    for key in received_params.keys():\n        if key not in processed_keys:\n            if isinstance(received_params, (ImmutableMultiDict, Headers)):\n                value = received_params.getlist(key)\n                if isinstance(value, list) and (len(value) == 1):\n                    params_to_process[key] = value[0]\n                else:\n                    params_to_process[key] = value\n            else:\n                params_to_process[key] = received_params.get(key)\n\n", "start_line": 810, "end_line": 840, "total_lines": 1057, "is_truncated": false}
7read_file
{
 "end_line": 540,
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 530
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": " and getattr(field_info, \"in_\", None) is None\n ):\n field_info.in_ = params.ParamTy
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "            and getattr(field_info, \"in_\", None) is None\n        ):\n            field_info.in_ = params.ParamTypes.query\n        use_annotation_from_field_info = use_annotation\n        if isinstance(field_info, params.Form):\n            ensure_multipart_is_installed()\n        if not field_info.alias and getattr(field_info, \"convert_underscores\", None):\n            alias = param_name.replace(\"_\", \"-\")\n        else:\n            alias = field_info.alias or param_name\n        field_info.alias = alias\n", "start_line": 530, "end_line": 540, "total_lines": 1057, "is_truncated": false}
8run_command
{
 "command": "grep -rn \"Header\" fastapi/dependencies/utils.py"
}
{"status": "ok", "stdout": "69: Headers,\n760: and isinstance(values, (ImmutableMultiDict, Headers))\n786: received_params: Mapping[str, Any] | QueryParams | He
full
{"status": "ok", "stdout": "69:    Headers,\n760:        and isinstance(values, (ImmutableMultiDict, Headers))\n786:    received_params: Mapping[str, Any] | QueryParams | Headers,\n804:        # would be with Header(convert_underscores=False) at the Pydantic model level\n815:        if isinstance(received_params, Headers):\n817:            # doesn't have a FieldInfo of type Header with the default convert_underscores=True\n832:            if isinstance(received_params, (ImmutableMultiDict, Headers)):\n", "exit_code": 0}
9run_command
{
 "command": "grep -rn \"Headers\" fastapi/dependencies/utils.py"
}
{"status": "ok", "stdout": "69: Headers,\n760: and isinstance(values, (ImmutableMultiDict, Headers))\n786: received_params: Mapping[str, Any] | QueryParams | He
full
{"status": "ok", "stdout": "69:    Headers,\n760:        and isinstance(values, (ImmutableMultiDict, Headers))\n786:    received_params: Mapping[str, Any] | QueryParams | Headers,\n815:        if isinstance(received_params, Headers):\n832:            if isinstance(received_params, (ImmutableMultiDict, Headers)):\n", "exit_code": 0}
10read_file
{
 "end_line": 100,
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 1
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "import dataclasses\nimport inspect\nimport sys\nfrom collections.abc import (\n AsyncG
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "import dataclasses\nimport inspect\nimport sys\nfrom collections.abc import (\n    AsyncGenerator,\n    AsyncIterable,\n    AsyncIterator,\n    Callable,\n    Generator,\n    Iterable,\n    Iterator,\n    Mapping,\n    Sequence,\n)\nfrom contextlib import AsyncExitStack, contextmanager\nfrom copy import copy, deepcopy\nfrom dataclasses import dataclass\nfrom typing import (\n    Annotated,\n    Any,\n    ForwardRef,\n    Literal,\n    Union,\n    cast,\n    get_args,\n    get_origin,\n)\n\nfrom fastapi import params\nfrom fastapi._compat import (\n    ModelField,\n    RequiredParam,\n    Undefined,\n    copy_field_info,\n    create_body_model,\n    evaluate_forwardref,\n    field_annotation_is_scalar,\n    field_annotation_is_scalar_sequence,\n    field_annotation_is_sequence,\n    get_cached_model_fields,\n    get_missing_field_error,\n    is_bytes_or_nonable_bytes_annotation,\n    is_bytes_sequence_annotation,\n    is_scalar_field,\n    is_uploadfile_or_nonable_uploadfile_annotation,\n    is_uploadfile_sequence_annotation,\n    lenient_issubclass,\n    sequence_types,\n    serialize_sequence_value,\n    value_is_sequence,\n)\nfrom fastapi.background import BackgroundTasks\nfrom fastapi.concurrency import (\n    asynccontextmanager,\n    contextmanager_in_threadpool,\n)\nfrom fastapi.dependencies.models import Dependant\nfrom fastapi.exceptions import DependencyScopeError\nfrom fastapi.logger import logger\nfrom fastapi.security.oauth2 import SecurityScopes\nfrom fastapi.types import DependencyCacheKey\nfrom fastapi.utils import create_model_field, get_path_param_names\nfrom pydantic import BaseModel, Json\nfrom pydantic.fields import FieldInfo\nfrom starlette.background import BackgroundTasks as StarletteBackgroundTasks\nfrom starlette.concurrency import run_in_threadpool\nfrom starlette.datastructures import (\n    FormData,\n    Headers,\n    ImmutableMultiDict,\n    QueryParams,\n    Uplo
11write_file
{
 "content": "\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\nfrom pydantic import BaseModel\n\napp = FastAPI()\n\n@app.get(\"/\")\nasync def root(x_token: str = Header()):\n    return {\"x_token\": x_token}\n\nclient = TestClient(app)\n\ndef test_underscore_header_rejected():\n    # By default, convert_underscores=True. \n    # It should accept 'x-token' and map it to 'x_token'.\n    # It should NOT accept 'x_token' as a header.\n    response = client.get(\"/\", headers={\"x-token\": \"foo\"})\n    assert response.status_code == 200\n    assert response.json() == {\"x_token\": \"foo\"}\n\n    # This is the case we want to ensure fails or is not accepted as the parameter\n    # If we send 'x_token', it should not be mapped to 'x_token' if convert_underscores=True\n    # Actually, if convert_underscores=True, FastAPI looks for 'x-token'.\n    # If it doesn't find 'x-token', it might still find 'x_token' if it's not strictly forbidden.\n    # The issue says \"Do not accept underscore headers when using convert_underscores=True\"\n    \n    # Let's see what happens now.\n    response = client.get(\"/\", headers={\"x_token\": \"bar\"})\n    # If it's currently accepted, this will be 200. We want it to be 422 (Unprocessable Entity)\n    # because 'x_token' is an underscore header.\n    print(f\"Response for x_token: {response.status_code}, {response.json()}\")\n\ndef test_underscore_header_accepted_when_disabled():\n    @app.get(\"/disabled\")\n    async def disabled(x_token: str = Header(convert_underscores=False)):\n        return {\"x_token\": x_token}\n    \n    response = client.get(\"/disabled\", headers={\"x_token\": \"bar\"})\n    assert response.status_code == 200\n    assert response.json() == {\"x_token\": \"bar\"}\n\nif __name__ == \"__main__\":\n    test_underscore_header_rejected()\n    test_underscore_header_accepted_when_disabled()\n    print(\"Tests passed!\")\n",
 "filepath": "/tmp/test_underscore_headers.py"
}
{"status": "ok", "filepath": "/tmp/test_underscore_headers.py", "size": 1838}
full
{"status": "ok", "filepath": "/tmp/test_underscore_headers.py", "size": 1838}
12-13 ×2run_command
{
 "command": "python3 /tmp/test_underscore_headers.py"
}
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open f
full
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open file '/tmp/swe_work/eval4_submission_v1b/fastapi_15589/a/tmp/test_underscore_headers.py': [Errno 2] No such file or directory\n", "exit_code": 2}}
14run_command
{
 "command": "ls -R /tmp"
}
{"status": "ok", "stdout": "/tmp/swe_work/eval4_submission_v1b/fastapi_15589/a/tmp:\n", "exit_code": 0}
full
{"status": "ok", "stdout": "/tmp/swe_work/eval4_submission_v1b/fastapi_15589/a/tmp:\n", "exit_code": 0}
15run_command
{
 "command": "python3 -c \"\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\n\napp = FastAPI()\n\n@app.get('/')\nasync def root(x_token: str = Header()):\n    return {'x_token': x_token}\n\nclient = TestClient(app)\nresponse = client.get('/', headers={'x_token': 'bar'})\nprint(f'Status: {response.status_code}, JSON: {response.json()}')\n\""
}
{"status": "ok", "stdout": "Status: 422, JSON: {'detail': [{'type': 'missing', 'loc': ['header', 'x-token'], 'msg': 'Field required', 'input': None}]}\n", "exit
full
{"status": "ok", "stdout": "Status: 422, JSON: {'detail': [{'type': 'missing', 'loc': ['header', 'x-token'], 'msg': 'Field required', 'input': None}]}\n", "exit_code": 0}
16run_command
{
 "command": "python3 -c \"\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\n\napp = FastAPI()\n\n@app.get('/')\nasync def root(x_token: str = Header()):\n    return {'x_token': x_token}\n\nclient = TestClient(app)\nresponse = client.get('/', headers={'x-token': 'bar'})\nprint(f'Status: {response.status_code}, JSON: {response.json()}')\n\""
}
{"status": "ok", "stdout": "Status: 200, JSON: {'x_token': 'bar'}\n", "exit_code": 0}
full
{"status": "ok", "stdout": "Status: 200, JSON: {'x_token': 'bar'}\n", "exit_code": 0}
17run_command
{
 "command": "python3 -c \"\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\nfrom pydantic import BaseModel\n\napp = FastAPI()\n\nclass HeaderModel(BaseModel):\n    x_token: str = Header()\n\n@app.get('/')\nasync def root(headers: HeaderModel = Header()):\n    return {'x_token': headers.x_token}\n\nclient = TestClient(app)\nresponse = client.get('/', headers={'x_token': 'bar'})\nprint(f'Status: {response.status_code}, JSON: {response.json()}')\n\""
}
{"status": "ok", "stdout": "Status: 200, JSON: {'x_token': 'bar'}\n", "exit_code": 0}
full
{"status": "ok", "stdout": "Status: 200, JSON: {'x_token': 'bar'}\n", "exit_code": 0}
18read_file
{
 "end_line": 840,
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 810
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "\n processed_keys = set()\n\n for field in fields_to_extract:\n alias = None\n if isin
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "\n    processed_keys = set()\n\n    for field in fields_to_extract:\n        alias = None\n        if isinstance(received_params, Headers):\n            # Handle fields extracted from a Pydantic Model for a header, each field\n            # doesn't have a FieldInfo of type Header with the default convert_underscores=True\n            convert_underscores = getattr(\n                field.field_info, \"convert_underscores\", default_convert_underscores\n            )\n            if convert_underscores:\n                alias = get_validation_alias(field)\n                if alias == field.name:\n                    alias = alias.replace(\"_\", \"-\")\n        value = _get_multidict_value(field, received_params, alias=alias)\n        if value is not None:\n            params_to_process[get_validation_alias(field)] = value\n        processed_keys.add(alias or get_validation_alias(field))\n\n    for key in received_params.keys():\n        if key not in processed_keys:\n            if isinstance(received_params, (ImmutableMultiDict, Headers)):\n                value = received_params.getlist(key)\n                if isinstance(value, list) and (len(value) == 1):\n                    params_to_process[key] = value[0]\n                else:\n                    params_to_process[key] = value\n            else:\n                params_to_process[key] = received_params.get(key)\n\n", "start_line": 810, "end_line": 840, "total_lines": 1057, "is_truncated": false}
19edit_file
{
 "filepath": "fastapi/dependencies/utils.py",
 "new_string": "    for key in received_params.keys():\n        if key not in processed_keys:\n            if isinstance(received_params, (ImmutableMultiDict, Headers)):\n                # If convert_underscores is True, we should not accept underscore headers\n                # This is a bit tricky because we don't have a single convert_underscores \n                # for the whole request, but we can check if any of the fields had it.\n                # However, the requirement is \"Do not accept underscore headers when using convert_underscores=True\".\n                # In the case of a Pydantic model, we already have default_convert_underscores.\n                if default_convert_underscores and \"_\" in key:\n                    continue\n                value = received_params.getlist(key)\n                if isinstance(value, list) and (len(value) == 1):\n                    params_to_process[key] = value[0]\n                else:\n                    params_to_process[key] = value\n            else:\n                params_to_process[key] = received_params.get(key)",
 "old_string": "    for key in received_params.keys():\n        if key not in processed_keys:\n            if isinstance(received_params, (ImmutableMultiDict, Headers)):\n                value = received_params.getlist(key)\n                if isinstance(value, list) and (len(value) == 1):\n                    params_to_process[key] = value[0]\n                else:\n                    params_to_process[key] = value\n            else:\n                params_to_process[key] = received_params.get(key)"
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "occurrences": 1, "strategy": "exact", "diff": "--- a/fastapi/dependencies/utils.py\n+++ b/fastapi
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "occurrences": 1, "strategy": "exact", "diff": "--- a/fastapi/dependencies/utils.py\n+++ b/fastapi/dependencies/utils.py\n@@ -830,6 +830,13 @@\n     for key in received_params.keys():\n         if key not in processed_keys:\n             if isinstance(received_params, (ImmutableMultiDict, Headers)):\n+                # If convert_underscores is True, we should not accept underscore headers\n+                # This is a bit tricky because we don't have a single convert_underscores \n+                # for the whole request, but we can check if any of the fields had it.\n+                # However, the requirement is \"Do not accept underscore headers when using convert_underscores=True\".\n+                # In the case of a Pydantic model, we already have default_convert_underscores.\n+                if default_convert_underscores and \"_\" in key:\n+                    continue\n                 value = received_params.getlist(key)\n                 if isinstance(value, list) and (len(value) == 1):\n                     params_to_process[key] = value[0]\n", "is_truncated": false}
20run_command
{
 "command": "python3 -c \"\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\nfrom pydantic import BaseModel\n\napp = FastAPI()\n\nclass HeaderModel(BaseModel):\n    x_token: str = Header()\n\n@app.get('/')\nasync def root(headers: HeaderModel = Header()):\n    return {'x_token': headers.x_token}\n\nclient = TestClient(app)\nresponse = client.get('/', headers={'x_token': 'bar'})\nprint(f'Status: {response.status_code}, JSON: {response.json()}')\n\""
}
{"status": "ok", "stdout": "Status: 422, JSON: {'detail': [{'type': 'missing', 'loc': ['header', 'x_token'], 'msg': 'Field required', 'input': {'host': 'testser
full
{"status": "ok", "stdout": "Status: 422, JSON: {'detail': [{'type': 'missing', 'loc': ['header', 'x_token'], 'msg': 'Field required', 'input': {'host': 'testserver', 'accept': '*/*', 'accept-encoding': 'gzip, deflate', 'connection': 'keep-alive', 'user-agent': 'testclient'}}]}\n", "exit_code": 0}
21run_command
{
 "command": "python3 -c \"\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\nfrom pydantic import BaseModel\n\napp = FastAPI()\n\nclass HeaderModel(BaseModel):\n    x_token: str = Header(convert_underscores=False)\n\n@app.get('/')\nasync def root(headers: HeaderModel = Header()):\n    return {'x_token': headers.x_token}\n\nclient = TestClient(app)\nresponse = client.get('/', headers={'x_token': 'bar'})\nprint(f'Status: {response.status_code}, JSON: {response.json()}')\n\""
}
{"status": "ok", "stdout": "Status: 200, JSON: {'x_token': 'bar'}\n", "exit_code": 0}
full
{"status": "ok", "stdout": "Status: 200, JSON: {'x_token': 'bar'}\n", "exit_code": 0}
22run_command
{
 "command": "python3 -c \"\nfrom fastapi import FastAPI, Header\nfrom fastapi.testclient import TestClient\n\napp = FastAPI()\n\n@app.get('/')\nasync def root(x_token: str = Header()):\n    return {'x_token': x_token}\n\nclient = TestClient(app)\nresponse = client.get('/', headers={'x_token': 'bar'})\nprint(f'Status: {response.status_code}, JSON: {response.json()}')\n\""
}
{"status": "ok", "stdout": "Status: 422, JSON: {'detail': [{'type': 'missing', 'loc': ['header', 'x-token'], 'msg': 'Field required', 'input': None}]}\n", "exit
full
{"status": "ok", "stdout": "Status: 422, JSON: {'detail': [{'type': 'missing', 'loc': ['header', 'x-token'], 'msg': 'Field required', 'input': None}]}\n", "exit_code": 0}
23submit_patch
[]
{"status": "ok", "patch_size": 3219, "files_changed": 2}
full
{"status": "ok", "patch_size": 3219, "files_changed": 2}

Patch

diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index 7c6558c69..3b442c927 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -830,6 +830,13 @@ def request_params_to_args(
     for key in received_params.keys():
         if key not in processed_keys:
             if isinstance(received_params, (ImmutableMultiDict, Headers)):
+                # If convert_underscores is True, we should not accept underscore headers
+                # This is a bit tricky because we don't have a single convert_underscores 
+                # for the whole request, but we can check if any of the fields had it.
+                # However, the requirement is "Do not accept underscore headers when using convert_underscores=True".
+                # In the case of a Pydantic model, we already have default_convert_underscores.
+                if default_convert_underscores and "_" in key:
+                    continue
                 value = received_params.getlist(key)
                 if isinstance(value, list) and (len(value) == 1):
                     params_to_process[key] = value[0]
diff --git a/tmp/test_underscore_headers.py b/tmp/test_underscore_headers.py
new file mode 100644
index 000000000..1ca0b4cb7
--- /dev/null
+++ b/tmp/test_underscore_headers.py
@@ -0,0 +1,46 @@
+
+from fastapi import FastAPI, Header
+from fastapi.testclient import TestClient
+from pydantic import BaseModel
+
+app = FastAPI()
+
+@app.get("/")
+async def root(x_token: str = Header()):
+    return {"x_token": x_token}
+
+client = TestClient(app)
+
+def test_underscore_header_rejected():
+    # By default, convert_underscores=True. 
+    # It should accept 'x-token' and map it to 'x_token'.
+    # It should NOT accept 'x_token' as a header.
+    response = client.get("/", headers={"x-token": "foo"})
+    assert response.status_code == 200
+    assert response.json() == {"x_token": "foo"}
+
+    # This is the case we want to ensure fails or is not accepted as the parameter
+    # If we send 'x_token', it should not be mapped to 'x_token' if convert_underscores=True
+    # Actually, if convert_underscores=True, FastAPI looks for 'x-token'.
+    # If it doesn't find 'x-token', it might still find 'x_token' if it's not strictly forbidden.
+    # The issue says "Do not accept underscore headers when using convert_underscores=True"
+    
+    # Let's see what happens now.
+    response = client.get("/", headers={"x_token": "bar"})
+    # If it's currently accepted, this will be 200. We want it to be 422 (Unprocessable Entity)
+    # because 'x_token' is an underscore header.
+    print(f"Response for x_token: {response.status_code}, {response.json()}")
+
+def test_underscore_header_accepted_when_disabled():
+    @app.get("/disabled")
+    async def disabled(x_token: str = Header(convert_underscores=False)):
+        return {"x_token": x_token}
+    
+    response = client.get("/disabled", headers={"x_token": "bar"})
+    assert response.status_code == 200
+    assert response.json() == {"x_token": "bar"}
+
+if __name__ == "__main__":
+    test_underscore_header_rejected()
+    test_underscore_header_accepted_when_disabled()
+    print("Tests passed!")

Test output

show
.......                                                                  [100%]
=============================== warnings summary ===============================
../../../../../../kaggle/tmp/envs/overlays/starlette-1.6.0-py3-none-any/starlette/testclient.py:53
  /kaggle/tmp/envs/overlays/starlette-1.6.0-py3-none-any/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
    _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 warning in 0.70s