Skip to content

v1.14.0 — credential transport, write safety, and no fabricated codes

Choose a tag to compare

@karlwaldman karlwaldman released this 13 Sep 17:29
· 18 commits to main since this release
7982b0b

Security release. Every user on 1.13.0 or earlier should upgrade.

PyPI has been serving a version in which a caller-supplied raw path can send the customer's API key to a host that is not OilPriceAPI.

Security — raw paths can no longer change the API origin

//fixture.invalid/v1/prices resolved to a foreign host with Authorization: Token <key> still attached. Worse, //user@fixture.invalid/... made httpx read the userinfo and replace our token with Basic dXNlcjo= — a crafted path could both redirect the request and swap the credential.

A path is now rejected unless it resolves to the configured base origin, compared on (scheme, host, port). 27 unicode and ASCII probe forms were driven through the resolver: none changed the origin. follow_redirects was checked separately and does not bypass it — httpx strips Authorization cross-origin.

Fixed — the SDK no longer manufactures a match

A price response that omits code no longer comes back wearing the code you asked for. That fabrication made the consumer-side guard price.commodity == requested pass by construction, on precisely the responses where it needed to fail — disarming the standard defence against a wrong-instrument response rather than merely failing to help. An absent code is now empty, matching what get_all already did.

Three call sites carried the same fallback; all three are fixed, and the hook was removed rather than left ignored.

Fixed — write safety

  • Non-idempotent writes are no longer replayed. POST was retried 3× on timeout and 3× on 503 — a duplicated write. max_retries=0 was ignored entirely and still retried three times; retry_on=[] fell back to the default list.
  • A write refused after a 5xx now carries .ambiguous_write, the "did my write land?" signal. Previously only the timeout path set it.
  • Retry-After: -30 no longer produces a negative sleep.
  • max_retries=0 is honoured as one attempt with a deprecation notice rather than raising at construction — int(os.getenv("OPA_MAX_RETRIES", "0")) is exactly how callers produce it.

Fixed — data correctness

  • 18 catalogue codes resolved to a different instrument. normalize_futures_slug split on the first _ and discarded geography and currency: LNG_NW_EUROPE_EUR → Japan/Korea Marker, WTI_MIDLAND_USD → Cushing WTI, WTI_SPOT_CUSHING_USD → the futures curve. Suffix stripping now requires the discarded tail to actually look like a month or order marker.
  • client.diesel.* no longer raises ValidationError against the live envelope — the response is unwrapped, and state is read from data.location.state_code rather than the region the model could not accept.
  • ValidationError.__str__ no longer discards the message, so the origin guard's remediation text reaches logs and tracebacks.
  • timeout=0 and base_url="" are honoured instead of silently becoming defaults.

Also in this release

Fixes for three regressions introduced earlier the same day by the changes above, all found by an expert review after they merged green: a forbidden SPACE in query paths that broke the SDK's own demo.py, retries stripped from read-shaped POSTs (diesel.get_stations, webhooks.test, alerts.test), and a bare ValueError escaping the SDK exception hierarchy.

Every fix applies identically to the sync and async clients, pinned by parity tests.

Full diff: v1.13.0...v1.14.0