c++websocketboostboost-asioboost-beast

websocket::stream::async_handshake with custom request


I am reading an HTTP request from the network that I want to use in a handshake with a remote websocket server. The websocket::stream::async_handshake API does not provide a way to specify the request that should be sent as part of the handshake.

Instead, it suggests using a decorator for the request that beast generates itself. When I use the decorator to modify the beast request headers, I get an error in the handshake: "The WebSocket handshake Sec-WebSocket-Accept field is invalid".

It looks like boost::beast::websocket::stream generates its own Sec-WebSocket-Key value and does not take into account the Sec-WebSocket-Key header that I passed via the decorator.

How can I solve this problem? Here is the part of the code where async_handshake is executed (please pay attention to comments):

// request variable points to http request which should be used in websocket handshake
// this request upgrades HTTP to WebSocket
ws_.set_option(boost::beast::websocket::stream_base::decorator(
    [request](boost::beast::websocket::request_type& req)
{
    // copy all headers from received request to handshake request (including Sec-WebSocket-Key)
    for (auto& field : *request)
    {
        req.set(field.name(), field.value());
    }
}));

ws_.async_handshake((*request)["Host"], request->target(),
    [this, request](boost::beast::error_code ec)
{
    // here is error (i think because Sec-WebSocket-Key is wrong)
    if (ec)
    {
        std::cout << ec.message() << std::endl;
    }
});

Solution

  • UPDATE

    To the comments, a simple implementation of application-level WS proxy.

    Note how it uses the following "embracing" order:

    It protects the "protected" headers because they MUST NOT be relayed. It also adds some courtesy headers both ways to indicate the source of the proxied peer (X-Real-Ip and Server headers).

    Note that it does not currently address SSL, extensions, control frames and/or message fragmentation. This is fine for application level proxying: Perhaps you expressly want different SSL/extensions/ping/etc. on the upstream than the downstream. These are possible reasons to interpose a proxy in the first place.

    Live On Coliru

    #include <boost/asio.hpp>
    #include <boost/asio/experimental/awaitable_operators.hpp>
    #include <boost/beast.hpp>
    #include <iostream>
    namespace beast     = boost::beast;
    namespace http      = beast::http;
    namespace net       = boost::asio;
    namespace websocket = beast::websocket;
    using net::awaitable;
    using net::ip::tcp;
    using WS = websocket::stream<tcp::socket>;
    
    template <typename From, typename To, bool IsRequest = To::is_request::value>
    static void mirror_headers(From const& from, To& to) {
        using http::field;
        auto inject = [&](auto const& h) {
            auto k = h.name_string();
            auto v = h.value();
    
            if (to.count(k)) {
                if (v != to.at(k)) {
                    std::cout << "Warning: overriding header " << k << "\n"
                              << " - old: " << to.at(k) << "\n"
                              << " - new: " << v << std::endl;
                }
            } else {
                std::cout << "Adding header " << k << ": " << v << std::endl;
            }
            to.set(k, v);
        };
    
        for (auto const& h : from.base()) {
    
            if (auto known_field = h.name();                  //
                (!IsRequest || known_field != field::host) && //
                known_field != field::sec_websocket_key &&    //
                known_field != field::sec_websocket_accept)   //
            {
                inject(h);
            } else {
                std::cout << "Skipping protected header " << known_field << std::endl;
            }
        }
    }
    
    awaitable<void> session(tcp::socket s) {
        auto ex = co_await net::this_coro::executor;
        std::cout << "Session " << s.remote_endpoint() << " started\n";
    
        // #1 read downstream upgrade request
        beast::flat_buffer buf;
        websocket::request_type down_req;
        co_await async_read(s, buf, down_req);
    
        assert(websocket::is_upgrade(down_req)); // assuming pure WebSocket traffic
        assert(buf.size() == 0);                 // not supporting pipelined requests on upgrade
    
        // adding courtesy headers to downstream request
        down_req.set("X-Real-IP", s.remote_endpoint().address().to_string());
    
        // #2 connect upstream
        WS downstream(std::move(s));
        WS upstream(ex);
    
        co_await upstream.next_layer().async_connect({{}, 8989});
    
        // #3 send decorated upgrade request to upstream
        upstream.set_option(websocket::stream_base::decorator([&down_req](websocket::request_type& req) {
            std::cout << " ---- Decorating upgrade request" << std::endl;
            mirror_headers(down_req, req);
        }));
        websocket::response_type up_res;
        co_await upstream.async_handshake(up_res, "localhost:8989", "/upstream");
    
        // adding courtesy headers to upstream response
        if (up_res.count(http::field::server)==0)
            up_res.set(http::field::server, "localhost:8989");
    
        // #4 accept downstream upgrade, decorating response with upstream headers
        downstream.set_option(websocket::stream_base::decorator([&up_res](websocket::response_type& res) {
            std::cout << " ---- Decorating upgrade response" << std::endl;
            mirror_headers(up_res, res);
        }));
        co_await downstream.async_accept(down_req);
    
        std::cout << "Proxying " << downstream.next_layer().remote_endpoint() << " <-> "
                  << upstream.next_layer().remote_endpoint() << std::endl;
    
        auto half_duplex = [&](WS& source, WS& sink) -> awaitable<void> {
            for (beast::flat_buffer buffer;; buffer.clear()) {
                co_await source.async_read_some(buffer, sink.write_buffer_bytes());
    
                sink.binary(source.got_binary());
                co_await sink.async_write_some(source.is_message_done(), buffer.cdata());
            }
        };
        using namespace net::experimental::awaitable_operators;
        co_await (half_duplex(downstream, upstream) && //
                  half_duplex(upstream, downstream));
    }
    
    awaitable<void> listen(uint16_t port) {
        auto ex = co_await net::this_coro::executor;
        for (tcp::acceptor acc(ex, {{}, port});;)
            co_spawn(ex, session(co_await acc.async_accept()), net::detached);
    }
    
    int main() try {
        net::io_context ioc;
        co_spawn(ioc, listen(7878), net::detached);
        ioc.run();
    } catch (std::exception const& e) {
        std::cerr << "Error: " << e.what() << '\n';
    }
    

    With my usual local demo:


    It looks like boost::beast::websocket::stream generates its own Sec-WebSocket-Key value and does not take into account the Sec-WebSocket-Key header that I passed via the decorator.

    The opposite. If you supply Sec-WebSocket-Key it will be wrong.

    That's because Sec-WebSocket-Key depends on the entire upgrade request. It is not for security, and if you pass a value from a different request it will be incorrect for the actual request.

    Read here for the role of Sec-WebSocket-Key in Websocket protocol: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-WebSocket-Key

    The value of the key is computed using an algorithm defined in the WebSocket specification, so this does not provide security. Instead, it helps to prevent non-WebSocket clients from inadvertently, or through misuse, requesting a WebSocket connection.

    Note that e.g. browsers do not allow you to set the header manually:

    This header is automatically added by user agents when a script opens a WebSocket; it cannot be added using the fetch() or XMLHttpRequest.setRequestHeader() methods.

    It seems that you had the wrong expectation about this header.