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
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #548.
Summary
THorseRequest.IsWebSockettests for the HTTP/1.1Upgrade: websocketheader.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 CONNECTplus a:protocol: websocketpseudo-header.So a perfectly valid HTTP/2 upgrade is refused by
UpgradeToWebSocketwith400 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
IsWebSockethonours it: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.
FWebSocketUpgradeis also reset inClear, alongside the other shadow fields.THorseRequestis pooled, so without that the next request served by the sameinstance would report
IsWebSocket = Truewith no upgrade of its own.Why a flag rather than reading
:protocolin coreReq.Headers. Providers strip:method,:path,:schemeand:authoritybecause they are not realheaders and would confuse Indy-style middleware. Core cannot see
:protocolwithout changing that, which is a much larger change.
mechanism; a boolean covers both without core knowing either.
Falsemeans everyexisting provider behaves exactly as today.
Compatibility
No signature changes, no behavioural change for any existing provider, no
breaking change for middleware.
SetWebSocketUpgradeisvirtual, consistentwith the surrounding accessors.
Testing
Verified against an HTTP/2 provider implementing RFC 8441 extended CONNECT,
driven by an independent Python
h2client: extended CONNECT accepted(
:status 200), server-pushed frame delivered and read by the client, and theclient'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
IsWebSocketevaluatesexactly 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:
Nothing is fabricated on the wire and the provider still rejects a genuine
upgradeheader from a peer, as HTTP/2 requires. It works — but it meansinventing 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 tochange.