aio-libs/aiohttp · warning · ValueError

Reason cannot contain \r or \n

Error message

Reason cannot contain \r or \n

What it means

Raised as ValueError by the constructor of every HTTP exception (HTTPException.__init__ in aiohttp/web_exceptions.py:103) when you pass reason=... that contains a carriage return (\r) or newline (\n). aiohttp forbids these characters because reason goes verbatim into the HTTP status line (e.g. 'HTTP/1.1 418 <reason>'), and an embedded CRLF would let you inject a second HTTP response (HTTP response splitting). The check is explicit and unconditional.

Source

Thrown at aiohttp/web_exceptions.py:103

    # You should set in subclasses:
    # status = 200

    status_code = -1
    empty_body = False
    default_reason = ""  # Initialized at the end of the module

    def __init__(
        self,
        *,
        headers: LooseHeaders | None = None,
        reason: str | None = None,
        text: str | None = None,
        content_type: str | None = None,
    ) -> None:
        if reason is None:
            reason = self.default_reason
        elif "\r" in reason or "\n" in reason:
            raise ValueError("Reason cannot contain \\r or \\n")

        if text is None:
            if not self.empty_body:
                text = f"{self.status_code}: {reason}"
        else:
            if self.empty_body:
                warnings.warn(
                    f"text argument is deprecated for HTTP status {self.status_code} "
                    "since 4.0 and scheduled for removal in 5.0 (#3462),"
                    "the response should be provided without a body",
                    DeprecationWarning,
                    stacklevel=2,
                )

        if headers is not None:
            real_headers = CIMultiDict(headers)
        else:
            real_headers = CIMultiDict()

View on GitHub (pinned to c0ef574e29)

Solutions

  1. Strip or reject CR/LF in the reason string before constructing the exception: reason.replace('\r', ' ').replace('\n', ' ').
  2. Keep reason as a short, static phrase; put dynamic detail in text= or a custom header instead.
  3. Validate user-supplied strings at the trust boundary, not at HTTP-exception construction time.
  4. Add a unit test asserting reason contains no control characters for any user-influenced value.

Example fix

// before
raise HTTPBadRequest(reason=f"Invalid field: {user_input}")

# after
safe = user_input.replace("\r", " ").replace("\n", " ")[:200]
raise HTTPBadRequest(text=f"Invalid field: {user_input}")
Defensive patterns

Strategy: validation

Validate before calling

def safe_reason(s: str, max_len: int = 200) -> str:
    if "\r" in s or "\n" in s:
        s = s.replace("\r", " ").replace("\n", " ")
    return s[:max_len]

Type guard

def is_safe_reason(s: str) -> bool:
    return "\r" not in s and "\n" not in s and len(s) <= 200

Try / catch

try:
    raise HTTPBadRequest(reason=user_text)
except ValueError:
    raise HTTPBadRequest(text=user_text)

Prevention

When it happens

Trigger: Constructing any web_exceptions class with reason containing a CR or LF: HTTPBadRequest(reason='oops\nX-Evil: 1'), HTTPException subclasses, or returning an HTTPException(reason=user_input) where user_input is unvalidated. Both '\r' and '\n' (Unix LF) trigger it, in either order.

Common situations: Passing exception/Status text derived from a user-controlled field (error message, form input, upstream API response) straight into reason=. Also happens when logging or copy-pasting multi-line strings as the reason. Sometimes surfaces after a refactor that started forwarding raw error bodies as the HTTP reason phrase.

Related errors


AI-assisted analysis of aio-libs/aiohttp@c0ef574e29 (2026-08-04). Data as JSON: /data/errors/1254d5e9656baba8.json. Report an issue: GitHub.