[CFX-7754] Reject browser-forged API keys during dr auth login - #820
[CFX-7754] Reject browser-forged API keys during dr auth login#820chasdr wants to merge 2 commits into
Conversation
Why: The `dr auth login` callback listener on localhost:51164 accepted any request for its 5-minute window. A cross-origin `<img src=".../?key=...">` on any open page could plant an attacker's API key, which the CLI then stored as the user's credential. A stray keyless request (favicon probe) was also misread as the CLI-to-CLI port-handover sentinel and aborted the login. Changes: - handleCallback refuses when Sec-Fetch-Dest is present and not "document". The real callback is a top-level navigation; a page cannot forge Sec-Fetch-* headers, so an img/fetch is turned away. Absent stays accepted, which keeps the port handover (a Go client sends no such header) working. - Success page is written only when a key is present; the keyless sentinel gets a 204, which the handover's Go client never reads. - Tests for image/empty refused, document accepted, headerless accepted, keyless image no longer interrupts, keyless headerless still does. Partial by design: does not stop a top-level navigation a hostile page forces (a real document request, but visible), or browsers without fetch metadata. The complete fix is a callback nonce and needs a paired web app change, tracked separately.
|
🎫 Jira: |
Code OwnershipCli Maintainers
Review requested from the teams above. Labels will be removed automatically upon approval. |
|
I thought I had reviewed this already. |
ajalon1
left a comment
There was a problem hiding this comment.
Reviewed from four angles (security/STRIDE, correctness & concurrency, test quality, docs & code style) using parallel review passes against upstream/main.
Verdict: approve. The Sec-Fetch-Dest gate is a correct, low-risk defense-in-depth improvement and the 204 sentinel change fixes a real latent bug (favicon probes no longer abort an in-flight login). The handler is HTTP-correct (header ordering, 204 with no body, 403 early-return before the channel send), concurrency-safe (buffered-1 keyCh with non-blocking select, closeOnce on Close), and the tests pass clean under -race -count 5 with golangci-lint reporting 0 issues.
Inline comments below cover the actionable findings (2 Medium, 4 Low). A few additional optional items that didn't warrant inline comments:
- Test coverage (optional):
Sec-Fetch-Dest: "document"with an empty key (the intersection of the two new behaviors) is untested — expected 204 +ErrLoginInterrupted. Looping the forged request 2-3x before the genuine callback would catch per-request state leaks. Adding"script"to the forged-dest table documents the gate's breadth. - Minor UX (informational): A user manually navigating to
localhost:51164/with no?key=now gets a blank 204 instead of the success page. The interrupt behavior is unchanged; only the rendered body differs. Could render a small "this URL is for the CLI login" page for that case if desired. - Docs (informational): The note could parenthetically acknowledge browsers that don't send Fetch Metadata headers aren't protected, though all modern browsers do.
None of the above are blocking. Nice fix.
| > is in flight could deliver a key. Treat `drconfig.yaml` as a secret and prefer | ||
| > `DATAROBOT_API_TOKEN` in shared or automated environments. | ||
| > The API key arrives as a URL query parameter and is stored in plaintext. The callback | ||
| > refuses browser requests that a page can forge (an `<img>` or background `fetch` carries |
There was a problem hiding this comment.
[Medium] Lead claim is overstated and self-contradicted. The first sentence says "a web page you have open cannot plant a key" but the very next sentence says "a page that forces a full-page navigation, can deliver one." A top-level navigation carries Sec-Fetch-Dest: document, which the gate explicitly accepts, so the bypass is real.
Suggest scoping the claim to the vectors actually blocked, e.g. "cannot plant a key via a hidden <img> or background fetch." Also, the bypass list omits <iframe>, <link rel="prefetch" as="document">, and window.open — all set Sec-Fetch-Dest: document and bypass the gate. Worth enumerating so the gate's limits aren't overstated.
|
|
||
| // handleCallback receives the redirect from the DataRobot web app, which carries | ||
| // the API key as the "key" query parameter. | ||
| // handleCallback receives the API key from the web app's redirect ("key" query param). |
There was a problem hiding this comment.
[Low] Doc comment omits the present-and-document accept case. The comment covers "present and not document refuses; absent accepts" but is silent on the third branch — present and document is accepted (the genuine callback). A future editor could misread "present … refuses" as "any present value refuses."
Suggest appending: present-and-"document" is the real callback; absent accepts (port handover sends none).
| func (f *BrowserFlow) handleCallback(w http.ResponseWriter, r *http.Request) { | ||
| if dest := r.Header.Get("Sec-Fetch-Dest"); dest != "" && dest != "document" { | ||
| log.Debugf("Refusing auth callback with Sec-Fetch-Dest %q", dest) | ||
| http.Error(w, "forbidden", http.StatusForbidden) |
There was a problem hiding this comment.
[Low] http.Error body is a bit cryptic. Per the repo's error-message rules (AGENTS.md: "specialize messages"; .cursor/bugbot-errors.md: "avoid cryptic error messages"), the bare "forbidden" is the kind of string those rules target. It's what a browser renders if the guard ever fires on a legitimate request (e.g. a browser quirk sending an unexpected dest).
Consider "forbidden: request type not allowed". Minor since this is primarily an attack/blocked path.
| req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) | ||
| require.NoError(t, reqErr) | ||
|
|
||
| for i := 0; i+1 < len(headers); i += 2 { |
There was a problem hiding this comment.
[Low] Odd-length headers is silently dropped. If a future caller writes waitForCallback(t, url, "Sec-Fetch-Dest") (name without a value), i+1 < len(headers) is false on the first iteration, so the header is silently dropped and the request is sent with no Sec-Fetch-Dest — exercising the ungated path instead of the intended one, producing a false-green test.
Suggest adding at the top of the helper: require.Equal(t, 0, len(headers)%2, "headers must be alternating name/value pairs").
|
|
||
| for { | ||
| resp, err := http.Get(url) //nolint:noctx,gosec // test-controlled localhost URL | ||
| req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) |
There was a problem hiding this comment.
[Low] context.Background() vs t.Context(). The repo is on Go 1.26, so t.Context() (available since 1.24) is usable. context.Background() is never cancelled, so an in-flight Do isn't torn down on test timeout/failure. Bounded by the server's ReadHeaderTimeout: 10s, so not a correctness bug — just nicer to use t.Context() so requests are cancelled automatically.
|
|
||
| func TestBrowserFlow_RefusesForgedBrowserCallbacks(t *testing.T) { | ||
| // An <img> carries Sec-Fetch-Dest "image" and a fetch()/XHR carries "empty"; | ||
| // a page cannot forge these headers, so neither can plant a key (CFX-7754). |
There was a problem hiding this comment.
[Medium] Tests are fully sequential — the real attack is concurrent. Both RefusesForgedBrowserCallbacks and KeylessBrowserProbeDoesNotInterrupt issue the forged request, wait for its response, then issue the genuine one. The real threat model is a page firing many <img>/fetch probes concurrently with the legitimate top-level navigation, where ordering isn't guaranteed.
The non-blocking select on the buffered-1 keyCh (browserflow.go ~line 209) isn't exercised under interleaved contention, which is where a subtle ordering bug (e.g., a 403 path that accidentally touched the channel) would hide.
Suggest a subtest that fires N forged probes concurrently (e.g. 10 goroutines with Sec-Fetch-Dest: image), waits for all to return 403, then sends the genuine callback and asserts <-keyCh == "real-key" and <-errCh is nil.
Summary
During
dr auth loginthe CLI runs a local listener onlocalhost:51164for 5 minutes, waiting for the browser to hand back your API key. It accepted any request on that port, so a web page you had open could plant an attacker's key with<img src="http://localhost:51164/?key=ATTACKER_KEY">and the CLI would store it as your credential, so every laterdrcommand ran against the attacker's account. The callback now refuses the forgeable browser requests.Notes for review
The gate is
Sec-Fetch-Destpresent and notdocument. Absent must stay accepted: the CLI-to-CLI port handover uses a Go http client that sends no fetch metadata, and rejecting absent would deadlock two concurrent logins. It does not gate onSec-Fetch-Site, since the real callback is legitimately cross-site.Partial by design. It does not stop a page that forces a full-page navigation (a real
documentrequest, visible in the tab) or browsers older than Safari 16.4. The complete fix is a callback nonce, tracked separately since it needs a web app change.Blocking manual gate, a real login must still work on:
Output
Technical Changes
browserflow.go: refuse whenSec-Fetch-Destis present and notdocument; success page only for a keyed request, 204 for the keyless handover sentinel.browserflow_test.go: image/empty refused, document accepted, headerless accepted, keyless image no longer interrupts, keyless headerless still hands over.Breakdown
Note
High Risk
Touches the login callback that accepts and stores API keys. The gate is partial by design (no
state/nonce), so mistakes here can still accept attacker credentials or break concurrent logins.Overview
Hardens
dr auth loginso a page cannot plant an API key via a forgeable request (<img>/fetch) to the localhost callback while login is in flight.handleCallbacknow returns 403 whenSec-Fetch-Destis present and notdocument. An absent header is still accepted so CLI-to-CLI port handover (Gohttp.Client, no fetch metadata) does not deadlock. Keyless handover gets 204 instead of the success HTML; a keyless browser probe (e.g. favicon) is refused and no longer aborts login.This is a partial mitigation: full-page navigation and clients that can set headers are still in scope. Docs and tests cover the gate, genuine
documentcallbacks, and the handover sentinel.Reviewed by Cursor Bugbot for commit 385eb4a. Configure here.