Skip to content

feat(websocket): let providers signal a WebSocket upgrade without the HTTP/1.1 Upgrade header (RFC 8441) - #550

Open
freitasjca wants to merge 1 commit into
HashLoad:masterfrom
freitasjca:feat/websocket-rfc8441-flag
Open

feat(websocket): let providers signal a WebSocket upgrade without the HTTP/1.1 Upgrade header (RFC 8441)#550
freitasjca wants to merge 1 commit into
HashLoad:masterfrom
freitasjca:feat/websocket-rfc8441-flag

Conversation

@freitasjca

Copy link
Copy Markdown
Contributor

Closes #548.

Summary

THorseRequest.IsWebSocket tests for the HTTP/1.1 Upgrade: websocket header.
HTTP/2 cannot carry that header — connection-specific headers are forbidden
outright (RFC 9113 §8.2.2), and a conforming HTTP/2 server must reject a
request that sends one. RFC 8441 replaces the whole mechanism with extended
CONNECT: :method CONNECT plus a :protocol: websocket pseudo-header.

So a perfectly valid HTTP/2 upgrade is refused by UpgradeToWebSocket with
400 Request is not a WebSocket upgrade request.

This is not a defect in anything Horse currently ships. Every bundled
provider speaks HTTP/1.1, where the existing check is exactly right. It only
becomes a gap once a provider serves HTTP/2.

The change

Ten lines in one unit. A flag a provider can set during request population, and
IsWebSocket honours it:

private
  FWebSocketUpgrade: Boolean;
public
  procedure SetWebSocketUpgrade(const AValue: Boolean); virtual;

function THorseRequest.IsWebSocket: Boolean;
begin
  Result := FWebSocketUpgrade
            or (Headers.ContainsKey('upgrade')
                and (Pos('websocket', LowerCase(Headers['upgrade'])) > 0));
end;

The flag is checked first and short-circuits, so an HTTP/2 provider never pays
the header lookup; on HTTP/1.1 nothing sets it and the original test runs
unchanged.

FWebSocketUpgrade is also reset in Clear, alongside the other shadow fields.
THorseRequest is pooled, so without that the next request served by the same
instance would report IsWebSocket = True with no upgrade of its own.

Why a flag rather than reading :protocol in core

  • Pseudo-headers are deliberately absent from Req.Headers. Providers strip
    :method, :path, :scheme and :authority because they are not real
    headers and would confuse Indy-style middleware. Core cannot see :protocol
    without changing that, which is a much larger change.
  • It does not tie core to HTTP/2. HTTP/3 uses the same extended-CONNECT
    mechanism; a boolean covers both without core knowing either.
  • Additive and backward compatible. Defaulting to False means every
    existing provider behaves exactly as today.

Compatibility

No signature changes, no behavioural change for any existing provider, no
breaking change for middleware. SetWebSocketUpgrade is virtual, consistent
with the surrounding accessors.

Testing

Verified against an HTTP/2 provider implementing RFC 8441 extended CONNECT,
driven by an independent Python h2 client: extended CONNECT accepted
(:status 200), server-pushed frame delivered and read by the client, and the
client's masked frame delivered intact to the parser.

Regression-checked on HTTP/1.1: the WebSocket path is unaffected on Indy, IOCP
and epoll, verified with a raw RFC 6455 echo test on Delphi 12 (Win64) and FPC
3.2.2 (Linux). Nothing sets the flag on those paths, so IsWebSocket evaluates
exactly as before.

Context — what the workaround costs today

Without this, an HTTP/2 provider has to synthesise the headers into the parsed
view to satisfy the check:

if SameText(AStream.Header[':method'], 'CONNECT')
   and SameText(AStream.Header[':protocol'], 'websocket') then
begin
  AHorseReq.Headers.Dictionary.AddOrSetValue('upgrade', 'websocket');
  AHorseReq.Headers.Dictionary.AddOrSetValue('connection', 'Upgrade');
end;

Nothing is fabricated on the wire and the provider still rejects a genuine
upgrade header from a peer, as HTTP/2 requires. It works — but it means
inventing headers to satisfy a check, which reads as a bug to the next person
who finds it, and every future HTTP/2 or HTTP/3 provider would repeat it.

Happy to adjust the shape if a different one fits Horse better — the naming, the
virtual, or exposing it as a property rather than a setter are all easy to
change.

freitasjca added a commit to freitasjca/horse-provider-nghttp2 that referenced this pull request Aug 22, 2026
Validated end-to-end 2026-08-21: build-fpc.sh 27/27 stages, stage 18 4/4 —
extended CONNECT accepted with :status 200, server frame delivered, and the
client's masked frame round-tripped as 'echo:hello'. Driven by Python h2, an
independent HTTP/2 implementation, so a symmetric bug here could not produce it.

There is no 101 and no Sec-WebSocket-Key handshake: HTTP/2 has no protocol-switch
status, so RFC 8441 opens an ordinary stream with :method CONNECT plus
:protocol websocket, answered with :status 200. RFC 6455 frames then flow as DATA
in both directions.

- WebSocket.pas (new): TNghttp2WebSocketTransport implements the six-method
  IHorseWebSocketTransport over ReadInbound/PushStreamData, plus the upgrader.
  Read loops on ReadInbound's -1 rather than passing it through: Horse treats
  <= 0 as a disconnect, and an idle peer would otherwise be torn down after the
  first quiet tick.
- Request.pas: permit extended CONNECT, still refuse the plain RFC 7540 s8.3
  tunnelling form — this is an origin server, not a forward proxy.
- RawRequest.pas: map extended CONNECT to GET. Horse's router re-derives the
  method from RawWebRequest.Method, so the shadow TMethodType alone is ignored
  and every route answered 405.
- EnableWebSocket opt-in on the provider, default False.
- Test suite: stages 15-18 and ws8441_check.py.

Requires a Horse core fix on FPC: Horse.Core.WebSocket.FeedBytes casts an
interface reference back to a class, which FPC does not resolve, so no inbound
callback fires. Submitted upstream as HashLoad/horse#551; until it merges, apply
patches/horse/src/Horse.Core.WebSocket.pas.

Req.IsWebSocket still relies on synthesising the upgrade headers into the parsed
view, since core cannot see :protocol. HashLoad/horse#550 adds SetWebSocketUpgrade
to replace that.
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.

IsWebSocket cannot detect an HTTP/2 (RFC 8441) WebSocket upgrade

1 participant