HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
777 lines
35 KiB
C++
777 lines
35 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// HTTP/1.1 wire format — RFC 9112 syntax on top of the RFC 9110 semantics
|
|
// already modelled by :HTTP (HTTPRequest / HTTPResponse are shared with the
|
|
// HTTP/3 stack, so a route handler written for one works unchanged on the
|
|
// other).
|
|
//
|
|
// Scope:
|
|
// - request / response serialisation, with the framing headers
|
|
// (host, content-length, transfer-encoding, connection) owned by the
|
|
// serialiser rather than the caller
|
|
// - an incremental parser that consumes arbitrary socket chunks and
|
|
// surfaces one message at a time, so a single connection can carry a
|
|
// keep-alive series (and pipelined requests)
|
|
// - both body framings that matter in practice: content-length and
|
|
// `Transfer-Encoding: chunked` (plus read-to-EOF for responses that
|
|
// use neither, RFC 9112 §6.3 item 8)
|
|
//
|
|
// Deliberately rejected rather than guessed at, because getting these
|
|
// wrong is how request smuggling happens (RFC 9112 §11.2):
|
|
// - Content-Length together with Transfer-Encoding
|
|
// - conflicting duplicate Content-Length values
|
|
// - whitespace between a field name and its colon
|
|
// - CR/LF inside a field value we are asked to serialise
|
|
//
|
|
// This partition has no transport dependency; ClientHTTP1 and ListenerHTTP1
|
|
// pair it with :ClientTCP / :ListenerTCP.
|
|
|
|
export module Crafter.Network:HTTP1;
|
|
import std;
|
|
import :HTTP;
|
|
|
|
namespace Crafter::HTTP1 {
|
|
// ---------------- Versions ----------------
|
|
export inline constexpr std::string_view kVersion10 = "HTTP/1.0";
|
|
export inline constexpr std::string_view kVersion11 = "HTTP/1.1";
|
|
|
|
// ---------------- Errors ----------------
|
|
// Thrown for any input we refuse to interpret. Callers turn this into a
|
|
// 400 (server side) or propagate it out of Send() (client side).
|
|
export class HTTP1ProtocolError : public std::runtime_error {
|
|
public:
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
|
|
// ---------------- Lexical helpers (RFC 9110 §5.6) ----------------
|
|
inline bool IsTChar(unsigned char c) {
|
|
constexpr std::string_view extra = "!#$%&'*+-.^_`|~";
|
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
|
|| extra.find(static_cast<char>(c)) != std::string_view::npos;
|
|
}
|
|
|
|
inline bool IsToken(std::string_view s) {
|
|
return !s.empty() && std::ranges::all_of(s, [](char c) {
|
|
return IsTChar(static_cast<unsigned char>(c));
|
|
});
|
|
}
|
|
|
|
inline std::string ToLowerAscii(std::string_view s) {
|
|
std::string out(s);
|
|
std::ranges::transform(out, out.begin(), [](unsigned char c) {
|
|
return static_cast<char>(c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
inline bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
|
|
return std::ranges::equal(a, b, [](unsigned char x, unsigned char y) {
|
|
auto lower = [](unsigned char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; };
|
|
return lower(x) == lower(y);
|
|
});
|
|
}
|
|
|
|
// Optional whitespace — SP / HTAB only (RFC 9110 §5.6.3).
|
|
inline std::string_view TrimOWS(std::string_view s) {
|
|
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
|
|
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
|
|
return s;
|
|
}
|
|
|
|
// Split a comma-separated field value into trimmed tokens (#rule).
|
|
export inline std::vector<std::string_view> SplitCommaList(std::string_view value) {
|
|
std::vector<std::string_view> parts;
|
|
while (!value.empty()) {
|
|
auto comma = value.find(',');
|
|
parts.push_back(TrimOWS(value.substr(0, comma)));
|
|
if (comma == std::string_view::npos) break;
|
|
value.remove_prefix(comma + 1);
|
|
}
|
|
std::erase_if(parts, [](std::string_view p) { return p.empty(); });
|
|
return parts;
|
|
}
|
|
|
|
// Whether a comma-separated field value contains `token`, compared
|
|
// case-insensitively — the shape of `Connection`, `Expect`,
|
|
// `Transfer-Encoding` and friends.
|
|
export inline bool HasToken(std::string_view fieldValue, std::string_view token) {
|
|
return std::ranges::any_of(SplitCommaList(fieldValue), [token](std::string_view candidate) {
|
|
return EqualsIgnoreCase(candidate, token);
|
|
});
|
|
}
|
|
|
|
// ---------------- Status reason phrases ----------------
|
|
// HTTP/1.1 keeps a reason phrase in the status line. It carries no
|
|
// meaning (RFC 9112 §4) but some ancient clients log it, and an empty
|
|
// one trips up a few proxies, so emit the registered text where we know
|
|
// it and a generic class name otherwise.
|
|
export inline std::string_view ReasonPhrase(std::string_view status) {
|
|
static const std::unordered_map<std::string_view, std::string_view> known = {
|
|
{"100", "Continue"}, {"101", "Switching Protocols"}, {"103", "Early Hints"},
|
|
{"200", "OK"}, {"201", "Created"}, {"202", "Accepted"}, {"204", "No Content"},
|
|
{"206", "Partial Content"},
|
|
{"301", "Moved Permanently"}, {"302", "Found"}, {"303", "See Other"},
|
|
{"304", "Not Modified"}, {"307", "Temporary Redirect"}, {"308", "Permanent Redirect"},
|
|
{"400", "Bad Request"}, {"401", "Unauthorized"}, {"403", "Forbidden"},
|
|
{"404", "Not Found"}, {"405", "Method Not Allowed"}, {"408", "Request Timeout"},
|
|
{"409", "Conflict"}, {"411", "Length Required"}, {"413", "Content Too Large"},
|
|
{"414", "URI Too Long"}, {"415", "Unsupported Media Type"},
|
|
{"426", "Upgrade Required"}, {"429", "Too Many Requests"},
|
|
{"431", "Request Header Fields Too Large"},
|
|
{"500", "Internal Server Error"}, {"501", "Not Implemented"},
|
|
{"502", "Bad Gateway"}, {"503", "Service Unavailable"}, {"504", "Gateway Timeout"},
|
|
{"505", "HTTP Version Not Supported"},
|
|
};
|
|
if (auto it = known.find(status); it != known.end()) return it->second;
|
|
if (status.size() == 3) {
|
|
switch (status[0]) {
|
|
case '1': return "Informational";
|
|
case '2': return "Success";
|
|
case '3': return "Redirection";
|
|
case '4': return "Client Error";
|
|
case '5': return "Server Error";
|
|
default: break;
|
|
}
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
// IMF-fixdate, the only date format an HTTP/1.1 sender may generate
|
|
// (RFC 9110 §5.6.7). Always UTC.
|
|
export inline std::string FormatHTTPDate(std::chrono::system_clock::time_point when) {
|
|
return std::format("{:%a, %d %b %Y %H:%M:%S} GMT",
|
|
std::chrono::floor<std::chrono::seconds>(when));
|
|
}
|
|
|
|
// ---------------- Serialisation ----------------
|
|
export struct RequestOptions {
|
|
// false emits `connection: close`, telling the peer we will not
|
|
// reuse the connection after this exchange.
|
|
bool keepAlive = true;
|
|
};
|
|
|
|
export struct ResponseOptions {
|
|
bool keepAlive = true;
|
|
// HTTP/1.0 peers have no keep-alive by default, so reuse has to be
|
|
// stated explicitly for them (RFC 9112 §9.3).
|
|
bool explicitKeepAlive = false;
|
|
// Response to HEAD: send the header section, including the
|
|
// content-length the body *would* have had, but no body.
|
|
bool omitBody = false;
|
|
};
|
|
|
|
// Headers whose value is derived from the message rather than copied
|
|
// from the caller's map — emitting the caller's copy as well would
|
|
// produce a duplicate, and for content-length a smuggling vector.
|
|
inline bool IsFramingHeader(std::string_view lowerName) {
|
|
return lowerName == "host" || lowerName == "content-length"
|
|
|| lowerName == "transfer-encoding" || lowerName == "connection";
|
|
}
|
|
|
|
inline void ValidateFieldName(std::string_view name) {
|
|
if (!IsToken(name)) {
|
|
throw HTTP1ProtocolError("invalid header field name: '" + std::string(name) + "'");
|
|
}
|
|
}
|
|
|
|
// A CR or LF smuggled through a field value would split the message.
|
|
inline void ValidateFieldValue(std::string_view name, std::string_view value) {
|
|
if (value.find_first_of("\r\n") != std::string_view::npos) {
|
|
throw HTTP1ProtocolError("header '" + std::string(name) + "' contains CR/LF");
|
|
}
|
|
}
|
|
|
|
inline void AppendHeaders(std::string& out,
|
|
const std::unordered_map<std::string, std::string>& headers) {
|
|
for (const auto& [name, value] : headers) {
|
|
// HTTP/3 pseudo-headers have no HTTP/1.1 equivalent — they are
|
|
// already represented by the start line.
|
|
if (name.empty() || name.front() == ':') continue;
|
|
std::string lower = ToLowerAscii(name);
|
|
if (IsFramingHeader(lower)) continue;
|
|
ValidateFieldName(lower);
|
|
ValidateFieldValue(lower, value);
|
|
out += lower;
|
|
out += ": ";
|
|
out += value;
|
|
out += "\r\n";
|
|
}
|
|
}
|
|
|
|
// Serialise a request in origin-form. `authority` becomes the mandatory
|
|
// Host header (RFC 9112 §3.2); ClientHTTP1 fills it in from the
|
|
// connection when the caller left it empty.
|
|
export inline std::string SerializeRequest(const HTTPRequest& request, RequestOptions options = {}) {
|
|
const std::string method = request.method.empty() ? std::string("GET") : request.method;
|
|
const std::string target = request.path.empty() ? std::string("/") : request.path;
|
|
if (!IsToken(method)) throw HTTP1ProtocolError("invalid request method: '" + method + "'");
|
|
if (target.find_first_of(" \r\n") != std::string::npos) {
|
|
throw HTTP1ProtocolError("invalid request target: '" + target + "'");
|
|
}
|
|
ValidateFieldValue("host", request.authority);
|
|
|
|
std::string out;
|
|
out.reserve(256 + request.headers.size() * 32 + request.body.size());
|
|
out += method;
|
|
out += ' ';
|
|
out += target;
|
|
out += " HTTP/1.1\r\n";
|
|
out += "host: ";
|
|
out += request.authority;
|
|
out += "\r\n";
|
|
AppendHeaders(out, request.headers);
|
|
// A body always gets an explicit length; so does a method that is
|
|
// normally expected to carry one, since some servers answer 411 for
|
|
// a bodyless POST that omits it.
|
|
if (!request.body.empty() || method == "POST" || method == "PUT" || method == "PATCH") {
|
|
out += "content-length: ";
|
|
out += std::to_string(request.body.size());
|
|
out += "\r\n";
|
|
}
|
|
if (!options.keepAlive) out += "connection: close\r\n";
|
|
out += "\r\n";
|
|
out += request.body;
|
|
return out;
|
|
}
|
|
|
|
export inline std::string SerializeResponse(const HTTPResponse& response, ResponseOptions options = {}) {
|
|
std::string status = response.status.empty() ? std::string("200") : response.status;
|
|
if (status.size() != 3 || !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
|
|
throw HTTP1ProtocolError("status must be three digits, got '" + status + "'");
|
|
}
|
|
|
|
std::string out;
|
|
out.reserve(256 + response.headers.size() * 32 + response.body.size());
|
|
out += "HTTP/1.1 ";
|
|
out += status;
|
|
out += ' ';
|
|
out += ReasonPhrase(status);
|
|
out += "\r\n";
|
|
AppendHeaders(out, response.headers);
|
|
// 1xx / 204 / 304 must not carry a content-length at all — sending
|
|
// one makes some clients wait for a body that never arrives.
|
|
const bool bodyless = status[0] == '1' || status == "204" || status == "304";
|
|
if (!bodyless) {
|
|
out += "content-length: ";
|
|
out += std::to_string(response.body.size());
|
|
out += "\r\n";
|
|
}
|
|
if (!options.keepAlive) out += "connection: close\r\n";
|
|
else if (options.explicitKeepAlive) out += "connection: keep-alive\r\n";
|
|
out += "\r\n";
|
|
if (!options.omitBody && !bodyless) out += response.body;
|
|
return out;
|
|
}
|
|
|
|
// The interim response a server owes a client that sent
|
|
// `Expect: 100-continue` before it will send its body (RFC 9110 §10.1.1).
|
|
export inline std::string SerializeContinue() {
|
|
return "HTTP/1.1 100 Continue\r\n\r\n";
|
|
}
|
|
|
|
// ---------------- Incremental parser ----------------
|
|
export enum class MessageKind { Request, Response };
|
|
|
|
// Bounds on what we are willing to buffer before declaring the peer
|
|
// hostile. maxBody applies to the decoded body, so it also caps a
|
|
// chunked stream that never ends.
|
|
export struct MessageLimits {
|
|
std::size_t maxStartLine = 8 * 1024;
|
|
std::size_t maxHeaderSection = 64 * 1024;
|
|
std::uint64_t maxBody = 512ull * 1024 * 1024;
|
|
};
|
|
|
|
// Feed socket bytes in with Feed(); call Finish() when the peer closes
|
|
// its send side. Complete() then reports whether a whole message is
|
|
// available, and Take*() moves it out. Reset() rearms for the next
|
|
// message on the same connection, keeping any bytes that already
|
|
// belonged to it (HTTP/1.1 pipelining).
|
|
export class MessageParser {
|
|
public:
|
|
explicit MessageParser(MessageKind kind, MessageLimits limits = {})
|
|
: kind(kind), limits(limits) {}
|
|
|
|
// Responses are framed partly by the request that provoked them: a
|
|
// HEAD response has no body no matter what its content-length says.
|
|
// Tell the parser before feeding it bytes.
|
|
void SetRequestMethod(std::string_view requestMethod) {
|
|
headRequest = EqualsIgnoreCase(requestMethod, "HEAD");
|
|
}
|
|
|
|
void Feed(const char* data, std::size_t size) {
|
|
if (size != 0) buffer.append(data, size);
|
|
Advance();
|
|
}
|
|
|
|
void Feed(std::span<const char> bytes) { Feed(bytes.data(), bytes.size()); }
|
|
|
|
// The peer closed. Completes a read-to-EOF body; anything else
|
|
// that is mid-message is a truncation error.
|
|
void Finish() {
|
|
eof = true;
|
|
Advance();
|
|
}
|
|
|
|
bool HeadersComplete() const noexcept { return headersDone; }
|
|
bool Complete() const noexcept { return state == State::Done; }
|
|
|
|
// True while the peer is waiting for `100 Continue` before it sends
|
|
// the body. Clear it with ContinueSent() once the interim response
|
|
// is on the wire.
|
|
bool ExpectsContinue() const noexcept { return expectContinue && !Complete(); }
|
|
void ContinueSent() noexcept { expectContinue = false; }
|
|
|
|
// Whether the connection may carry another message after this one.
|
|
// Meaningful once HeadersComplete().
|
|
bool KeepAlive() const noexcept { return keepAlive; }
|
|
|
|
// No partial message buffered — a clean point to stop reading.
|
|
bool AtMessageBoundary() const noexcept {
|
|
return state == State::StartLine && pos == buffer.size() && !headersDone;
|
|
}
|
|
|
|
std::string_view Method() const noexcept { return method; }
|
|
std::string_view Version() const noexcept { return version; }
|
|
std::string_view Status() const noexcept { return status; }
|
|
|
|
HTTPRequest TakeRequest() {
|
|
if (state != State::Done) throw HTTP1ProtocolError("request is not complete");
|
|
HTTPRequest request;
|
|
request.method = std::move(method);
|
|
request.path = std::move(path);
|
|
request.scheme = scheme.empty() ? std::string("http") : std::move(scheme);
|
|
// Absolute-form targets carry their own authority and win over
|
|
// Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way
|
|
// it lives in the named field, matching the HTTP/3 shape, so it
|
|
// is removed from the header map.
|
|
if (authority.empty()) {
|
|
if (auto it = headers.find("host"); it != headers.end()) authority = it->second;
|
|
}
|
|
headers.erase("host");
|
|
request.authority = std::move(authority);
|
|
request.headers = std::move(headers);
|
|
request.body = std::move(body);
|
|
return request;
|
|
}
|
|
|
|
HTTPResponse TakeResponse() {
|
|
if (state != State::Done) throw HTTP1ProtocolError("response is not complete");
|
|
HTTPResponse response;
|
|
response.status = std::move(status);
|
|
response.headers = std::move(headers);
|
|
response.body = std::move(body);
|
|
return response;
|
|
}
|
|
|
|
// Rearm for the next message. Any bytes past the one just taken are
|
|
// retained and parsed immediately, so a pipelined request can be
|
|
// Complete() as soon as this returns.
|
|
void Reset() {
|
|
buffer.erase(0, pos);
|
|
pos = 0;
|
|
state = State::StartLine;
|
|
method.clear();
|
|
path.clear();
|
|
scheme.clear();
|
|
authority.clear();
|
|
version.clear();
|
|
status.clear();
|
|
lastHeaderName.clear();
|
|
headers.clear();
|
|
body.clear();
|
|
headerBytes = 0;
|
|
bodyRemaining = 0;
|
|
chunkRemaining = 0;
|
|
bodyMode = BodyMode::None;
|
|
headersDone = false;
|
|
expectContinue = false;
|
|
keepAlive = true;
|
|
headRequest = false;
|
|
Advance();
|
|
}
|
|
|
|
private:
|
|
enum class State { StartLine, Headers, Body, ChunkSize, ChunkData, ChunkTrailingCRLF, Trailers, Done };
|
|
enum class BodyMode { None, Length, Chunked, UntilClose };
|
|
|
|
// Read one CRLF-terminated line. A bare LF is accepted as well: it
|
|
// is not legal to generate, but recipients may recognise it
|
|
// (RFC 9112 §2.2) and some embedded clients still emit it.
|
|
std::optional<std::string_view> TryReadLine(std::size_t maxLength) {
|
|
const std::size_t nl = buffer.find('\n', pos);
|
|
if (nl == std::string::npos) {
|
|
if (buffer.size() - pos > maxLength) {
|
|
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
|
|
}
|
|
if (eof && buffer.size() != pos) {
|
|
throw HTTP1ProtocolError("connection closed mid-message");
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
std::size_t end = nl;
|
|
if (end > pos && buffer[end - 1] == '\r') --end;
|
|
if (end - pos > maxLength) {
|
|
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
|
|
}
|
|
std::string_view line(buffer.data() + pos, end - pos);
|
|
pos = nl + 1;
|
|
return line;
|
|
}
|
|
|
|
void ParseRequestLine(std::string_view line) {
|
|
const auto first = line.find(' ');
|
|
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed request line");
|
|
const auto second = line.find(' ', first + 1);
|
|
if (second == std::string_view::npos) {
|
|
// HTTP/0.9 simple-request. Not supported, and accepting it
|
|
// would leave us unable to answer with headers.
|
|
throw HTTP1ProtocolError("malformed request line (missing version)");
|
|
}
|
|
method = std::string(line.substr(0, first));
|
|
const std::string_view target = line.substr(first + 1, second - first - 1);
|
|
version = std::string(line.substr(second + 1));
|
|
if (!IsToken(method)) throw HTTP1ProtocolError("invalid method token");
|
|
if (target.empty()) throw HTTP1ProtocolError("empty request target");
|
|
CheckVersion();
|
|
|
|
if (target.front() == '/') {
|
|
path = std::string(target); // origin-form
|
|
} else if (target == "*") {
|
|
path = "*"; // asterisk-form (OPTIONS)
|
|
} else if (const auto sep = target.find("://"); sep != std::string_view::npos) {
|
|
// absolute-form, as sent to a proxy.
|
|
scheme = ToLowerAscii(target.substr(0, sep));
|
|
const std::string_view rest = target.substr(sep + 3);
|
|
const auto slash = rest.find('/');
|
|
authority = std::string(rest.substr(0, slash));
|
|
path = slash == std::string_view::npos ? std::string("/") : std::string(rest.substr(slash));
|
|
} else {
|
|
// authority-form — only legal for CONNECT, which this
|
|
// listener does not implement.
|
|
authority = std::string(target);
|
|
path = std::string(target);
|
|
}
|
|
}
|
|
|
|
void ParseStatusLine(std::string_view line) {
|
|
const auto first = line.find(' ');
|
|
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed status line");
|
|
version = std::string(line.substr(0, first));
|
|
CheckVersion();
|
|
std::string_view rest = TrimOWS(line.substr(first + 1));
|
|
const auto space = rest.find(' ');
|
|
status = std::string(space == std::string_view::npos ? rest : rest.substr(0, space));
|
|
if (status.size() != 3
|
|
|| !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
|
|
throw HTTP1ProtocolError("malformed status code '" + status + "'");
|
|
}
|
|
// The reason phrase is discarded: it carries no semantics and
|
|
// HTTPResponse has nowhere to put it.
|
|
}
|
|
|
|
void CheckVersion() {
|
|
if (version != kVersion11 && version != kVersion10) {
|
|
throw HTTP1ProtocolError("unsupported HTTP version '" + version + "'");
|
|
}
|
|
}
|
|
|
|
void AddHeader(std::string name, std::string_view value) {
|
|
auto [it, inserted] = headers.try_emplace(std::move(name), std::string(value));
|
|
if (!inserted) {
|
|
// Repeated field lines are equivalent to one comma-joined
|
|
// value (RFC 9110 §5.3) — except content-length, where
|
|
// disagreeing values are a smuggling attempt.
|
|
if (it->first == "content-length" && it->second != value) {
|
|
throw HTTP1ProtocolError("conflicting content-length values");
|
|
}
|
|
if (it->first != "content-length") {
|
|
it->second += ", ";
|
|
it->second += value;
|
|
}
|
|
}
|
|
}
|
|
|
|
void ParseHeaderLine(std::string_view line) {
|
|
headerBytes += line.size() + 2;
|
|
if (headerBytes > limits.maxHeaderSection) {
|
|
throw HTTP1ProtocolError("header section exceeds "
|
|
+ std::to_string(limits.maxHeaderSection) + " bytes");
|
|
}
|
|
if (line.front() == ' ' || line.front() == '\t') {
|
|
// obs-fold. RFC 9112 §5.2 allows replacing it with a space
|
|
// rather than rejecting the message.
|
|
if (lastHeaderName.empty()) throw HTTP1ProtocolError("obs-fold before any header");
|
|
auto it = headers.find(lastHeaderName);
|
|
if (it != headers.end()) {
|
|
it->second += ' ';
|
|
it->second += TrimOWS(line);
|
|
}
|
|
return;
|
|
}
|
|
const auto colon = line.find(':');
|
|
if (colon == std::string_view::npos) throw HTTP1ProtocolError("header line has no colon");
|
|
const std::string_view rawName = line.substr(0, colon);
|
|
// "No whitespace is allowed between the field name and colon" —
|
|
// a recipient MUST reject such a message (RFC 9112 §5.1).
|
|
if (!IsToken(rawName)) throw HTTP1ProtocolError("invalid header field name");
|
|
lastHeaderName = ToLowerAscii(rawName);
|
|
AddHeader(lastHeaderName, TrimOWS(line.substr(colon + 1)));
|
|
}
|
|
|
|
std::optional<std::string_view> Header(std::string_view name) const {
|
|
auto it = headers.find(std::string(name));
|
|
if (it == headers.end()) return std::nullopt;
|
|
return std::string_view(it->second);
|
|
}
|
|
|
|
void ResolveKeepAlive() {
|
|
const auto connection = Header("connection");
|
|
const bool close = connection && HasToken(*connection, "close");
|
|
const bool explicitKeep = connection && HasToken(*connection, "keep-alive");
|
|
keepAlive = version == kVersion11 ? !close : (explicitKeep && !close);
|
|
}
|
|
|
|
void ResolveFraming() {
|
|
const auto transferEncoding = Header("transfer-encoding");
|
|
const auto contentLength = Header("content-length");
|
|
if (transferEncoding && contentLength) {
|
|
// Ambiguous framing — the classic smuggling primitive.
|
|
throw HTTP1ProtocolError("both content-length and transfer-encoding present");
|
|
}
|
|
|
|
if (transferEncoding) {
|
|
auto codings = SplitCommaList(*transferEncoding);
|
|
if (codings.empty() || !EqualsIgnoreCase(codings.back(), "chunked")) {
|
|
throw HTTP1ProtocolError("unsupported transfer-encoding '"
|
|
+ std::string(*transferEncoding) + "'");
|
|
}
|
|
if (codings.size() > 1) {
|
|
throw HTTP1ProtocolError("stacked transfer-codings are not supported");
|
|
}
|
|
bodyMode = BodyMode::Chunked;
|
|
return;
|
|
}
|
|
|
|
if (contentLength) {
|
|
if (contentLength->empty()
|
|
|| !std::ranges::all_of(*contentLength, [](char c) { return c >= '0' && c <= '9'; })) {
|
|
throw HTTP1ProtocolError("malformed content-length '"
|
|
+ std::string(*contentLength) + "'");
|
|
}
|
|
std::uint64_t length = 0;
|
|
const auto result = std::from_chars(contentLength->data(),
|
|
contentLength->data() + contentLength->size(), length);
|
|
if (result.ec != std::errc{}) throw HTTP1ProtocolError("content-length out of range");
|
|
if (length > limits.maxBody) {
|
|
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
|
|
}
|
|
bodyRemaining = length;
|
|
bodyMode = length == 0 ? BodyMode::None : BodyMode::Length;
|
|
return;
|
|
}
|
|
|
|
// No explicit framing. A request has no body; a response runs
|
|
// to connection close (RFC 9112 §6.3).
|
|
if (kind == MessageKind::Request) {
|
|
bodyMode = BodyMode::None;
|
|
} else {
|
|
bodyMode = BodyMode::UntilClose;
|
|
keepAlive = false;
|
|
}
|
|
}
|
|
|
|
// Returns false when this was an interim (1xx) response and parsing
|
|
// must restart on the next status line.
|
|
bool FinishHeaders() {
|
|
ResolveKeepAlive();
|
|
|
|
if (kind == MessageKind::Response) {
|
|
if (status[0] == '1') {
|
|
// Interim response (100 Continue, 103 Early Hints, …).
|
|
// It has no body and is not the answer to the request,
|
|
// so drop it and keep reading.
|
|
headers.clear();
|
|
lastHeaderName.clear();
|
|
status.clear();
|
|
version.clear();
|
|
headerBytes = 0;
|
|
state = State::StartLine;
|
|
return false;
|
|
}
|
|
if (status == "204" || status == "304" || headRequest) {
|
|
bodyMode = BodyMode::None;
|
|
headersDone = true;
|
|
state = State::Done;
|
|
return true;
|
|
}
|
|
} else if (auto expect = Header("expect")) {
|
|
expectContinue = HasToken(*expect, "100-continue");
|
|
}
|
|
|
|
ResolveFraming();
|
|
headersDone = true;
|
|
switch (bodyMode) {
|
|
case BodyMode::None: state = State::Done; break;
|
|
case BodyMode::Length: state = State::Body; break;
|
|
case BodyMode::Chunked: state = State::ChunkSize; break;
|
|
case BodyMode::UntilClose: state = State::Body; break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void AppendBody(const char* data, std::size_t size) {
|
|
if (body.size() + size > limits.maxBody) {
|
|
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
|
|
}
|
|
body.append(data, size);
|
|
}
|
|
|
|
// Drive the state machine as far as the buffered bytes allow, then
|
|
// drop what has been consumed so a long-lived connection (or a
|
|
// large body arriving in many chunks) doesn't grow the buffer
|
|
// without bound.
|
|
void Advance() {
|
|
AdvanceState();
|
|
if (pos != 0 && pos == buffer.size()) {
|
|
buffer.clear();
|
|
pos = 0;
|
|
}
|
|
}
|
|
|
|
void AdvanceState() {
|
|
for (;;) {
|
|
switch (state) {
|
|
case State::StartLine: {
|
|
auto line = TryReadLine(limits.maxStartLine);
|
|
if (!line) return;
|
|
// A server should tolerate stray empty lines left
|
|
// over from a previous message (RFC 9112 §2.2).
|
|
if (line->empty()) continue;
|
|
if (kind == MessageKind::Request) ParseRequestLine(*line);
|
|
else ParseStatusLine(*line);
|
|
state = State::Headers;
|
|
continue;
|
|
}
|
|
case State::Headers: {
|
|
auto line = TryReadLine(limits.maxHeaderSection);
|
|
if (!line) return;
|
|
if (line->empty()) {
|
|
FinishHeaders();
|
|
continue;
|
|
}
|
|
ParseHeaderLine(*line);
|
|
continue;
|
|
}
|
|
case State::Body: {
|
|
const std::size_t available = buffer.size() - pos;
|
|
if (bodyMode == BodyMode::UntilClose) {
|
|
AppendBody(buffer.data() + pos, available);
|
|
pos = buffer.size();
|
|
if (!eof) return;
|
|
state = State::Done;
|
|
continue;
|
|
}
|
|
const std::size_t take = static_cast<std::size_t>(
|
|
std::min<std::uint64_t>(available, bodyRemaining));
|
|
AppendBody(buffer.data() + pos, take);
|
|
pos += take;
|
|
bodyRemaining -= take;
|
|
if (bodyRemaining != 0) {
|
|
if (eof) throw HTTP1ProtocolError("connection closed mid-body");
|
|
return;
|
|
}
|
|
state = State::Done;
|
|
continue;
|
|
}
|
|
case State::ChunkSize: {
|
|
auto line = TryReadLine(limits.maxStartLine);
|
|
if (!line) return;
|
|
// Chunk extensions after ';' are legal and ignorable.
|
|
std::string_view digits = TrimOWS(line->substr(0, line->find(';')));
|
|
if (digits.empty()) throw HTTP1ProtocolError("empty chunk size");
|
|
std::uint64_t size = 0;
|
|
const auto result = std::from_chars(digits.data(), digits.data() + digits.size(),
|
|
size, 16);
|
|
if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size()) {
|
|
throw HTTP1ProtocolError("malformed chunk size '" + std::string(digits) + "'");
|
|
}
|
|
if (size == 0) {
|
|
state = State::Trailers;
|
|
continue;
|
|
}
|
|
chunkRemaining = size;
|
|
state = State::ChunkData;
|
|
continue;
|
|
}
|
|
case State::ChunkData: {
|
|
const std::size_t available = buffer.size() - pos;
|
|
if (available == 0) {
|
|
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
|
|
return;
|
|
}
|
|
const std::size_t take = static_cast<std::size_t>(
|
|
std::min<std::uint64_t>(available, chunkRemaining));
|
|
AppendBody(buffer.data() + pos, take);
|
|
pos += take;
|
|
chunkRemaining -= take;
|
|
if (chunkRemaining != 0) {
|
|
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
|
|
return;
|
|
}
|
|
state = State::ChunkTrailingCRLF;
|
|
continue;
|
|
}
|
|
case State::ChunkTrailingCRLF: {
|
|
auto line = TryReadLine(2);
|
|
if (!line) return;
|
|
if (!line->empty()) throw HTTP1ProtocolError("chunk not terminated by CRLF");
|
|
state = State::ChunkSize;
|
|
continue;
|
|
}
|
|
case State::Trailers: {
|
|
auto line = TryReadLine(limits.maxHeaderSection);
|
|
if (!line) return;
|
|
if (line->empty()) {
|
|
state = State::Done;
|
|
continue;
|
|
}
|
|
// Trailer fields are merged into the header map;
|
|
// callers see one flat view of the message.
|
|
ParseHeaderLine(*line);
|
|
continue;
|
|
}
|
|
case State::Done:
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
MessageKind kind;
|
|
MessageLimits limits;
|
|
State state = State::StartLine;
|
|
BodyMode bodyMode = BodyMode::None;
|
|
|
|
std::string buffer;
|
|
std::size_t pos = 0;
|
|
bool eof = false;
|
|
|
|
std::string method;
|
|
std::string path;
|
|
std::string scheme;
|
|
std::string authority;
|
|
std::string version;
|
|
std::string status;
|
|
std::string lastHeaderName;
|
|
std::unordered_map<std::string, std::string> headers;
|
|
std::string body;
|
|
|
|
std::size_t headerBytes = 0;
|
|
std::uint64_t bodyRemaining = 0;
|
|
std::uint64_t chunkRemaining = 0;
|
|
bool headersDone = false;
|
|
bool expectContinue = false;
|
|
bool keepAlive = true;
|
|
bool headRequest = false;
|
|
};
|
|
}
|