Skip to content

Make HTTP backend pluggable and default to Bandit#153

Open
Hajto wants to merge 3 commits into
PSPDFKit-labs:masterfrom
Hajto:bandit-pluggable-backend
Open

Make HTTP backend pluggable and default to Bandit#153
Hajto wants to merge 3 commits into
PSPDFKit-labs:masterfrom
Hajto:bandit-pluggable-backend

Conversation

@Hajto

@Hajto Hajto commented Jul 10, 2026

Copy link
Copy Markdown

What

Makes the HTTP server backend pluggable behind a new Bypass.Adapter behaviour
and switches the default from Cowboy to Bandit.
Cowboy stays available as an opt-in adapter.

Why

Bandit is a pure-Elixir, actively developed Plug server. Decoupling Bypass from
Cowboy lets users pick their backend and keeps Bypass lighter by default.

Changes

  • Bypass.Adapter behaviour: start/4, stop/1, opaque server handle.
  • Bypass.Adapter.Bandit (default) and Bypass.Adapter.Cowboy (opt-in).
  • Select via config :bypass, adapter: Bypass.Adapter.Cowboy.
  • Bypass.Instance now stores an opaque server handle + adapter instead of a
    raw socket/ref; free port grabbed with :gen_tcp; direct :ranch dep dropped.
  • so_reuseport/0 moved to Bypass.Utils.
  • Bandit adapter uses the reuseport inet option (OTP 25+) and drops in-flight
    connections on shutdown (shutdown_timeout: 0) so a plug that calls
    Bypass.down/1 on itself does not deadlock.
  • :plug_cowboy is now optional; a clear error is raised if the Cowboy adapter
    is selected without it.

Breaking

  • Minimum Elixir 1.14 / OTP 25.
  • Bandit is the default. To keep Cowboy: add {:plug_cowboy, "~> 2.0"} and set
    config :bypass, adapter: Bypass.Adapter.Cowboy.

Security context

The Cowboy stack (cowboy + cowlib + plug_cowboy) has racked up roughly a
dozen CVEs, the bulk of them denial-of-service in the HTTP parsers:

  • CVE-2026-8466cowboy unbounded buffer accumulation in multipart header
    parsing (DoS), Cowboy 2.0.0–2.14.0.
  • CVE-2026-43966cowboy CRLF injection in HTTP headers (request/response
    splitting).
  • CVE-2026-32688plug_cowboy unauthenticated remote DoS via HTTP/2
    :scheme atom-table exhaustion.
  • CVE-2026-7790cowlib chunked transfer-encoding parser, O(N²) CPU/memory
    DoS, cowlib 0.6.0–2.16.0.
  • CVE-2026-43968cowlib CRLF injection / XSS in SSE frame building
    (cow_sse:event/1).
  • CVE-2026-43970cowlib SPDY decompression bomb (cow_spdy:inflate/2),
    memory-exhaustion DoS.
  • CVE-2023-44487 — HTTP/2 Rapid Reset (mitigated in Cowboy 2.11).
  • HTTP/2 CONTINUATION Flood (mitigated in Cowboy 2.12).

Fair caveat: Bypass is a test-only mock server bound to loopback, so these
CVEs barely touch how Bypass itself is used — the real driver is that Bandit is
pure-Elixir and actively maintained. But cutting a hard Cowboy dependency does
shed that whole advisory surface for anyone auditing their test dependency tree.

Testing

  • Full suite runs against both adapters via BYPASS_ADAPTER=bandit|cowboy;
    CI matrix exercises both. Local: bandit 36/36, cowboy 36/36, dialyzer clean.
  • A few teardown assertions were relaxed where they over-specified transport
    behaviour that legitimately differs between Bandit and modern Cowboy
    (e.g. in-flight down yields :closed on hard-close backends, :timeout on
    Cowboy 2.13+ graceful drain).

🤖 Generated with Claude Code

Introduce a `Bypass.Adapter` behaviour so the underlying HTTP server can be
switched via config, and make Bandit the default backend. Cowboy remains
available as an opt-in adapter backed by the now-optional `:plug_cowboy`
dependency.

- Add `Bypass.Adapter` behaviour (`start/4`, `stop/1`, opaque handle).
- Add `Bypass.Adapter.Bandit` (default) and `Bypass.Adapter.Cowboy` (opt-in).
- Select the adapter with `config :bypass, adapter: <module>`.
- Rework `Bypass.Instance` to store an opaque server handle + adapter instead
  of a raw socket/ref; grab the free port with `:gen_tcp` and drop the direct
  `:ranch` dependency.
- Move `so_reuseport/0` into `Bypass.Utils`.
- Bandit uses the `reuseport` inet option (OTP 25+) and drops in-flight
  connections on shutdown to avoid deadlocking a plug that calls `Bypass.down/1`
  on itself.
- Bump minimum Elixir to 1.14 / OTP 25; make `:plug_cowboy` optional.
- Parametrize the test suite via `BYPASS_ADAPTER` and run both adapters in CI.
- Relax teardown assertions that over-specified transport-level behaviour which
  legitimately differs between Bandit and modern Cowboy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hajto Hajto marked this pull request as draft July 10, 2026 13:38
- CI matrix: enumerate elixir/otp/os as an object axis crossed with the
  adapter axis so both backends actually run across all versions (the previous
  include-only list collapsed to two jobs on a single version).
- Raise the real version floor to Elixir 1.15 (plug/plug_cowboy require ~> 1.15);
  drop the 1.14 CI row. Update README and CHANGELOG accordingly.
