Packages
bandit
1.10.4
1.12.0
1.11.1
1.11.0
1.10.4
1.10.3
1.10.2
1.10.1
1.10.0
retired
1.9.0
1.8.0
1.7.0
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.7
1.5.6
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.2
1.4.1
1.4.0
1.3.0
1.2.3
1.2.2
1.2.1
1.2.0
1.1.3
1.1.2
1.1.1
1.1.0
1.0.0
1.0.0-pre.18
1.0.0-pre.17
1.0.0-pre.16
1.0.0-pre.15
1.0.0-pre.14
1.0.0-pre.13
1.0.0-pre.12
1.0.0-pre.11
1.0.0-pre.10
1.0.0-pre.9
1.0.0-pre.8
1.0.0-pre.7
1.0.0-pre.6
1.0.0-pre.5
1.0.0-pre.4
1.0.0-pre.3
1.0.0-pre.2
1.0.0-pre.1
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.2.3
0.2.2
0.2.1
0.2.0
0.1.1
0.1.0
A pure-Elixir HTTP server built for Plug & WebSock apps
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/bandit/http1/socket.ex
defmodule Bandit.HTTP1.Socket do
@moduledoc false
# This module implements the lower level parts of HTTP/1 (roughly, the aspects of the protocol
# described in RFC 9112 as opposed to RFC 9110). It is similar in spirit to
# `Bandit.HTTP2.Stream` for HTTP/2, and indeed both implement the `Bandit.HTTPTransport`
# behaviour. An instance of this struct is maintained as the state of a `Bandit.HTTP1.Handler`
# process, and it moves an HTTP/1 request through its lifecycle by calling functions defined on
# this module. This state is also tracked within the `Bandit.Adapter` instance that backs
# Bandit's Plug API.
defstruct socket: nil,
buffer: <<>>,
read_state: :unread,
write_state: :unsent,
unread_content_length: nil,
body_encoding: nil,
version: :"HTTP/1.0",
send_buffer: nil,
request_connection_header: nil,
keepalive: nil,
opts: %{}
@typedoc "An HTTP/1 read state"
@type read_state :: :unread | :headers_read | :read
@typedoc "An HTTP/1 write state"
@type write_state :: :unsent | :writing | :chunking | :chunk_streaming | :sent
@typedoc "The information necessary to communicate to/from a socket"
@type t :: %__MODULE__{
socket: ThousandIsland.Socket.t(),
buffer: iodata(),
read_state: read_state(),
write_state: write_state(),
unread_content_length: non_neg_integer() | :chunked | nil,
body_encoding: nil | binary(),
version: nil | :"HTTP/1.1" | :"HTTP/1.0",
send_buffer: iolist(),
request_connection_header: binary(),
keepalive: boolean(),
opts: %{
required(:http_1) => Bandit.http_1_options()
}
}
defimpl Bandit.HTTPTransport do
def peer_data(%@for{} = socket), do: Bandit.SocketHelpers.peer_data(socket.socket)
def sock_data(%@for{} = socket), do: Bandit.SocketHelpers.sock_data(socket.socket)
def ssl_data(%@for{} = socket), do: Bandit.SocketHelpers.ssl_data(socket.socket)
def version(%@for{} = socket), do: socket.version
def read_headers(%@for{read_state: :unread} = socket) do
{method, request_target, socket} = do_read_request_line!(socket)
{headers, socket} = do_read_headers!(socket)
content_length = get_content_length!(headers)
body_encoding = Bandit.Headers.get_header(headers, "transfer-encoding")
request_connection_header = safe_downcase(Bandit.Headers.get_header(headers, "connection"))
socket = %{socket | request_connection_header: request_connection_header}
case {content_length, body_encoding} do
{nil, nil} ->
# No body, so just go straight to 'read'
{:ok, method, request_target, headers, %{socket | read_state: :read}}
{content_length, nil} ->
socket = %{socket | read_state: :headers_read, unread_content_length: content_length}
{:ok, method, request_target, headers, socket}
{nil, body_encoding} ->
socket = %{socket | read_state: :headers_read, body_encoding: body_encoding}
{:ok, method, request_target, headers, socket}
{_content_length, _body_encoding} ->
request_error!(
"Request cannot contain both 'content-length' and 'transfer-encoding' (RFC9112ยง6.3.3)"
)
end
end
defp do_read_request_line!(socket, request_target \\ nil) do
packet_size = Keyword.get(socket.opts.http_1, :max_request_line_length, 10_000)
case :erlang.decode_packet(:http_bin, socket.buffer, packet_size: packet_size) do
{:more, _len} ->
chunk = read_available_for_header!(socket.socket)
do_read_request_line!(%{socket | buffer: socket.buffer <> chunk}, request_target)
{:ok, {:http_request, method, request_target, version}, rest} ->
version = get_version!(version)
# decode_packet is inconsistent about atom/string method returns
method = to_string(method)
request_target = resolve_request_target!(request_target, method)
socket = %{socket | buffer: rest, version: version}
{method, request_target, socket}
{:ok, {:http_error, reason}, _rest} ->
request_error!("Request line HTTP error: #{inspect(reason)}")
{:error, :invalid} ->
request_error!("Request URI is too long", :request_uri_too_long)
{:error, reason} ->
request_error!("Request line unknown error: #{inspect(reason)}")
end
end
defp get_version!({1, 1}), do: :"HTTP/1.1"
defp get_version!({1, 0}), do: :"HTTP/1.0"
defp get_version!(other), do: request_error!("Invalid HTTP version: #{inspect(other)}")
# Unwrap different request_targets returned by :erlang.decode_packet/3
defp resolve_request_target!({:abs_path, path}, _), do: {nil, nil, nil, path}
defp resolve_request_target!({:absoluteURI, scheme, host, :undefined, path}, _),
do: {to_string(scheme), host, nil, path}
defp resolve_request_target!({:absoluteURI, scheme, host, port, path}, _),
do: {to_string(scheme), host, port, path}
defp resolve_request_target!(:*, "OPTIONS"), do: {nil, nil, nil, :*}
defp resolve_request_target!({:scheme, scheme, port}, "CONNECT"),
do: {nil, scheme, port, nil}
defp resolve_request_target!(_request_target, _method),
do: request_error!("Unsupported request target (RFC9112ยง3.2)")
defp do_read_headers!(socket, headers \\ []) do
packet_size = Keyword.get(socket.opts.http_1, :max_header_length, 10_000)
case :erlang.decode_packet(:httph_bin, socket.buffer, packet_size: packet_size) do
{:more, _len} ->
chunk = read_available_for_header!(socket.socket)
socket = %{socket | buffer: socket.buffer <> chunk}
do_read_headers!(socket, headers)
{:ok, {:http_header, _, header, _, value}, rest} ->
socket = %{socket | buffer: rest}
headers = [{header |> to_string() |> String.downcase(:ascii), value} | headers]
if length(headers) <= Keyword.get(socket.opts.http_1, :max_header_count, 50) do
do_read_headers!(socket, headers)
else
request_error!("Too many headers", :request_header_fields_too_large)
end
{:ok, :http_eoh, rest} ->
socket = %{socket | read_state: :headers_read, buffer: rest}
{Enum.reverse(headers), socket}
{:ok, {:http_error, reason}, _rest} ->
request_error!("Header read HTTP error: #{inspect(reason)}")
{:error, :invalid} ->
request_error!("Header too long", :request_header_fields_too_large)
{:error, reason} ->
request_error!("Header read unknown error: #{inspect(reason)}")
end
end
defp get_content_length!(headers) do
case Bandit.Headers.get_content_length(headers) do
{:ok, content_length} -> content_length
{:error, reason} -> request_error!("Content length unknown error: #{inspect(reason)}")
end
end
def read_data(
%@for{read_state: :headers_read, unread_content_length: unread_content_length} = socket,
opts
)
when is_number(unread_content_length) do
{to_return, buffer, remaining_unread_content_length} =
do_read_content_length_data!(socket.socket, socket.buffer, unread_content_length, opts)
socket = %{socket | buffer: buffer, unread_content_length: remaining_unread_content_length}
if remaining_unread_content_length == 0 do
{:ok, to_return, %{socket | read_state: :read}}
else
{:more, to_return, socket}
end
end
def read_data(%@for{read_state: :headers_read, body_encoding: "chunked"} = socket, opts) do
read_size = Keyword.get(opts, :read_length, 1_000_000)
read_timeout = Keyword.get(opts, :read_timeout)
{body, buffer} =
do_read_chunked_data!(socket.socket, socket.buffer, <<>>, read_size, read_timeout)
body = IO.iodata_to_binary(body)
{:ok, body, %{socket | read_state: :read, buffer: buffer}}
end
def read_data(%@for{read_state: :headers_read, body_encoding: body_encoding}, _opts)
when not is_nil(body_encoding) do
request_error!("Unsupported transfer-encoding")
end
def read_data(%@for{} = socket, _opts), do: {:ok, <<>>, socket}
@dialyzer {:no_improper_lists, do_read_content_length_data!: 4}
defp do_read_content_length_data!(socket, buffer, unread_content_length, opts) do
max_to_return = min(unread_content_length, Keyword.get(opts, :length, 8_000_000))
cond do
max_to_return == 0 ->
# We have already satisfied our content length
{<<>>, buffer, unread_content_length}
byte_size(buffer) >= max_to_return ->
# We can satisfy the read request entirely from our buffer
<<to_return::binary-size(max_to_return), rest::binary>> = buffer
{to_return, rest, unread_content_length - max_to_return}
byte_size(buffer) < max_to_return ->
# We need to read off the wire
read_size = Keyword.get(opts, :read_length, 1_000_000)
read_timeout = Keyword.get(opts, :read_timeout)
to_return =
read!(socket, max_to_return - byte_size(buffer), [buffer], read_size, read_timeout)
|> IO.iodata_to_binary()
# We may have read more than we need to return
if byte_size(to_return) >= max_to_return do
<<to_return::binary-size(max_to_return), rest::binary>> = to_return
{to_return, rest, unread_content_length - max_to_return}
else
{to_return, <<>>, unread_content_length - byte_size(to_return)}
end
end
end
@dialyzer {:no_improper_lists, do_read_chunked_data!: 5}
defp do_read_chunked_data!(socket, buffer, body, read_size, read_timeout) do
case :binary.split(buffer, "\r\n") do
["0", "\r\n" <> rest] ->
# We should be reading (and ignoring) trailers here
{IO.iodata_to_binary(body), rest}
[chunk_size, rest] ->
chunk_size = String.to_integer(chunk_size, 16)
case rest do
<<next_chunk::binary-size(chunk_size), ?\r, ?\n, rest::binary>> ->
do_read_chunked_data!(socket, rest, [body, next_chunk], read_size, read_timeout)
_ ->
to_read = chunk_size - byte_size(rest)
if to_read > 0 do
iolist = read!(socket, to_read, [], read_size, read_timeout)
buffer = IO.iodata_to_binary([buffer | iolist])
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
else
chunk = read_available!(socket, read_timeout)
buffer = buffer <> chunk
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
end
end
_ ->
chunk = read_available!(socket, read_timeout)
buffer = buffer <> chunk
do_read_chunked_data!(socket, buffer, body, read_size, read_timeout)
end
end
##################
# Internal Reading
##################
@compile {:inline, read_available_for_header!: 1}
@spec read_available_for_header!(ThousandIsland.Socket.t()) :: binary()
defp read_available_for_header!(socket) do
case ThousandIsland.Socket.recv(socket, 0) do
{:ok, chunk} -> chunk
{:error, reason} -> socket_error!(reason)
end
end
@compile {:inline, read_available!: 2}
@spec read_available!(ThousandIsland.Socket.t(), timeout()) :: binary()
defp read_available!(socket, read_timeout) do
case ThousandIsland.Socket.recv(socket, 0, read_timeout) do
{:ok, chunk} -> chunk
{:error, :timeout} -> <<>>
{:error, reason} -> socket_error!(reason)
end
end
@dialyzer {:no_improper_lists, read!: 5}
@spec read!(
ThousandIsland.Socket.t(),
non_neg_integer(),
iolist(),
non_neg_integer(),
timeout()
) ::
iolist()
defp read!(socket, to_read, already_read, read_size, read_timeout) do
case ThousandIsland.Socket.recv(socket, min(to_read, read_size), read_timeout) do
{:ok, chunk} ->
remaining_bytes = to_read - byte_size(chunk)
if remaining_bytes > 0 do
read!(socket, remaining_bytes, [already_read | chunk], read_size, read_timeout)
else
[already_read | chunk]
end
{:error, :timeout} ->
handle_timeout_with_disconnect_check!(socket)
{:error, reason} ->
socket_error!(reason)
end
end
# After a timeout, check if the peer is still connected. If not, this is
# likely a client disconnect that manifested as a timeout.
# We raise TransportError for disconnects and HTTPError for genuine timeouts.
# Use a non-blocking recv (timeout: 0) to detect closed connections.
@spec handle_timeout_with_disconnect_check!(ThousandIsland.Socket.t()) :: no_return()
defp handle_timeout_with_disconnect_check!(socket) do
case ThousandIsland.Socket.recv(socket, 0, 0) do
{:error, :timeout} ->
# Socket is still open but no data - genuine timeout
request_error!("Body read timeout", :request_timeout)
{:error, reason} ->
# Socket error (e.g., :closed) - client disconnected
socket_error!(reason)
{:ok, _data} ->
# Unexpected: data arrived just after timeout. Treat as timeout
# since we already committed to the timeout path.
request_error!("Body read timeout", :request_timeout)
end
end
def send_headers(%@for{write_state: :unsent} = socket, status, headers, body_disposition) do
resp_line = "#{socket.version} #{status} #{Plug.Conn.Status.reason_phrase(status)}\r\n"
{headers, socket} = handle_keepalive(status, headers, socket)
has_content_length = Bandit.Headers.get_header(headers, "content-length") != nil
case body_disposition do
:raw ->
# This is an optimization for the common case of sending a non-encoded body (or file),
# and coalesces the header and body send calls into a single ThousandIsland.Socket.send/2
# call. This makes a _substantial_ difference in practice
%{socket | write_state: :writing, send_buffer: [resp_line | encode_headers(headers)]}
:chunk_encoded when not has_content_length ->
headers = [{"transfer-encoding", "chunked"} | headers]
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :chunking}
:chunk_encoded when has_content_length ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :chunk_streaming}
:no_body ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :sent}
:inform ->
send!(socket.socket, [resp_line | encode_headers(headers)])
%{socket | write_state: :unsent}
end
end
defp handle_keepalive(status, headers, socket) do
response_connection_header = safe_downcase(Bandit.Headers.get_header(headers, "connection"))
# Per RFC9112ยง9.3
cond do
status in 100..199 ->
{headers, socket}
socket.request_connection_header == "close" || response_connection_header == "close" ->
{headers, %{socket | keepalive: false}}
socket.version == :"HTTP/1.1" ->
{headers, %{socket | keepalive: true}}
socket.version == :"HTTP/1.0" && socket.request_connection_header == "keep-alive" ->
{[{"connection", "keep-alive"} | headers], %{socket | keepalive: true}}
true ->
{[{"connection", "close"} | headers], %{socket | keepalive: false}}
end
end
defp safe_downcase(str) when is_binary(str), do: String.downcase(str, :ascii)
defp safe_downcase(str), do: str
defp encode_headers(headers) do
headers
|> Enum.map(fn {k, v} -> [k, ": ", v, "\r\n"] end)
|> then(&[&1 | ["\r\n"]])
end
def send_data(%@for{write_state: :writing} = socket, data, end_request) do
send!(socket.socket, [socket.send_buffer | data])
write_state = if end_request, do: :sent, else: :writing
%{socket | write_state: write_state, send_buffer: []}
end
def send_data(%@for{write_state: :chunking} = socket, data, end_request) do
byte_size = data |> IO.iodata_length()
send!(socket.socket, [Integer.to_string(byte_size, 16), "\r\n", data, "\r\n"])
write_state = if end_request, do: :sent, else: :chunking
%{socket | write_state: write_state}
end
def send_data(%@for{write_state: :chunk_streaming} = socket, data, end_request) do
send!(socket.socket, data)
write_state = if end_request, do: :sent, else: :chunk_streaming
%{socket | write_state: write_state}
end
def sendfile(%@for{write_state: :writing} = socket, path, offset, length) do
send!(socket.socket, socket.send_buffer)
case ThousandIsland.Socket.sendfile(socket.socket, path, offset, length) do
{:ok, _bytes_written} -> %{socket | write_state: :sent}
{:error, reason} -> socket_error!(reason)
end
end
@spec send!(ThousandIsland.Socket.t(), iolist()) :: :ok | no_return()
defp send!(socket, payload) do
case ThousandIsland.Socket.send(socket, payload) do
:ok ->
:ok
{:error, reason} ->
# Prevent error handlers from possibly trying to send again
send(self(), {:plug_conn, :sent})
socket_error!(reason)
end
end
def ensure_completed(%@for{read_state: :read} = socket), do: socket
def ensure_completed(%@for{keepalive: false} = socket), do: socket
def ensure_completed(%@for{} = socket) do
case read_data(socket, []) do
{:ok, _data, socket} -> socket
{:more, _data, _socket} -> request_error!("Unable to read remaining data in request body")
end
rescue
e in [Bandit.HTTPError] ->
# If we got a timeout during ensure_completed (draining the body),
# check if the client actually disconnected.
if e.plug_status == :request_timeout do
handle_timeout_with_disconnect_check!(socket.socket)
else
reraise e, __STACKTRACE__
end
end
def supported_upgrade?(%@for{} = _socket, protocol), do: protocol == :websocket
def send_on_error(%@for{}, %Bandit.TransportError{}), do: :ok
def send_on_error(%@for{} = socket, error) do
receive do
{:plug_conn, :sent} -> %{socket | write_state: :sent}
after
0 ->
status = error |> Plug.Exception.status() |> Plug.Conn.Status.code()
try do
send_headers(socket, status, [{"connection", "close"}], :no_body)
rescue
_e in [Bandit.TransportError, Bandit.HTTPError] -> :ok
end
end
end
@spec request_error!(term()) :: no_return()
@spec request_error!(term(), Plug.Conn.status()) :: no_return()
defp request_error!(reason, plug_status \\ :bad_request) do
raise Bandit.HTTPError, message: to_string(reason), plug_status: plug_status
end
@spec socket_error!(term()) :: no_return()
defp socket_error!(reason) do
raise Bandit.TransportError, message: "Unrecoverable error: #{reason}", error: reason
end
end
end