|
31 | 31 | WRANGLER_VERSION = "3.1.0" |
32 | 32 |
|
33 | 33 |
|
| 34 | +def _patch_tls_disable_http2(): |
| 35 | + """ |
| 36 | + Monkey-patch LocalStack's Twisted gateway to stop advertising HTTP/2 (h2) via ALPN, |
| 37 | + so that all HTTPS connections always negotiate HTTP/1.1. |
| 38 | +
|
| 39 | + ROOT CAUSE |
| 40 | + ---------- |
| 41 | + twisted.web.server.Site.acceptableProtocols() returns [b"h2", b"http/1.1"] whenever the |
| 42 | + `h2` Python package is installed (H2_ENABLED = True in twisted.web.http). LocalStack's |
| 43 | + TwistedGateway inherits from Site, so it also advertises h2. |
| 44 | +
|
| 45 | + During TLS connection setup, TLSMemoryBIOFactory._createConnection() calls |
| 46 | + _applyProtocolNegotiation(), which reads wrappedFactory.acceptableProtocols() and |
| 47 | + installs an ALPN select callback that prefers the first listed protocol. Because h2 is |
| 48 | + first, any TLS client that sends h2 in its ALPN extension (modern browsers, Node.js |
| 49 | + fetch/undici, httpx with http2=True, etc.) will have h2 selected. |
| 50 | +
|
| 51 | + After ALPN selects h2, Twisted's _GenericHTTPChannelProtocol.dataReceived() detects |
| 52 | + `negotiatedProtocol == b"h2"` and swaps the underlying channel for an H2Connection |
| 53 | + (twisted.web.http2.H2Connection). H2Connection handles raw HTTP/2 frames and produces |
| 54 | + Request objects via Site.requestFactory, but LocalStack's gateway pipeline is built |
| 55 | + around rolo's WsgiGateway → WSGI environ → LocalstackAwsGateway. The H2Connection |
| 56 | + request/response lifecycle (streams, flow control, DATA frames) is incompatible with |
| 57 | + WSGI, so HTTP/2 requests are processed incorrectly. |
| 58 | +
|
| 59 | + The symptom is that HTTPS requests to extension paths (e.g. /miniflare/user) appear |
| 60 | + to return NoSuchBucket from S3 or are silently dropped, because the garbled HTTP/2 |
| 61 | + frames fail to match any registered route and fall through to the legacy_s3_rules |
| 62 | + catch-all in localstack-core/localstack/aws/protocol/service_router.py: |
| 63 | + if method in ["GET", "HEAD"] and stripped: |
| 64 | + return ServiceModelIdentifier("s3") # incredibly greedy fallback |
| 65 | +
|
| 66 | + HOW THIS PATCH FIXES IT |
| 67 | + ----------------------- |
| 68 | + We override TwistedGateway.acceptableProtocols() to return only [b"http/1.1"]. |
| 69 | + This propagates through _applyProtocolNegotiation() so the ALPN select callback |
| 70 | + never picks h2. Clients then use HTTP/1.1 over TLS, which works correctly end-to-end |
| 71 | + with LocalStack's WSGI-based gateway. |
| 72 | +
|
| 73 | + TODO: remove this patch once HTTP/2 is properly supported in LocalStack's Twisted |
| 74 | + serving stack. The fix belongs upstream in rolo (TwistedGateway) or localstack-core |
| 75 | + (TLSMultiplexer / TwistedRuntimeServer). Proper HTTP/2 support would require |
| 76 | + integrating H2Connection's stream-based request lifecycle with rolo's gateway model, |
| 77 | + likely via an ASGI-style adapter rather than WSGI. |
| 78 | + See: https://github.com/localstack/localstack-extensions/issues (track upstream fix here) |
| 79 | + """ |
| 80 | + try: |
| 81 | + from rolo.serving.twisted import TwistedGateway |
| 82 | + |
| 83 | + if getattr(TwistedGateway, "_http2_disabled_by_patch", False): |
| 84 | + return |
| 85 | + |
| 86 | + def _http11_only_protocols(self): |
| 87 | + return [b"http/1.1"] |
| 88 | + |
| 89 | + TwistedGateway.acceptableProtocols = _http11_only_protocols |
| 90 | + TwistedGateway._http2_disabled_by_patch = True |
| 91 | + LOG.debug("Applied TLS ALPN patch: disabled h2 advertisement for HTTPS connections") |
| 92 | + except Exception as e: |
| 93 | + LOG.warning("Could not apply TLS ALPN patch for HTTPS routing fix: %s", e) |
| 94 | + |
| 95 | + |
34 | 96 | class MiniflareExtension(Extension): |
35 | 97 | name = "miniflare" |
36 | 98 |
|
| 99 | + def on_extension_load(self): |
| 100 | + _patch_tls_disable_http2() |
| 101 | + |
37 | 102 | def update_gateway_routes(self, router: http.Router[http.RouteHandler]): |
38 | 103 | from miniflare.config import HANDLER_PATH_MINIFLARE |
39 | 104 |
|
|
0 commit comments