Skip to content

fix(config): refuse a scheme-less base_url so the origin guard stays real (#123) - #131

Merged
karlwaldman merged 3 commits into
mainfrom
fix/base-url-scheme-123
Sep 13, 2026
Merged

karlwaldman merged 3 commits into
mainfrom
fix/base-url-scheme-123

Conversation

@karlwaldman

@karlwaldman karlwaldman commented Sep 13, 2026 •

Copy link
Copy Markdown
Member

Splits the one serious finding out of #123 and fixes it on its own.

The bug

_url.resolve_api_url pins every request to the configured API origin:

if _origin(url) != _origin(base_url):
    raise _reject(path, "resolves to a different host than the configured base URL")

_origin is (scheme, host, port) derived from urlsplit. Give the client a scheme-less base_url and urlsplit finds no authority at all, so the base origin is ("", "", 0). The URL resolved against it is relative, so its origin is ("", "", 0) too. The guard compares nothing against nothing and passes everything.

Measured on main at 7982b0b, CPython 3.14.7:

'api.oilpriceapi.com' -> '/v1/prices'
''                    -> '/v1/prices'
'ftp://x.example'     -> 'ftp://x.example/v1/prices'

The first two return a relative URL from a function whose documented contract is "guaranteed to share base_url's origin".

How serious, stated honestly

The issue files this P3 and asks whether it is really the leak v1.14.0 shipped to close. It is not — and the PR should say so rather than overclaim.

I probed it end to end. The origin comparison is genuinely a tautology, but it is the second of two layers. The first is the literal // ban at _url.py:100, which fires before the origin check and is untouched by this bug:

scheme-less base 'api.oilpriceapi.com':
  '/v1/x'                      -> '/v1/x'
  'https:/evil.example/x'      -> '/https:/evil.example/x'      (becomes a path)
  '/https://evil.example/x'    REFUSED ValidationError
  '/\t//evil'                  REFUSED ValidationError
  '/..//evil.example/x'        REFUSED ValidationError
  '/%2f%2fevil.example/x'      -> '/%2f%2fevil.example/x'       (stays a path)

No probe changed the origin. And the end state is a loud failure, not a silent send:

httpx.Client(base_url="api.oilpriceapi.com").get("/v1/prices")
-> httpx.UnsupportedProtocol: Request URL is missing an 'http://' or 'https://' protocol.

So: no credential reaches a wrong host today, and no request is silently misrouted. What is real is that one of the two layers is silently gone under a misconfiguration, the guard's documented guarantee is false for those inputs, and the error the caller actually sees names neither the setting they got wrong nor the guard. Defence-in-depth regression plus a bad diagnostic — worth fixing now and cheap to fix, but not the live leak.

The fix

validated_base_url (retry.py:117) is already shared by both clients since #120 — client.py:130 and async_client.py:109 both call it. Adding the requirement there fixes sync and async identically and by construction, not by two parallel edits that can drift.

It now requires an absolute http/https base with a non-empty host, and converts the raw ValueError from an out-of-range port into the documented ConfigurationError. 38 added lines, one file.

Red

Test written first, against unmodified main:

platform darwin -- Python 3.14.7, pytest-9.1.1, pluggy-1.6.0
collected 27 items

tests/unit/test_base_url_absolute.py FFFFFFF....FFFFFFFFFFFFFF..         [100%]

_____ test_validated_base_url_refuses_non_absolute[api.oilpriceapi.com] _____
base = 'api.oilpriceapi.com'

    @pytest.mark.parametrize("base", SCHEMELESS)
    def test_validated_base_url_refuses_non_absolute(base):
>       with pytest.raises(ConfigurationError) as exc:
E       Failed: DID NOT RAISE ConfigurationError

========================= 21 failed, 6 passed in 0.56s =========================

Re-proved red-capable after the fix by restoring the pre-fix source (git checkout origin/main -- oilpriceapi/retry.py):

### RED against origin/main retry.py ###
========================= 21 failed, 6 passed in 0.14s =========================
### GREEN restored ###
============================== 27 passed in 0.06s ==============================

Green

tests/unit/test_base_url_absolute.py .................................   [100%]
============================== 33 passed in 0.12s ==============================

A follow-up commit on this branch also wraps the raw ValueError that urlsplit / .hostname / .port raise on an out-of-range port and on a non-ASCII netloc whose NFKC normalisation introduces /?#@: (https://\u2100evil.example). That leak was in this PR's own new validation, so it is fixed here rather than deferred. Red for those 6 added cases against pre-fix source: 27 failed, 6 passed.

Sync and async parity

Both clients route through the one shared validated_base_url, so they cannot diverge. Pinned two ways:

  • test_async_client_refuses_non_absolute_base_url mirrors test_sync_client_refuses_non_absolute_base_url over the same 7 inputs.
  • test_sync_and_async_share_one_base_url_validator asserts structurally that both __init__ bodies call the same validator, so a future edit to one cannot quietly drop it.

Full suite

passed failed skipped
before (origin/main) 864 3 63
after 897 3 63

+33 is exactly this PR's new tests. The 3 failures are tests/integration/test_demo_contract.py making live calls and getting HTTP 429 — environmental, identical before and after, unrelated to this change.

git diff --stat vs origin/main: oilpriceapi/retry.py | 54 +++++, plus the new test file. No CRLF normalisation of resources/alerts.py or resources/diesel.py.

Scope

The remaining findings in #123 — the raw ValueError still escaping from _url._origin / resolve_api_url itself, jitter exceeding the documented 60s cap, and ValidationError hard-coding status_code=422 — are a separate PR. Version deliberately left at 1.14.0; release decision is separate.

Closes part 2 of #123.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

…real (#123)

`resolve_api_url` pins every request to the configured origin by comparing
`_origin(url) != _origin(base_url)`, where `_origin` is (scheme, host, port)
from `urlsplit`. Given a base_url with no scheme -- "api.oilpriceapi.com" --
urlsplit finds no authority, so the base origin is ("", "", 0); the URL
resolved against it is relative, so its origin is ("", "", 0) too. The
comparison then has nothing on either side and passes everything.

That silently removes one of the two layers protecting the caller's API key --
the protection v1.14.0 exists to provide. The remaining layer, the literal
"//" ban, still holds, so this is a defence-in-depth regression rather than a
live leak; end to end httpx refuses the relative URL with UnsupportedProtocol.
The error the caller saw named neither the setting nor the guard.

`validated_base_url` is already shared by both clients (#120), so requiring an
absolute http/https base there fixes sync and async identically and by
construction. Also converts the raw ValueError from an out-of-range port into
the documented ConfigurationError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 2a7a1cd0-f2aa-4e60-aaa1-88bb1015f4e2


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

#123)

The new validation itself called urlsplit and .hostname/.port, both of which
raise ValueError on an out-of-range port and on a non-ASCII netloc whose NFKC
normalisation introduces one of /?#@: -- "https://℀evil.example". The
constructor documents ConfigurationError, so wrap the whole parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
…xpression

`parts.port` was evaluated purely for its ValueError side effect. Ruff B018
flags a bare attribute expression; bind it instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@karlwaldman
karlwaldman merged commit 569ef6b into main Sep 13, 2026
7 checks passed
@karlwaldman
karlwaldman deleted the fix/base-url-scheme-123 branch September 13, 2026 19:06
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