ruby / http clients

I would rather have written and maintained code like this than depended on a library specific to the API:

require "bundler/inline"

gemfile do
  source "https://rubygems.org"
  gem "http"
end

url = ENV["API_URL"]
if url.to_s.strip == ""
  puts "err: API_URL environment variable is not set"
  exit 1
end

begin
  resp = HTTP.post(url, json: { text: "hi" })
rescue => e
  puts "err: #{e.message}"
  exit 1
end

puts resp.body.to_s

SDK-style libraries have costs. They may need to be upgraded to patch security issues or resolve competing requirements in the dependency graph. They can be slow to be updated or become unmaintained. They are an additional interface for the team to learn.

When a security review motivates replacing an SDK, the rewrite is bounded by the endpoints the app actually calls. Most of an SDK's weight is code the app never used.

In the above example, I used the HTTP gem. I preferred it to the Ruby standard library net/http's interfaces but ideally, I'd have used the standard library, such as this Go version of the same program:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
)

func main() {
    url := os.Getenv("API_URL")
    if url == "" {
        log.Fatalln("err: API_URL environment variable is not set")
    }

    reqBody, err := json.Marshal(map[string]string{
        "text": "Hello, world!",
    })
    if err != nil {
        log.Fatalf("err: %v\n", err)
    }

    resp, err := http.Post(url, "application/json", bytes.NewBuffer(reqBody))
    if err != nil {
        log.Fatalf("err: %v\n", err)
    }
    defer resp.Body.Close()

    respBody, err := io.ReadAll(resp.Body)
    if err != nil {
        log.Fatalf("err: %v\n", err)
    }

    fmt.Println(string(respBody))
}

As I used an API, I built up my custom client as needed. For example, I might add retries with exponential backoff.

Case studies

Four clients I built this way, each with a lesson worth keeping.

Cloudflare Images: retries and untrusted input

The upload client retried transient 429/502/503/504 responses with a short backoff (DELAYS = [1, 2, nil], kept under Rack's 15s request timeout). Image uploads are idempotent, so retrying is safe.

Uploads that fetch an attacker-supplied URL first passed through a shared SSRF guard, which resolved the host and rejected any private, loopback, link-local, or multicast address before the HTTP client connected. That stopped a URL like http://169.254.169.254/latest/meta-data/ from reaching cloud metadata.

Content type came from the bytes, not the Content-Type header:

private def detect_content_type(body)
  bytes = body.bytes.take(4)

  if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
    "image/jpeg"
  elsif bytes == [0x89, 0x50, 0x4E, 0x47]
    "image/png"
  elsif bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46
    "image/gif"
  end
end

Postmark: authenticating an inbound webhook

Outbound email was a single POST /email. The interesting half was inbound: Postmark posts parsed emails to a webhook, so it needed two layers of authentication.

Basic auth covered transport, compared with Rack::Utils.secure_compare to avoid timing attacks and coerced with .to_s so a malformed credential returned the same 401 as a wrong password. DKIM authenticated the message itself:

ok = auth_results.any? do |v|
  v.match?(/\bdkim=pass\b/i) &&
    v.match?(/\b(?:header\.)?d=example\.com\b/i)
end

Postmark retries failed webhooks up to 10 times, so the handler recorded every message ID on first arrival and rejected duplicates.

WorkOS: OAuth without the SDK

Four endpoints covered SSO, audit logs, and AuthKit. JWT verification stayed in my own code via the jwt gem.

The login handler stashed a CSRF state token in the session and the callback rejected any mismatch. The AuthKit bridge added a consent POST so an attacker could not silently bind their identity to a logged-in victim. The completion call returned a redirect URL from WorkOS, checked before use:

uri = URI.parse(redirect_uri.to_s)
if uri.scheme != "https" || !uri.host&.end_with?(".workos.com", ".authkit.app")
  return redirect_to("/login")
end

Sentry: error capture without the SDK

The sentry-ruby gem is large: APM tracing, profiling, breadcrumbs, session tracking, and dozens of integrations. I used only error capture, which a thin client covers in a few hundred lines:

Sentry.init(dsn: ENV["SENTRY_DSN"])
Sentry.capture_exception(err, extra: {...}, fingerprint: [...])
Sentry.capture_message("did something weird", extra: {...})

capture_* is a no-op until init runs with a non-empty DSN, so tests stay quiet without stubbing. A DSN (https://{key}@{host}/{project_id}) is parsed once at boot to build the envelope endpoint and signed auth header.

Capture runs on the error path, so events go onto a bounded SizedQueue that a background thread POSTs with retries. A Process.pid check makes the worker fork-safe under Puma, and a full queue drops events rather than blocking the caller. A failed Sentry must never break the request.

Request params ride along with each event, so a POST /login would otherwise ship the password. Filter at the call site, since the client only sends what callers hand it:

SENSITIVE_KEY_FRAGMENTS = %w(email password secret token).freeze
SENSITIVE_EXACT_KEYS = Set.new(%w(code)).freeze

Substring matching catches current_password and any *_token; exact matching redacts the OAuth code while keeping country_code readable. Keeping passwords, OAuth codes, and PII off a third-party host is the point. The gem's auto-instrumented breadcrumbs (request params, SQL, headers) are a steady PII leak that this client simply never emits.

← All articles