- Bandit adapter: silence the per-start "Running ..." info log (startup_log:
  false); apply the `reuseport` inet option only on platforms that support it
  (mirroring Bypass.Utils.so_reuseport/0); set `reuseaddr: true` explicitly;
  document the listen-socket-close-on-teardown assumption in stop/1.
- Free-port probe: pass `reuseaddr: true` so re-binding a fixed port after
  traffic does not hit :eaddrinuse on platforms without SO_REUSEPORT.
- Bump version to 3.0.0 and document the potentially breaking transport-level
  behavior changes (interrupted-request error now depends on the backend).
- Drop the stale ranch_tcp comment in Bypass.Instance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hajto Hajto marked this pull request as ready for review July 11, 2026 10:42
Follow-ups from an example-app integration test and adversarial review.

ranch / Cowboy adapter:
- Re-pin ranch to ~> 1.8. The Cowboy adapter hands ranch a pre-opened listen
  socket (for SO_REUSEPORT), which ranch 2.x removed support for. Dropping the
  direct ranch dep let fresh consumers resolve ranch 2.x and break; a
  non-optional pin constrains their resolution back to 1.x.
- Open the pre-opened socket with :gen_tcp.listen/2 rather than
  :ranch_tcp.listen/1, whose argument shape differs across ranch 1.x/2.x.
- Raise a clear error if ranch 2.x is somehow loaded.

Instance:
- Fix Bypass.Instance.dispatch_awaiting_callers/1: it called GenServer.stop/1
  with :normal as the server ref (crashing with :noproc) and re-ran do_exit/1 on
  pre-teardown state (double-stopping the server). It now returns proper
  {:stop, :normal, state} / {:noreply, state} tuples and reuses the torn-down
  state. Add a regression test covering :on_exit deferred while a request is in
  flight.

Tooling:
- Add a `mix test.adapters` alias that runs the suite against both backends,
  each in a fresh VM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hajto

Hajto commented Jul 11, 2026

Copy link
Copy Markdown
Author

weather_client.zip
Added example that is using the modified version as proof of work.

Comment thread lib/bypass/instance.ex
{result, updated_state} = do_exit(state)
Enum.each(exit_callers, &GenServer.reply(&1, result))
GenServer.stop(:normal)
{:stop, :normal, updated_state}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can migrate this fix into its own PR.

Comment thread lib/bypass/instance.ex
{result, updated_state} = do_exit(state)
Enum.each(exit_callers, &GenServer.reply(&1, result))
GenServer.stop(:normal)
{:stop, :normal, updated_state}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:on_exit deferral — the bug fixed in dispatch_awaiting_callers/1

At the end of a test, :on_exit verifies expectations. If a request is still being handled
(a retained plug), the instance defers the check and parks the caller in
callers_awaiting_exit; the deferred exit is completed later by
dispatch_awaiting_callers/1 once the last plug returns.

The deferral itself was fine — the completion path was broken. It only fired when a request
was in flight at teardown (a narrow race), so it lurked.

Before — completion path crashes:

flowchart TD
  A[":on_exit while a request is in-flight"] --> B["defer: push caller to callers_awaiting_exit"]
  P["in-flight plug returns"] --> D["dispatch_awaiting_callers/1"]
  D --> E{"retained_plugs_count == 0 ?"}
  E -- no --> F["keep waiting"]
  E -- yes --> G["down-callers: do_down + reply :ok"]
  G --> H["do_exit(state) — uses PRE-teardown state<br/>⚠ stops the server a second time"]
  H --> I["reply exit-callers with result"]
  I --> J["GenServer.stop(:normal)<br/>⚠ :normal read as the server ref → exit :noproc"]
  J --> K["💥 instance crashes; :transient policy restarts it"]

  style H fill:#fbe6e3,stroke:#c0392b,color:#1c1a24
  style J fill:#fbe6e3,stroke:#c0392b,color:#1c1a24
  style K fill:#fbe6e3,stroke:#c0392b,color:#1c1a24
Loading

After — returns proper GenServer tuples and reuses the torn-down state:

flowchart TD
  A[":on_exit while a request is in-flight"] --> B["defer: push caller to callers_awaiting_exit"]
  P["in-flight plug returns"] --> D["dispatch_awaiting_callers/1"]
  D --> E{"retained_plugs_count == 0 ?"}
  E -- no --> F["{:noreply, state}"]
  E -- yes --> G{"any down-callers?"}
  G -- yes --> H["do_down + reply :ok<br/>state := torn-down"]
  G -- no --> I["state unchanged"]
  H --> J{"any exit-callers?"}
  I --> J
  J -- yes --> K["do_exit(state) on the torn-down state<br/>✓ single stop"]
  K --> L["reply result → {:stop, :normal, state}"]
  J -- no --> M["{:noreply, state}"]

  style K fill:#e0f2e9,stroke:#1f8a5b,color:#1c1a24
  style L fill:#e0f2e9,stroke:#1f8a5b,color:#1c1a24
Loading

Why it matters: verifying while a request is mid-flight is wrong two ways — the server gets
torn down under the running plug (request recorded as an error → false failure), or the plug
hasn't recorded its result yet (false :not_called). Deferral makes verification observe the
true outcome; this fix makes the deferred completion actually stop the instance cleanly instead
of crashing. Covered by a new regression test that drives :on_exit while a request is in flight.

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.

1 participant