Skip to content

reject CR and LF in basic_fields field name and value - #3105

Open
sahvx655-wq wants to merge 2 commits into
boostorg:developfrom
sahvx655-wq:fields-reject-crlf
Open

reject CR and LF in basic_fields field name and value#3105
sahvx655-wq wants to merge 2 commits into
boostorg:developfrom
sahvx655-wq:fields-reject-crlf

Conversation

@sahvx655-wq

Copy link
Copy Markdown
Contributor

basic_fields::try_create_new_element assembles the on-wire field as name, ": ", value, then a terminating CRLF, but it only checks the name and value lengths, never their contents. A CR or LF sitting inside a value is copied straight into that buffer, so a header built from attacker-influenced data such as a redirect target or a reflected parameter splits the field and starts new header lines. Setting Location to "/next\r\nSet-Cookie: sid=attacker" and serialising the response puts Set-Cookie on its own line, which is classic response splitting.

The fix rejects a name or value containing CR or LF in try_create_new_element, alongside the existing size checks, reusing the bad_field and bad_value codes the parser already returns for those octets when reading. All of the set and insert overloads, including the error_code variants, route through this one function, so the check covers them together and stops the malformed field at the point it is created instead of relying on every caller to pre-scan its input.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.20%. Comparing base (56a7c3a) to head (1640608).
⚠️ Report is 1 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #3105      +/-   ##
===========================================
- Coverage    93.30%   93.20%   -0.11%     
===========================================
  Files          177      177              
  Lines        13764    13739      -25     
===========================================
- Hits         12843    12805      -38     
- Misses         921      934      +13     
Files with missing lines Coverage Δ
include/boost/beast/http/impl/fields.hpp 97.45% <100.00%> (-0.13%) ⬇️

... and 31 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 56a7c3a...1640608. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ashtum

ashtum commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Hi, thanks for the PR. A few caveats worth flagging:

  • Given the layer Boost.Beast's serializer operates at, checks like this are usually left to a higher layer, unless doing them here proves cheaper than delegating.
  • The request method, request target, and response reason-phrase aren't covered by this check and likely need the same sanitization.
  • Checking for CR/LF fixes the vulnerability, but we could also validate names and values against their allowed character sets, which would cover this case as well, if it proves to be worthwhile.

Separately, core::string_view::find_first_of is oddly slow for this task. Even a naive form like:

    if( sname.find('\r') != string_view::npos ||
        sname.find('\n') != string_view::npos)
    {
        BOOST_BEAST_ASSIGN_EC(ec, error::bad_field);
        return nullptr;
    }
    if( value.find('\r') != string_view::npos ||
        value.find('\n') != string_view::npos)
    {
        BOOST_BEAST_ASSIGN_EC(ec, error::bad_value);
        return nullptr;
    }

is ~3x faster for ~30 char inputs and ~10x faster around ~300 chars. Note: I'm not suggesting this exact version, just that core::string_view::find_first_of looks like a poor fit here and warrants a closer look.

Replace find_first_of with two single-octet finds, which lower
to memchr and measure notably faster from ~30 char inputs up.
@sahvx655-wq

Copy link
Copy Markdown
Contributor Author

Good points, took a proper look at each. On the scan: I benchmarked before touching anything, and on Apple clang with core::string_view two single-octet find() calls measure about 1.4x faster than find_first_of("\r\n") at 30 chars and roughly 5x at 300, with find_first_of only winning marginally below ~10 chars. The root cause is that find_first_of walks the input octet by octet testing membership in the needle set, while find(char) forwards to memchr and gets vectorised. I've switched to the two-find form through a small detail::contains_crlf helper and extended the same rejection to set_method_impl, set_target_impl and set_reason_impl, reusing the parser's bad_method, bad_target and bad_reason codes, with tests in message.cpp. The read path is unaffected since basic_parser never passes those strings through with a bare CR or LF; the only new cost there is one memchr pass per start line. One quirk I noticed while testing: header::method_string assigns its cached verb before calling set_method_impl, so a rejected method leaves the cached verb as unknown while the stored bytes stay intact. I left that ordering alone to keep the diff contained.

On layering, the case for doing it here is that try_create_new_element is the one choke point every set and insert overload funnels through, so the check is written once and costs two memchr passes over bytes the function is about to copy anyway. Delegating means every call site that touches request-derived data has to remember to pre-scan, and a single miss puts a split header on the wire, which is the failure mode that worries me. Full charset validation would cover this too, but it needs a per-octet table lookup rather than memchr, and its main additional effect beyond CR/LF is rejecting stray C0 controls, which are ugly but don't alter framing on a correct peer; CR and LF are the two octets that actually change message structure. If you think the table-driven version is worth that cost I'm fine doing it instead.

@sahvx655-wq

