aio-libs/aiohttp · error · ValueError

Forbidden control character detected in headers. Potential…

Error message

Forbidden control character detected in headers. Potential header injection attack.

What it means

Raised as a plain ValueError by _safe_header when serializing headers/status-line if any forbidden control character (bytes 0x00-0x08, 0x0a-0x1f, 0x7f) is found. The regex _FORBIDDEN_HEADER_CHARS_RE enforces RFC 9110 §5.5 / RFC 9112 §4.3, preventing header-injection (CRLF injection / response splitting) attacks. Applied to both the status line and every header name/value.

Solutions

  1. Sanitize any user-controlled value before placing it in a header: strip/reject CR, LF, NUL, and other control bytes.
  2. Use URL-encoding for arbitrary data passed in headers.
  3. Validate header values against _FORBIDDEN_HEADER_CHARS_RE (or [\x20-\x7e] plus tab) before setting them.
  4. For redirects, build Location from a safelist or validated URL, never raw input.
  5. Keep the security check in _safe_header enabled; do not bypass it.

Example fix

# before
import re
async def handler(request):
    name = request.query.get('name', '')
    return web.Response(headers={'X-Name': name})  # CRLF in name -> ValueError

# after
import re
_FORBIDDEN = re.compile(r'[\x00-\x08\x0a-\x1f\x7f]')
async def handler(request):
    name = _FORBIDDEN.sub('', request.query.get('name', ''))
    return web.Response(headers={'X-Name': name})
Defensive patterns

Strategy: validation

Validate before calling

import re
_FORBIDDEN_HEADER_CHARS_RE = re.compile(r'[\x00-\x08\x0a-\x1f\x7f]')

def safe_header_value(value: str) -> str | None:
    if _FORBIDDEN_HEADER_CHARS_RE.search(value) is None:
        return value
    return None  # or: return _FORBIDDEN_HEADER_CHARS_RE.sub('', value)

def validate_headers(headers: dict[str, str]) -> dict[str, str]:
    out = {}
    for k, v in headers.items():
        if _FORBIDDEN_HEADER_CHARS_RE.search(k) is not None:
            raise ValueError(f'Forbidden chars in header name {k!r}')
        cleaned = safe_header_value(v)
        if cleaned is None:
            raise ValueError(f'Forbidden chars in header value for {k!r}')
        out[k] = cleaned
    return out

Try / catch

try:
    resp = web.Response(headers={'X-Name': user_input})
    return resp
except ValueError:
    # ValueError from _safe_header during serialization
    return web.Response(status=400, text='Invalid header input')

Prevention

When it happens

Trigger: Application code sets a header name or value (or a status line) containing a raw CR/LF/NUL or other control byte, e.g. including '\r\n' inside a cookie/Location/User-Agent value. _py_serialize_headers runs _safe_header on each component and raises before any bytes hit the wire.

Common situations: User-controlled input placed into a header without sanitization (reflected XSS / injection vector); a Location header built from a query param containing newlines; logging/correlation IDs containing control chars; a server-side header value computed from untrusted data; tests with literal newlines in headers.

Understand the failure class

Related errors


AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11). Data as JSON: /api/errors/8ea35fbd6392e1cd. Report an issue: GitHub.

Appendix: source

Thrown at aiohttp/http_writer.py:374

        The intended use is to write

          await w.write(data)
          await w.drain()
        """
        protocol = self._protocol
        if protocol.transport is not None and protocol._paused:
            await protocol._drain_helper()


# https://www.rfc-editor.org/info/rfc9110/#section-5.5-5
# https://www.rfc-editor.org/info/rfc9112/#section-4-3
_FORBIDDEN_HEADER_CHARS_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")


def _safe_header(string: str) -> str:
    if _FORBIDDEN_HEADER_CHARS_RE.search(string) is not None:
        raise ValueError(
            "Forbidden control character detected in headers. "
            "Potential header injection attack."
        )
    return string


def _py_serialize_headers(status_line: str, headers: "CIMultiDict[str]") -> bytes:
    _safe_header(status_line)
    headers_gen = (_safe_header(k) + ": " + _safe_header(v) for k, v in headers.items())
    line = status_line + "\r\n" + "\r\n".join(headers_gen) + "\r\n\r\n"
    return line.encode("utf-8")


_serialize_headers = _py_serialize_headers

try:
    import aiohttp._http_writer as _http_writer  # type: ignore[import-not-found]

View on GitHub (pinned to d041d4d0fd)