SPEC §9: credential-bearing values are never rendered

Merged
JE
2mo ago
+52-11

Replaces the "peer-derived text in observer-facing errors" section with the rule #788 was actually about: the SDK's own credentials are never rendered. Split out of #793 so that PR's settled §6 material could ship without waiting on this.

Why the old section could not converge

It tried to hold a general claim — no peer-derived text in any observer-facing rendering — across six languages. Every formulation of its boundary enumerated destinations (call sites, constructors, egresses, a closed vocabulary with carve-outs), and destinations are an open set: each was defeated by the next sweep, across ten bot-review rounds. The deeper problem: the claim was defending against nothing. Server-chosen text — a close reason, an error body, a decoder's quotation — reaching a Basecamp customer's own log is not a leak. What must not echo is the SDK's own secrets.

The rule now

Secrets are a closed set the SDK creates or requests (the table in §9 is the enumeration; the prose deliberately never restates its size — it drifted twice when rows were added):

SecretTravels inRenders it if unguarded
Bearer tokenAuthorization headerheader logging — already redacted
Stream ticket (§23)mint URL querya transport's rendering of the dial URL
Signed download URL (§14 hop 2)the URL itselfa transport's rendering of the hop-2 URL, or a pre-dial validation error
OAuth token + device-authorization responses (§16)response bodiesa JSON parser quoting — or retaining (JSONDecodeError.doc) — the body
OAuth request bodies (§16)form bodies (client_secret, code, code_verifier, device_code, refresh token)request-body logging, which nothing in the SDK does; that is the invariant to keep

The trust model, stated once: the API peer is Basecamp (§8/§23 refuse cross-origin before any request); the storage host and the OAuth endpoints are the two other hosts, and both are exactly the credential-bearing exchanges, so their error bodies are never rendered — a hop-2 failure is status-only (all six SDKs already do this), an OAuth failure carries RFC 6749's error/error_description and nothing else.

The rule binds at the construction sites where those values can enter an error: project before any library renders the whole (origin only; unparsable where no complete origin exists), and keep the raw value out of the cause chain — with the two runtime footguns stated: CPython attaches __context__ at raise time (from None silences the traceback but leaves the body-retaining exception in the slot, so the remedies are raise-outside-the-handler or clear-after-raise), and MRI attaches the implicit cause past any cause: keyword the class stores (raise-site cause: nil; Ruby's OAuth exchange already does, and is the pattern). Hop 1's request hooks get the rewritten download URL projected to origin+path, since downloadURL accepts any absolute URL and the rewrite preserves a signed input's query. Handing a credential to its owner (on_refresh, an auth strategy returning its token) is not rendering.

No rule on non-credential text at all: close reasons, decoder messages, reason phrases, rejected Link URLs, and retained non-secret fields are Basecamp's own text, bounded by the existing cap.

Kept on its own merits

  • §6 step 5's empty/unparseable-body fallback is the fixed code-bearing phrase — "HTTP status text" was three different things (wire reason phrase, absent in HTTP/2; platform tables, empty for 599 in Go and localized on Apple).
  • §6 step 6 caps hint as well as message.
  • §14 hop 2 fires no request hooks and renders failures status-only (now stated; all six SDKs already comply).
  • unparsable as the fallback for the origin-only renderings §23 already required.
  • §12's on_paginate is Ruby and Python, not Ruby only.

SDK follow-ups (#788)

  • Hop-2/transport network errors in Python, TypeScript, Ruby, Kotlin interpolate the transport's rendering and chain the raw cause; Go's ErrNetwork puts cause.Error() in Hint and returns it from Unwrap; Swift owes only the chain.
  • Pre-dial signed-URL validation errors render the URL in Ruby (client.rb) and Swift (HTTPClient.swift).
  • Python's auth.py refresh chains JSONDecodeError with from e; oauth/exchange.py/device.py use from None and owe the raising-boundary form.
  • Hop-1 hook URL projection (TypeScript passes rewrittenURL whole today; others to check).
  • Step 5's fixed phrase in TypeScript, Kotlin, Go's checkResponse, Swift.
  • Each §23 connector, as written, owes projection at its dial-failure site.

Open question left for the author

One review thread is deliberately open: whether the OAuth endpoints' error_description should be rendered at all. The section renders it (truncated) and says why — it is the caller's only OAuth diagnostic, and the endpoint that could echo a submitted secret into it is the endpoint the SDK handed that secret to. Codex argues for status-only. Judgment call.

Refs #788 — closable on this basis once the rows above land.


Summary by cubic

Rewrites §9 to protect the SDK's own credentials instead of policing all peer-derived text. The old rule guarded the wrong thing — Basecamp is the SDK's only API peer, so server-chosen error text reaching a customer's log is not a leak — and every boundary it tried (call sites, constructions, egresses) was an open set. The new rule names the closed set of values that carry a credential the SDK holds or requested (bearer token, stream ticket, signed download URL, OAuth and device-authorization responses, and the OAuth form fields client_secret, code, code_verifier, device_code, refresh_token) and binds at the construction sites where each can enter an error, requiring origin-only projection plus cause-chain severing there. The set's size lives only in the table — a prose count drifted twice when rows were added.

  • The trust model names three non-API hosts — hop 2's storage host, the OAuth token endpoint, and the device-authorization endpoint — all credential-bearing exchanges. Hop-2 failures render the status alone; OAuth and device failures render RFC 6749's error/error_description, deliberately, since that is the caller's only OAuth diagnostic. Within the API peer, server-chosen text is bounded by the cap and free to be diagnostic.
  • Pre-dial validation failures on a signed Location are the same value and render the same way; Ruby's client.rb and Swift's HTTPClient.swift render the URL before any transport, and Python's auth.py refresh chains a JSONDecodeError whose .doc retains the token body.
  • Sever the cause chain at construction: from None is not sufficient in Python — __context__ still holds the body-retaining JSONDecodeError — so raise where no exception is active or clear __context__ after; MRI sets its cause at raise time past any stored keyword.
  • Handing a credential to its owner — on_refresh(access_token, ...) or an auth strategy returning the token it holds — is not rendering and sits outside the rule.
  • The inventory was settled by sweep after three rounds each surfaced a missing row: held credentials — the bearer inside the auth strategy, the configured client_secret, the §15 webhook signing secret — never transit a failure path and owe only that no rendering is built from configuration, which none is. A caller who serializes their own configured object is publishing their own secret; no library rule reaches that act.
  • §6 step 5 renders the fixed phrase Request failed (HTTP 500), never the wire reason phrase, and step 6 caps hint alongside message.
  • Hop 2 fires no request hooks and renders the signed URL as origin only on failure; hop 1's request hooks receive the rewritten URL as origin and path only — no query, no fragment — since downloadURL preserves a caller-passed signed URL's signature through the rewrite.
  • §23 dial failures render the mint URL as origin only, with unparsable where no origin forms; poll and resume URLs are not credentials, so gap(resume_url) and catch_up_started(cursor) carry them whole.
  • Ruby's client.rb exchange is already conformant and is the pattern to copy; the remaining hop-2 and pre-dial sites that still render or chain the credential are tracked in #788, and §12's on_paginate is Ruby and Python.