# curb: Ruby bindings for libcurl, with timeouts that do not need threads

> Curb gives Ruby access to libcurl's easy and multi interfaces, which means the full option set of the C library is available from Ruby rather than a small compatible subset. The README's own security section is the more interesting read: it warns that a libcurl binding supports protocols beyond HTTP, and it documents a safety policy for Ractors with a network allowlist and a response size cap.

**taf2/curb** — Ruby bindings for libcurl

- Repository: https://github.com/taf2/curb
- Stars: 1,333 · Forks: 235
- Language: Ruby
- License: NOASSERTION
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/taf2-curb

## easy mode and multi mode, and no threads for timeouts

Curb provides Ruby-language bindings for libcurl(3), and the README says it is probably CUrl-RuBy or something. The acronym is a joke but the description is precise, and the second sentence sets the scope: it is a work-in-progress, and currently only supports libcurl's easy and multi modes.

Those two modes are the whole of libcurl's architecture. Easy mode is one transfer at a time with a handle you configure and then perform. Multi mode is many transfers in one process, driven by a single event loop. Everything else in libcurl is a layer on top of those two.

The README names one advantage over the other known Ruby HTTP libraries, and it is the specific one: the ability to handle timeouts without the use of threads.

That matters in Ruby specifically. A Ruby HTTP client that has no timeout has to rely on the caller noticing a hang, and the usual answer in a threaded server is to give every request a timeout thread, which multiplies the thread count and the scheduler pressure. libcurl's own timeout handling is implemented in C inside the transfer, so a request that exceeds its deadline is aborted by the library rather than by a Ruby thread noticing. In a Puma or Falcon application where every external call is untrusted, that difference is the difference between a bounded thread pool and an unbounded one.

The other reason to want this binding rather than a reimplementation is coverage. The binding is the C library, so every option libcurl has is available, and the README's examples set options by name with set(), which is how a large option set stays usable.

## The easy mode API, and how a timeout is expressed

The GET example is the shape of the whole API.

```ruby
  res = Curl.get("https://www.google.com/") {|http|
    http.timeout = 10 # raise exception if request/response not handled within 10 seconds
  }
  puts res.code
  puts res.head
  puts res.body
```

The block receives the handle before the transfer starts, and the timeout is set as a property on it. The comment on the line is the contract, and it is worth reading precisely: the exception is raised if the request or response is not handled within 10 seconds. It is a total deadline on the exchange, not a per-byte inactivity timer.

POST is the same shape with a body and a content type.

```ruby
  res = Curl.post("https://your-server.com/endpoint", {post: "this"}.to_json) {|http|
    http.headers["Content-Type"] = "application/json"
  }
```

The convenience methods get and post are shortcuts, and the underlying handle is what you use when you need more. The README's advanced example builds a Curl::Easy with a block that sets a whole list of options before performing, which is the pattern to follow for anything beyond a simple request.

One design detail is easy to miss in the advanced example. The progress callback must return true to continue.

```ruby
  curl.on_progress do |dl_total, dl_now, ul_total, ul_now|
    puts "Download: #{dl_now}/#{dl_total} Upload: #{ul_now}/#{ul_total}"
    true # must return true to continue
  end
```

That mirrors libcurl's own callback convention, where returning false aborts the transfer. A callback that accidentally returns nil, which is what a Ruby block ending in a puts would do, aborts the download. The comment in the example exists because this is a common and confusing failure.

## FTP, and the proxy tunnel case that trips people up

The README devotes a large section to FTP, and most of it to one specific failure.

Basic FTP is straightforward. A Curl::Easy with an ftp URL, a username, a password and a perform call downloads. Setting upload = true and put_data to the file content uploads. And setting dirlistonly to 1 lists a directory.

The interesting case is FTP over an HTTP proxy tunnel. The README explains that when listing directories through an HTTP proxy with proxy_tunnel, the CONNECT method, you should let libcurl manage the passive data connection, and you must not send PASV, EPSV or NLST via easy.ftp_commands. The reason is given plainly: QUOTE commands run on the control connection, libcurl will not open the data connection, and the result is 425 errors.

That is a genuinely non-obvious protocol fact, and the fix is to let libcurl do the data connection negotiation.

```ruby
list = Curl::Easy.new('ftp://ftp.example.com/remote/directory/')
list.username = 'user'
list.password = 'password'
list.proxy_url = 'http://proxy.example.com:80'
list.proxy_tunnel = true

# Ask libcurl to perform a listing (names only)
list.set(:dirlistonly, 1)
```

Three escape hatches are given for servers and proxies that struggle with the passive modes: ftp_use_epsv set to 0 to disable EPSV, ftp_use_eprt set to 0 to stick to IPv4 PASV, and ftp_skip_pasv_ip set to 1 to ignore the PASV host and reuse the control host.

For a full LIST output with permissions, owner, size and timestamp, the README says simply to omit dirlistonly, since the default is LIST for directory URLs.

## The security section, which is the part to read first

The Security considerations section is the most useful thing in this README, and it is not standard boilerplate.

The opening is a warning: curb is a libcurl binding and intentionally supports protocols beyond HTTP. Do not pass untrusted URLs to Curl.get, Curl::Easy.new, or related raw helpers without application-level validation.

That is the correct warning. A libcurl binding inherits libcurl's protocol range, so an ftp:// or file:// or other scheme that a browser-oriented client would refuse is reachable, and a URL fetched from a user can reach resources you did not intend to expose.

The remedy is a safety policy for the current Ractor.

```ruby
Curl.safe! do |config|
  config.network_policy = :public       # block local/private destination IPs
  config.max_body_bytes = 1_000_000     # cap buffered/callback response bytes
end

curl = Curl.get(user_url)               # allows only http/https, including redirects
```

