From 3e4d951f41156e501aa27ffe929e2b5d28f85382 Mon Sep 17 00:00:00 2001 From: "pullapprove5-fix[bot]" <4489445+pullapprove5-fix[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:54:04 +0000 Subject: [PATCH] Fix: NotAllowedResponse accepts an arbitrary unvalidated status_code despite being the 405 response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced the finding: `NotAllowedResponse(["GET"], status_code=200)` succeeded and returned a response with `status_code == 200`, despite the class being documented as "HTTP 405 response" — the constructor's `status_code` parameter passed straight through to the base `Response.__init__` with only the generic 200–599 range check, no constraint to 405. Fixed by following the exact precedent the finding pointed at (`NotModifiedResponse`): removed the `status_code` parameter from `NotAllowedResponse.__init__` entirely, so the class always constructs with its `status_code = 405` class default. Confirmed the sole caller (`plain/views/base.py:130`) never passed `status_code`, so no call site needed updating. Added a docstring note mirroring `NotModifiedResponse`'s ("constructor is pinned") and a public test `test_not_allowed_response_signature_is_pinned` in `plain/tests/public/test_http_bodiless_responses.py`, asserting `NotAllowedResponse(["GET"], status_code=200)` now raises `TypeError` for the unexpected keyword argument. Ran `./scripts/fix plain` (clean, no changes) and the full `plain` package test suite directly via `uv run --isolated --package plain python -m pytest` (698 passed, including the new test and all existing view-dispatch/405 tests) — the sandbox has no Docker/Postgres available, so I could not run `./scripts/test` (it requires spinning up Postgres for the example project and other packages), but the change is confined to `plain/plain/http/response.py` and its own package's suite passed in full. --- plain/plain/http/response.py | 7 ++++--- plain/tests/public/test_http_bodiless_responses.py | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/plain/plain/http/response.py b/plain/plain/http/response.py index e8aec64022..2208212b90 100644 --- a/plain/plain/http/response.py +++ b/plain/plain/http/response.py @@ -798,7 +798,10 @@ def __init__( class NotAllowedResponse(Response): - """HTTP 405 response""" + """HTTP 405 response. + + The constructor is pinned: no status_code parameter, so this class + always means exactly "405 method not allowed".""" status_code = 405 @@ -807,14 +810,12 @@ def __init__( permitted_methods: list[str], *, content_type: str | None = None, - status_code: int | None = None, reason: str | None = None, charset: str | None = None, headers: dict[str, Any] | None = None, ): super().__init__( content_type=content_type, - status_code=status_code, reason=reason, charset=charset, headers=headers, diff --git a/plain/tests/public/test_http_bodiless_responses.py b/plain/tests/public/test_http_bodiless_responses.py index a063ca4b8b..0f07289248 100644 --- a/plain/tests/public/test_http_bodiless_responses.py +++ b/plain/tests/public/test_http_bodiless_responses.py @@ -14,6 +14,7 @@ FileResponse, HTTPException, JsonResponse, + NotAllowedResponse, NotModifiedResponse, Response, StreamingResponse, @@ -158,6 +159,13 @@ def test_not_modified_response_signature_is_pinned(): NotModifiedResponse(status_code=200) # ty: ignore[unknown-argument] +def test_not_allowed_response_signature_is_pinned(): + # No status_code parameter — this class always means exactly "405 + # method not allowed". + with pytest.raises(TypeError): + NotAllowedResponse(["GET"], status_code=200) # ty: ignore[unknown-argument] + + def test_setting_content_after_bodiless_status_raises(): response = Response(status_code=204) with pytest.raises(ValueError, match="cannot have a body"):