Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- `base_url` is now validated when the client is built: it must be an absolute
`https` URL with no userinfo, query, or fragment. Plain `http` is allowed only
for loopback hosts. Anything else raises `ConfigurationError` instead of
sending the API key in cleartext or to a host the URL does not name.

## 0.1.0 - 2026-09-18

Provider-neutral release. One `Client`, two providers behind it.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ client.model # => "jev-latest" (resolved after aliasing)

Both providers send `User-Agent: ruby_decision_model/<version>`.

`base_url` is validated when the client is built. It must be an absolute
`https` URL with no userinfo, query, or fragment; plain `http` is accepted only
for `localhost`, `127.0.0.1`, and `[::1]`, where nothing leaves the machine.
Anything else raises `ConfigurationError`, because every request carries the API
key in an `Authorization` header and a plaintext or redirected base URL hands
that key to whoever is listening.

### Model aliases

Each provider resolves a few friendly names to its own canonical model name.
Expand Down Expand Up @@ -171,7 +178,7 @@ return is still accepted and treated as having no headers, which means no

| Error | Meaning |
| --- | --- |
| `ConfigurationError` | No provider could be resolved, missing api_key, unknown provider, or bad `retry:` value |
| `ConfigurationError` | No provider could be resolved, missing api_key, unknown provider, bad `base_url`, or bad `retry:` value |
| `RequestError` | Questions hash was empty |
| `TransportError` (`TimeoutError`) | Network or timeout failure after retries, carries `#cause_error` |
| `ApiError` | Non-2xx response, carries `#status`, `#body`, and `#headers` |
Expand Down
2 changes: 2 additions & 0 deletions lib/ruby_decision_model/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout:
"api_key is required for #{@provider.name}: pass api_key: or set #{@provider.env_var}"
end

@provider.validate_base_url!

@model = @provider.resolve_model(model)
@timeout = timeout
@transport = transport || default_transport
Expand Down
40 changes: 40 additions & 0 deletions lib/ruby_decision_model/providers/base.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "json"
require "uri"

module RubyDecisionModel
module Providers
Expand Down Expand Up @@ -59,6 +60,45 @@ def api_key?
!(api_key.nil? || api_key.to_s.strip.empty?)
end

# Hosts that never leave the machine, where plain http carries no risk.
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze

# Every request carries the API key in an Authorization header, so the
# base URL has to be one that keeps it secret. Raises unless it is an
# absolute http(s) URL, and unless it is https or a loopback host.
# Called once when a Client is built, so a bad URL fails before the
# first request rather than after the key has already gone out.
def validate_base_url!
uri = begin
URI.parse(base_url)
rescue URI::InvalidURIError => e
raise ConfigurationError, "base_url is not a valid URL: #{base_url.inspect} (#{e.message})"
end

unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
raise ConfigurationError, "base_url must be an absolute http(s) URL, got #{base_url.inspect}"
end

unless uri.userinfo.nil?
raise ConfigurationError, "base_url must not carry userinfo, got #{base_url.sub(uri.userinfo, '...').inspect}"
end

# endpoint_path is appended to base_url as a string. A query or a
# fragment would swallow it -- "https://host/p?x=1" + "/v1/systemone"
# posts to /p with the path buried in the query -- so reject both
# rather than build a URL that points somewhere else than it reads.
unless uri.query.nil? && uri.fragment.nil?
raise ConfigurationError,
"base_url must not carry a query or fragment, got #{base_url.inspect}"
end

return if uri.is_a?(URI::HTTPS) || LOOPBACK_HOSTS.include?(uri.host.downcase)

raise ConfigurationError,
"base_url must use https: over plain http the #{env_var} bearer token is sent in " \
"cleartext. Got #{base_url.inspect}. http is allowed only for #{LOOPBACK_HOSTS.join(', ')}."
end

# Nil or blank means the provider default. Known aliases resolve to the
# provider's canonical name. Anything else passes through untouched.
def resolve_model(model)
Expand Down
92 changes: 92 additions & 0 deletions test/base_url_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

require "test_helper"

# The API key travels in an Authorization header on every request, so the base
# URL is the thing that decides whether it stays secret.
class BaseUrlTest < Minitest::Test
def build(base_url)
RubyDecisionModel::Client.new(
provider: :typesafe, api_key: "ts-secret", base_url: base_url, transport: FakeTransport.new([])
)
end

def test_https_base_url_is_accepted
client = build("https://api.typesafe.ai")
assert_equal "https://api.typesafe.ai/v1/systemone", client.provider.url
end

def test_plain_http_base_url_is_rejected
error = assert_raises(RubyDecisionModel::ConfigurationError) { build("http://api.typesafe.ai") }
assert_match(/https/, error.message)
assert_match(/cleartext/, error.message)
end

def test_plain_http_is_allowed_for_loopback_hosts
%w[http://localhost:4000 http://127.0.0.1:4000 http://[::1]:4000].each do |url|
client = build(url)
assert_equal "#{url}/v1/systemone", client.provider.url
end
end

def test_loopback_host_matching_ignores_case
client = build("http://LOCALHOST:4000")
assert_equal "http://LOCALHOST:4000/v1/systemone", client.provider.url
end

def test_non_http_scheme_is_rejected
error = assert_raises(RubyDecisionModel::ConfigurationError) { build("ftp://api.typesafe.ai") }
assert_match(/absolute http\(s\) URL/, error.message)
end

def test_base_url_without_a_host_is_rejected
error = assert_raises(RubyDecisionModel::ConfigurationError) { build("api.typesafe.ai") }
assert_match(/absolute http\(s\) URL/, error.message)
end

def test_unparseable_base_url_is_rejected
error = assert_raises(RubyDecisionModel::ConfigurationError) { build("https://exa mple.com") }
assert_match(/not a valid URL/, error.message)
end

def test_userinfo_in_base_url_is_rejected_and_not_echoed
error = assert_raises(RubyDecisionModel::ConfigurationError) { build("https://user:pw@api.typesafe.ai") }
assert_match(/userinfo/, error.message)
refute_includes error.message, "pw"
end

def test_query_or_fragment_in_base_url_is_rejected
# "https://api.typesafe.ai/p?x=1" + "/v1/systemone" would POST to /p.
["https://api.typesafe.ai/p?x=1", "https://api.typesafe.ai/p#frag"].each do |url|
error = assert_raises(RubyDecisionModel::ConfigurationError) { build(url) }
assert_match(/query or fragment/, error.message)
end
end

def test_userinfo_host_confusion_is_rejected
# Reads as trusted.example, resolves to evil.example.
error = assert_raises(RubyDecisionModel::ConfigurationError) do
build("https://trusted.example@evil.example/p")
end
assert_match(/userinfo/, error.message)
end

def test_env_supplied_http_base_url_is_rejected_too
error = assert_raises(RubyDecisionModel::ConfigurationError) do
RubyDecisionModel::Client.new(
provider: RubyDecisionModel::Providers::Typesafe.new(api_key: "k", base_url: "http://evil.example.com"),
transport: FakeTransport.new([])
)
end
assert_match(/cleartext/, error.message)
end

def test_the_default_base_urls_pass_validation
with_env("TYPESAFE_API_KEY" => "ts-key", "OPENROUTER_API_KEY" => "or-key") do
RubyDecisionModel::Providers.names.each do |name|
client = RubyDecisionModel::Client.new(provider: name, transport: FakeTransport.new([]))
assert_match(%r{\Ahttps://}, client.base_url)
end
end
end
end