--- a/bin/tep +++ b/bin/tep @@ -899,6 +899,8 @@ __port = #{config[:port] || 4567} __workers = #{config[:workers] || 1} __sockpath = "" + __listen_host = "" + __listen_bad = false __quiet = false __scheduled = #{scheduled ? 'true' : 'false'} __i = 0 @@ -915,6 +917,21 @@ # argv deployment platforms like CARLOS spawn instances with). __sockpath = ARGV[__i + 1] __i += 2 + elsif __a == "--listen" && __i + 1 < ARGV.length + # host:port -- bind TCP on exactly that address. Loopback only + # (127.0.0.0/8, ::1, localhost): anything else is refused below. + __hp = ARGV[__i + 1] + __colon = __hp.rindex(":") + if __colon == nil + __listen_bad = true + else + __listen_host = __hp[0, __colon] + __port = __hp[__colon + 1, __hp.length - __colon - 1].to_i + if !Tep.loopback_host?(__listen_host) || __port < 1 || __port > 65535 + __listen_bad = true + end + end + __i += 2 elsif __a == "--db" && __i + 1 < ARGV.length # Platform-supplied DB path; tolerated so a platform can pass it # unconditionally. Apps that want it read it from ARGV themselves. @@ -934,10 +951,19 @@ __i += 1 end end + # Never fall back to tep's every-interface listener: the process + # serves a unix socket (--socket) or a loopback address (--listen). + if __listen_bad + puts "tep: --listen needs a loopback address and a port, e.g. 127.0.0.1:8080" + exit(2) + end if __sockpath.length > 0 Tep.run_unix!(__sockpath, __quiet) + elsif __listen_host.length > 0 + Tep.run_host!(__listen_host, __port, __quiet) else - Tep.run!(__port, __workers, __quiet, __scheduled) + puts "tep: give --socket or --listen 127.0.0.1:" + exit(2) end RB out << "" --- a/native/sqlite/tep_sqlite.c +++ b/native/sqlite/tep_sqlite.c @@ -44,7 +44,7 @@ #include #define TEP_SQLITE_MAX_HANDLES 16 -#define TEP_SQLITE_COL_BUFSIZE 65536 +#define TEP_SQLITE_COL_BUFSIZE (256 * 1024 + 1) /* a whole request body (SPHTTP_BODY_MAX) fits */ #define TEP_SQLITE_COL_BUF_SLOTS 16 #define TEP_SQLITE_CACHE_SLOTS 64 /* prepare-statement cache */ #define TEP_SQLITE_CACHE_SQL_MAX 512 /* longest cached SQL */ --- a/sig/tep/request.rbs +++ b/sig/tep/request.rbs @@ -14,6 +14,7 @@ # an ivar slot with Response. attr_accessor req_headers: Hash[String, String] attr_accessor raw_body: String + attr_accessor body_status: Integer attr_accessor cookies: Hash[String, String] attr_accessor session: Tep::Session attr_accessor remote_host: String --- a/tep/assets.rb +++ b/tep/assets.rb @@ -25,8 +25,11 @@ # `Tep.public_dir` instead. module Tep class Assets + # Returns 0, as sig/tep/assets.rbs declares (add_asset returns the + # ETag String, which otherwise leaks out as this method's value). def self._add(path, body, mime) Tep::APP.add_asset(path, body, mime) + 0 end def self.has?(path) --- a/tep/net.rb +++ b/tep/net.rb @@ -27,6 +27,14 @@ # Unix-domain listener: bind+listen on a filesystem path (see # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths. ffi_func :sphttp_listen_unix, [:str], :int + # TCP listener on one address (`--listen 127.0.0.1:`). + ffi_func :sphttp_listen_host, [:str, :int], :int + # Form-rules URL decoding in C (see sphttp.c and Tep.parse_form). + ffi_func :sphttp_url_unescape, [:str], :str + # Whole-request deadline + complete-body reads (see sphttp.c). + ffi_func :sphttp_set_deadline, [:int, :int], :int + ffi_func :sphttp_body_max, [], :int + ffi_func :sphttp_body_incomplete, [], :int ffi_func :sphttp_accept, [:int], :int # Non-blocking accept variant used by Tep::Server::Scheduled. # Listen fd must be in non-blocking mode (sphttp_set_nonblock). --- a/tep/parser.rb +++ b/tep/parser.rb @@ -33,7 +33,7 @@ else req.path = req.raw_path[0, qmark] qstring = req.raw_path[qmark + 1, req.raw_path.length - qmark - 1] - req.query = SpinelKit::Url.parse_query(qstring) + req.query = Tep.parse_form(qstring) end i = 1 @@ -66,7 +66,8 @@ if eq > 0 cname = pair[0, eq].strip cvalue = pair[eq + 1, pair.length - eq - 1].strip - req.cookies[cname] = SpinelKit::Url.unescape(cvalue) + cdecoded = Sock.sphttp_url_unescape(cvalue) + req.cookies[cname] = cdecoded end end end --- a/tep/request.rb +++ b/tep/request.rb @@ -4,6 +4,8 @@ attr_accessor :verb, :path, :raw_path, :http_version attr_accessor :params, :query, :req_headers, :raw_body, :cookies, :session attr_accessor :remote_host + # 0 when the body was read whole; else the status to refuse it with. + attr_accessor :body_status attr_accessor :ivars # Set by the auth-filter (Tep::AuthFilter, run before the user's # before-filter -- see Tep::App#auth_filter). Always populated: @@ -29,6 +31,7 @@ @session = Session.new # signed cookie store @raw_body = "" # same reasoning as req_headers @remote_host = "" + @body_status = 0 @passed = false # `pass` flag: skip to the next matching route @ivars = Tep.str_hash # per-request bag for `@name = ...` # set by handlers and `before` filters, @@ -136,12 +139,33 @@ # Other content types leave @raw_body intact for handlers that # want to consume it directly. def consume_body(client_fd) + if @req_headers.key?("transfer-encoding") + @body_status = 400 + return 0 + end cl = content_length - already = @raw_body.length + if cl < 0 + @body_status = 400 + return 0 + end + if cl > Sock.sphttp_body_max + @body_status = 413 + return 0 + end + already = @raw_body.bytesize if cl > already rest = Sock.sphttp_drain_body(client_fd, cl - already) + if Sock.sphttp_body_incomplete != 0 + @body_status = 400 + return 0 + end @raw_body = @raw_body + rest end + # Short (or carrying a NUL, which the C string boundary cuts off). + if @raw_body.bytesize < cl + @body_status = 400 + return 0 + end parse_form_body 0 end @@ -190,7 +214,7 @@ # @raw_body to Content-Length. def parse_form_body if form? - SpinelKit::Url.parse_query(@raw_body).each do |k, v| + Tep.parse_form(@raw_body).each do |k, v| @params[k] = v end elsif multipart? --- a/tep/router.rb +++ b/tep/router.rb @@ -77,7 +77,8 @@ pp = pat[i] if pp.length > 0 && pp[0] == ":" name = @r_params[pi] - req.params[name] = SpinelKit::Url.unescape(rp[i]) + pdecoded = Sock.sphttp_url_unescape(rp[i]) + req.params[name] = pdecoded pi += 1 end i += 1 --- a/tep/server.rb +++ b/tep/server.rb @@ -8,11 +8,13 @@ if status == 204; return "No Content"; end if status == 301; return "Moved Permanently"; end if status == 302; return "Found"; end + if status == 303; return "See Other"; end if status == 304; return "Not Modified"; end if status == 400; return "Bad Request"; end if status == 401; return "Unauthorized"; end if status == 403; return "Forbidden"; end if status == 404; return "Not Found"; end + if status == 413; return "Content Too Large"; end if status == 500; return "Internal Server Error"; end "OK" end @@ -121,6 +123,25 @@ end end + # Serve TCP on one address (`--listen 127.0.0.1:`), with the + # same single-worker, close-per-request shape as run_unix. + def run_host(host, port, quiet) + if !quiet + puts "[tep " + VERSION + "] listening on http://" + host + ":" + port.to_s + end + Tep::APP.close_per_request = true + Sock.sphttp_install_term_handlers + sfd = Sock.sphttp_listen_host(host, port) + if sfd < 0 + puts "tep: bind failed on " + host + ":" + port.to_s + exit(1) + end + worker_loop(sfd) + if Sock.sphttp_shutdown_requested != 0 + Tep.on_shutdown + end + end + def worker_loop(sfd) loop do client = Sock.sphttp_accept(sfd) @@ -152,6 +173,12 @@ end Sock.sphttp_set_recv_timeout(client, 0) end + # Close-per-request mode serves one connection at a time, so a + # client that connects and then sends nothing, or trickles bytes, + # must not hold the worker: the whole request gets 10 seconds. + if Tep::APP.close_per_request + Sock.sphttp_set_deadline(client, 10000) + end handle_connection(client) end # Explicit Integer return: the `loop` falls through as nil (boxed), @@ -178,6 +205,12 @@ end req.consume_body(client) + # A body that is too large, short of its Content-Length, or chunked + # (unsupported here) never reaches a handler, so nothing mutates. + if req.body_status != 0 + send_simple(client, req.body_status, "the body was refused") + break + end res = Response.new @app.dispatch(req, res) --- a/tep/sphttp.c +++ b/tep/sphttp.c @@ -85,6 +85,7 @@ extern int sp_net_install_term_handlers(void); extern int sp_net_shutdown_requested(void); extern int sp_net_listen(int port, int reuseport); +extern int sp_net_listen_host(const char *host, int port, int backlog); extern int sp_net_accept(int sfd); extern int sp_net_accept_nb(int sfd); extern int sp_net_connect(const char *host, int port); @@ -147,19 +148,111 @@ return fd; } +/* TCP listener bound to one address (e.g. 127.0.0.1) rather than + * INADDR_ANY, for `--listen host:port`. -1 on failure. */ +int sphttp_listen_host(const char *host, int port) { + int fd = sp_net_listen_host(host, port, 1024); + if (fd >= 0) { + int one = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + } + return fd; +} + int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); } + +/* URL-decode with form rules ('+' is a space). Decoding byte by byte in + * Ruby allocates a string per input byte, and SpinelKit::Url.parse_query + * holds the decoded key in an unrooted temporary while it decodes the + * value, so a collection in between loses the key (seen on aarch64 with + * long form values, and on x86-64 under SPINEL_GC_STRESS). Returns a + * static buffer; the FFI copies it into a Ruby string. */ +static char *sphttp_unesc_buf = NULL; +static size_t sphttp_unesc_cap = 0; + +static int sphttp_hexval(int c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +const char *sphttp_url_unescape(const char *s) { + size_t n = strlen(s); + if (n + 1 > sphttp_unesc_cap) { + char *nb = (char *)realloc(sphttp_unesc_buf, n + 1); + if (!nb) return ""; + sphttp_unesc_buf = nb; + sphttp_unesc_cap = n + 1; + } + size_t o = 0; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (c == '+') { + sphttp_unesc_buf[o++] = ' '; + } else if (c == '%' && i + 2 < n && + sphttp_hexval((unsigned char)s[i + 1]) >= 0 && + sphttp_hexval((unsigned char)s[i + 2]) >= 0) { + sphttp_unesc_buf[o++] = (char)(sphttp_hexval((unsigned char)s[i + 1]) * 16 + + sphttp_hexval((unsigned char)s[i + 2])); + i += 2; + } else { + sphttp_unesc_buf[o++] = (char)c; + } + } + sphttp_unesc_buf[o] = '\0'; + return sphttp_unesc_buf; +} int sphttp_accept(int sfd) { return sp_net_accept(sfd); } int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); } /* Read until end-of-headers ("\r\n\r\n") or the buffer fills. Subsequent * recv()s for the body are the caller's job (we expose a length helper). * Returns the parsed length (>0), 0 on clean EOF, -1 on error. */ +/* Whole-request deadline (close-per-request mode). Before every blocking + * read or write on the connection, SO_RCVTIMEO/SO_SNDTIMEO are set to the + * time left, so a client trickling one byte at a time can hold the single + * worker for at most the deadline, not a timeout per read. */ +static struct timespec sphttp_deadline; +static int sphttp_deadline_on = 0; + +int sphttp_set_deadline(int fd, int ms) { + (void)fd; + if (ms <= 0) { sphttp_deadline_on = 0; return 0; } + clock_gettime(CLOCK_MONOTONIC, &sphttp_deadline); + sphttp_deadline.tv_sec += ms / 1000; + sphttp_deadline.tv_nsec += (long)(ms % 1000) * 1000000L; + if (sphttp_deadline.tv_nsec >= 1000000000L) { + sphttp_deadline.tv_sec += 1; + sphttp_deadline.tv_nsec -= 1000000000L; + } + sphttp_deadline_on = 1; + return 0; +} + +/* Arms the socket timeouts with the time left; -1 once it has run out. */ +static int sphttp_arm_deadline(int fd) { + if (!sphttp_deadline_on) return 0; + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + long long left_ms = (long long)(sphttp_deadline.tv_sec - now.tv_sec) * 1000LL + + (sphttp_deadline.tv_nsec - now.tv_nsec) / 1000000L; + if (left_ms <= 0) { errno = ETIMEDOUT; return -1; } + struct timeval tv; + tv.tv_sec = (time_t)(left_ms / 1000); + tv.tv_usec = (suseconds_t)((left_ms % 1000) * 1000); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + return 0; +} + int sphttp_read_request(int fd) { SSL *ssl = sphttp_ssl_for(fd); /* inbound TLS: decrypt via SSL_read */ sphttp_req_len = 0; sphttp_req_buf[0] = '\0'; while (sphttp_req_len < SPHTTP_BUFSIZE - 1) { int want = SPHTTP_BUFSIZE - 1 - sphttp_req_len; + if (!ssl && sphttp_arm_deadline(fd) < 0) return -1; ssize_t n = ssl ? (ssize_t)SSL_read(ssl, sphttp_req_buf + sphttp_req_len, want) : recv(fd, sphttp_req_buf + sphttp_req_len, (size_t)want, 0); @@ -190,23 +283,42 @@ * computes remaining = content_length - already_in_buf; this gulps * those into a Ruby-visible string buffer. We round-trip via a * static buffer to avoid hand-rolling write_str FFI. */ -static char sphttp_body_buf[SPHTTP_BUFSIZE]; +/* Bodies are read whole, up to SPHTTP_BODY_MAX, into a growing buffer + * (this used to stop at 64 KiB, silently truncating longer bodies). + * sphttp_body_incomplete() reports whether the last drain came up short: + * the peer closed or the deadline passed before total_len bytes arrived. */ +#define SPHTTP_BODY_MAX (256 * 1024) +static char *sphttp_body_buf = NULL; +static size_t sphttp_body_cap = 0; +static int sphttp_body_short = 0; + +int sphttp_body_max(void) { return SPHTTP_BODY_MAX; } +int sphttp_body_incomplete(void) { return sphttp_body_short; } const char *sphttp_drain_body(int fd, int total_len) { SSL *ssl = sphttp_ssl_for(fd); int n = total_len; + sphttp_body_short = 0; if (n < 0) n = 0; - if (n >= SPHTTP_BUFSIZE) n = SPHTTP_BUFSIZE - 1; + if (n > SPHTTP_BODY_MAX) { sphttp_body_short = 1; return ""; } + if ((size_t)n + 1 > sphttp_body_cap) { + char *nb = (char *)realloc(sphttp_body_buf, (size_t)n + 1); + if (!nb) { sphttp_body_short = 1; return ""; } + sphttp_body_buf = nb; + sphttp_body_cap = (size_t)n + 1; + } int got = 0; while (got < n) { + if (!ssl && sphttp_arm_deadline(fd) < 0) break; ssize_t r = ssl ? (ssize_t)SSL_read(ssl, sphttp_body_buf + got, n - got) : recv(fd, sphttp_body_buf + got, n - got, 0); if (r <= 0) { - if (!ssl && errno == EINTR) continue; + if (!ssl && r < 0 && errno == EINTR) continue; break; } got += (int)r; } + if (got < n) sphttp_body_short = 1; sphttp_body_buf[got] = '\0'; return sphttp_body_buf; } @@ -221,6 +333,7 @@ if (w <= 0) return -1; off += (size_t)w; } else { + if (sphttp_arm_deadline(fd) < 0) return -1; ssize_t n = send(fd, s + off, len - off, 0); if (n <= 0) { if (errno == EINTR) continue; --- a/tep.rb +++ b/tep.rb @@ -166,6 +166,68 @@ # narrowing model). Removing it would require a mechanical # `< 0` -> `.nil?` refactor across http.rb / parser.rb / url.rb / # jwt.rb / app.rb. Worth doing eventually; not urgent. + # Parse an application/x-www-form-urlencoded string (a body or a query + # string). Replaces SpinelKit::Url.parse_query, which can lose keys to + # the GC (see sphttp_url_unescape); every intermediate here is a local. + def self.parse_form(s) + h = Tep.str_hash + if s.length == 0 + return h + end + pairs = s.split("&") + i = 0 + while i < pairs.length + pair = pairs[i] + if pair.length > 0 + k = "" + v = "" + eq = Tep.str_find(pair, "=", 0) + if eq < 0 + k = Sock.sphttp_url_unescape(pair) + else + kraw = pair[0, eq] + vraw = pair[eq + 1, pair.length - eq - 1] + k = Sock.sphttp_url_unescape(kraw) + v = Sock.sphttp_url_unescape(vraw) + end + h[k] = v + end + i += 1 + end + h + end + + # True for the addresses `--listen` may bind: localhost, ::1 and + # dotted-quad 127.0.0.0/8. + def self.loopback_host?(h) + if h == "localhost" || h == "::1" + return true + end + parts = h.split(".") + if parts.length != 4 || parts[0] != "127" + return false + end + i = 0 + while i < 4 + p = parts[i] + if p.length == 0 || p.length > 3 + return false + end + j = 0 + while j < p.length + if p[j] < "0" || p[j] > "9" + return false + end + j += 1 + end + if p.to_i > 255 + return false + end + i += 1 + end + true + end + def self.str_find(s, needle, start) nlen = needle.length slen = s.length @@ -300,6 +362,11 @@ Server.new(APP).run_unix(path, quiet) end + # TCP twin bound to one host address -- see Server#run_host. + def self.run_host!(host, port, quiet) + Server.new(APP).run_host(host, port, quiet) + end + def self.run!(port, workers, quiet, scheduled = false) if scheduled Server::Scheduled.new(APP).run(port, workers, quiet)