The two settings do two different jobs. network_policy set to :public blocks local and private destination IPs, which is what stops a user-supplied URL from reaching a metadata endpoint or an internal service on the same network. max_body_bytes caps buffered and callback response bytes, which is what stops a hostile server from streaming an unbounded body into your process.

The protocol set can be widened explicitly, and redirects default to the same protocol list.

```ruby
Curl.safe! do |config|
  config.protocols = [:http, :ftp]
  config.max_body_bytes = 1_000_000
end
```

The README also mentions a per-handle alternative for a local policy rather than a process-wide one, using easy.safe_http! and a matching easy method, with the text cut off at the end of the section.

One thing to be clear about: the safety policy is opt-in. The example that uses it is separate from the plain Curl.get calls shown earlier in the README, so a reader who copies the first example and never reaches this section has a different security posture than one who does.

## Multi mode for parallel transfers, with per-item callbacks

The multi interface is how Curb does concurrency without threads, and the parallel download example shows the option shape.

```ruby
options = {
  :username => 'user',
  :password => 'password',
  :timeout => 30,
  :on_success => proc { |easy| puts "Successfully downloaded: #{easy.url}" },
  :on_failure => proc { |easy, code| puts "Failed to download: #{easy.url} (#{code})" }
}

Curl::Multi.download(urls, options) do |curl, file_path|
  puts "Completed downloading to: #{file_path}"
end
```

Two things are worth pulling out. The options hash applies to every transfer, so the timeout of 30 is a shared default rather than a per-URL setting, and the success and failure callbacks give you the handle and the return code so you can log per transfer.

The block on Multi.download receives the curl handle and the file path, which is where you would rename or move a completed file.

For multi mode generally, the transfer set is driven by a handle list and the library runs them through libcurl's own multi handler, so the event loop is inside libcurl. Combined with the timeout property, that is the whole argument for the binding: a program can have a bounded number of in-flight requests and a deadline on each, with no Ruby thread per request.

The samples/ directory reinforces that this is a real multi-mode library rather than a thin wrapper. There is a delayed_wait.rb, a downloader.rb, a fiber_crawler.rb, a gmail.rb, a multi_interface.rb and a simple_multi.rb, which is a spread of use cases from a single transfer to many.

## A native extension, an old gem, and a maintained tree

The repository layout tells you how the gem is built, and there is a wrinkle in the file names that is worth explaining.

There is an ext/ directory, which is where the C extension lives, and lib/ for the Ruby side. The gemspec is curb.gemspec, and there is a Gemfile alongside three older ones named Gemfile.ruby-1.8, Gemfile.ruby-2.1 and a plain Gemfile, which is a way of keeping dependency sets for different Ruby versions available for older checkouts. There is a .ruby-version file, a Rakefile, a ChangeLog.md, a TODO file, bench/ for benchmarks, tests/, samples/, suppressions/, tasks/ and a switch.sh script.

The practical consequence of the ext/ directory is that curb is not a pure Ruby gem. It compiles against libcurl, so installing it needs a C toolchain and libcurl development headers. That is the trade for the option coverage and the thread-free timeouts, and it is the reason the gem is worth reaching for rather than the reason to avoid it.

The maintenance picture is good. The last push was on 2026-09-27, and the CI workflow badge points at a build that runs on every change. The gemspec and the README are both current, and the README documents the FTP surface in detail, which is more than most bindings do.

The licence needs one clarification, because the two sources disagree in a way that could matter. The README says Curb is copyright (c) 2006 Ross Bamford, and released under the terms of the Ruby license, pointing at the LICENSE file. The overall licence is listed as a custom one that GitHub cannot classify. The README is the more specific statement, and the Ruby license is a permissive, BSD-like licence with an explicit disclaimer of warranty.

## Conclusion

curb suits a Ruby service that needs a protocol libcurl supports and the rest of the HTTP stack does not, or that wants libcurl's exact option set rather than a reimplementation, because the binding exposes easy and multi mode directly. It is the wrong tool if you want a pure Ruby dependency, since the gem compiles a native extension against libcurl, and it is the wrong choice if the requests are to untrusted URLs without the safety policy enabled. Before you adopt it, read the security considerations section, because the default in the README's example is a permissive Curl.get and the hardened form is opt-in, and check the Curl.safe! network policy for the Ractor your request runs in.

## FAQ

### What is Curb and which libcurl modes does it support?

Curb is a Ruby-language binding for libcurl(3). The README states it is a work-in-progress and currently supports only libcurl's easy and multi modes.

### What advantage does Curb have over other Ruby HTTP libraries?

The README names one: the ability to handle timeouts without the use of threads, which avoids a timeout thread per request in a threaded server.

### How do I use Curb safely with user-supplied URLs?

Enable the safety policy for the current Ractor with Curl.safe!, setting config.network_policy = :public to block local and private destination IPs and config.max_body_bytes to cap the response size, because a libcurl binding supports protocols beyond HTTP.

### How do I list an FTP directory through an HTTP proxy tunnel with Curb?

Set proxy_url and proxy_tunnel on the handle and let libcurl open the passive data connection, using set(:dirlistonly, 1) for names only. The README warns against sending PASV, EPSV or NLST through easy.ftp_commands, because those run on the control connection and produce 425 errors.

### What licence is the Curb gem released under?

The README says Curb is copyright (c) 2006 Ross Bamford and released under the terms of the Ruby license, with the details in the LICENSE file.

## Sources

- [Issues](https://github.com/taf2/curb/issues)
- [README](https://github.com/taf2/curb/blob/master/README.md)
- [taf2/curb on GitHub](https://github.com/taf2/curb)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/taf2-curb