Copy link
Copy Markdown
Contributor Author

any update?

@ashtum

ashtum commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Any update?

Regarding the code, I think we could probably do a bit better than:

   s.find('\r') != string_view::npos ||
   s.find('\n') != string_view::npos;

Especially since there’s an opportunity here to validate names and values against their complete allowed character sets.

That said, I think this is the less important part. It would be good to first settle the questions above:

  • Given the layer Boost.Beast's serializer operates at, checks like this are usually left to a higher layer, unless doing them here proves cheaper than delegating.
  • The request method, request target, and response reason-phrase aren't covered by this check and likely need the same sanitization.
  • Checking for CR/LF fixes the vulnerability, but we could also validate names and values against their allowed character sets, which would cover this case as well, if it proves to be worthwhile.

If we decide to perform the validation, there are a few options:

  • If we defer the validation to serialization time, a simple configuration option in the serializer could allow users to skip the validation when they don’t need it, so they don’t pay the cost unnecessarily.
  • If we add the validation to the container, we would need an _unchecked variant that skips the validation. This could be used by users who want to avoid the validation cost, and also by the parser itself when inserting parsed field names and values, since the parser has already validated them.
  • There’s also the possibility of adding concrete view types for field names and values that guarantee the referenced string is valid. These could then be passed around without incurring validation overhead at each boundary. (Similar to boost::urls::pct_string_view).

The following is a table of popular HTTP libraries and frameworks, showing whether they perform validation and, if so, whether it happens at the container level or at serialization time (generated using Claude Code):

Library                                  Name     Value    Where
---------------------------------------- -------- -------- -------------
C / C++
  libcurl                                none     none     -
  cpp-httplib                            full s   full s   container
  libevent                               crlf     crlf     container
  libsoup                                crlf s   crlf s   container
  libmicrohttpd                          crlf     crlf     container
  nghttp2 (outbound)                     none     none     -
  POCO                                   none     none     -
  Qt QHttpHeaders (>=6.7)                full     full     container
  Proxygen HTTPHeaders (egress)          none?    none?    -
Go
  net/http client                        full     full     serialization
  net/http server                        full s   crlf s   serialization
  fasthttp                               crlf s   crlf s   container
Rust
  http (hyper/reqwest/axum/actix)        full     full     type
JS
  Node http                              full     full     container
  Fetch Headers (browsers/undici/Deno)   full     crlf     container
Python
  http.client / urllib3~                 crlf     crlf     serialization
  requests                               crlf     crlf     serialization
  httpx (~h11)                           full~    full~    serialization
  h11                                    full     full     container
  aiohttp                                crlf     crlf     serialization
  Tornado                                crlf     crlf     serialization
  Twisted                                crlf s   crlf s   container
  Django                                 crlf     crlf     container
  Werkzeug / Flask                       none     crlf     container
  Starlette / FastAPI (~ASGI server)     ~        ~        ~
  uvicorn (httptools)                    full     full!    serialization
  gunicorn                               full     full     serialization
Java / Kotlin
  Netty                                  full     full     container
  OkHttp                                 full     full     container
  JDK java.net.http                      full     full     container
  JDK HttpURLConnection                  crlf!    crlf!    container
  JDK com.sun.net.httpserver             crlf     crlf     container
  Apache HttpComponents 5                none     crlf s   serialization
  Jetty                                  crlf s   full s   serialization
  Tomcat                                 crlf s   full s   serialization
  Ktor                                   full     full     container
  Spring HttpHeaders (~container)        ~        ~        ~
.NET
  HttpClient HttpHeaders.Add             full     crlf     container
    TryAddWithoutValidation              full     none     container
  WebHeaderCollection                    full     full     container
  Kestrel                                full     full     container
Other
  Ruby Net::HTTP                         none     crlf     container
  Puma                                   full s   full! s  serialization
  PHP header() (Symfony/Laravel~)        crlf     crlf     container
  guzzle/psr7 (PSR-7)                    full     full     container
  Elixir Plug                            none     crlf     container
  Elixir Mint                            full     full     serialization
  SwiftNIO                               full     full     serialization
  Dart dart:io                           full     full     container

full = grammar: name token (or close approximation), value no CTL
       except HT (obs-text handling varies)
crlf = CR/LF caught (some also NUL, ':' or leading WS), nothing else
none = no check
s    = sanitized or silently dropped instead of rejected
!    = bare CR passes (only LF caught)
~    = no check of its own; inherits from the layer in parentheses
?    = not verified

Beast is neither a client nor a server library, so there are other vulnerabilities on both the client and server sides that it doesn’t, and can’t, prevent. This wouldn’t be the only case where the higher-level application needs to take responsibility for the appropriate validation.

Given that, I believe delegating this validation to the higher level would be a defensible position.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants