Skip to content

[CFX-7754] Reject browser-forged API keys during dr auth login - #820

Open
chasdr wants to merge 2 commits into
mainfrom
chas/CFX-7754
Open

[CFX-7754] Reject browser-forged API keys during dr auth login#820
chasdr wants to merge 2 commits into
mainfrom
chas/CFX-7754

Conversation

@chasdr

@chasdr chasdr commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

During dr auth login the CLI runs a local listener on localhost:51164 for 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 later dr command ran against the attacker's account. The callback now refuses the forgeable browser requests.

Notes for review

The gate is Sec-Fetch-Dest present and not document. 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 on Sec-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 document request, 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:

  • Chrome (macOS): pass
  • Firefox: pending
  • Safari: pending
  • Edge: pending

Output

$ curl -s -o /dev/null -w "%{http_code}\n" -H "Sec-Fetch-Dest: image" "http://localhost:51164/?key=PLANTED-BY-ATTACKER"
403
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Sec-Fetch-Dest: document" "http://localhost:51164/?key=REAL-KEY"
200

Technical Changes

  • browserflow.go: refuse when Sec-Fetch-Dest is present and not document; 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.
  • auth docs: corrected the security note, added the editing rule for the gate.

Breakdown

  • code: +17 / -5
  • tests: +103 / -3
  • docs: +15 / -5

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 login so a page cannot plant an API key via a forgeable request (<img> / fetch) to the localhost callback while login is in flight.

handleCallback now returns 403 when Sec-Fetch-Dest is present and not document. An absent header is still accepted so CLI-to-CLI port handover (Go http.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 document callbacks, and the handover sentinel.

Reviewed by Cursor Bugbot for commit 385eb4a. Configure here.

chasdr added 2 commits August 21, 2026 18:05
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.
@chasdr
chasdr requested a review from a team as a code owner August 21, 2026 22:12
@datarobot-pr-review-router

Copy link
Copy Markdown

🎫 Jira: CFX-7754 — Auth callback listener accepts any browser request, so a page can plant an API key during login

@github-actions github-actions Bot added the go Pull requests that update go code label Aug 21, 2026
@datarobot-pr-review-router

Copy link
Copy Markdown

Code Ownership

Cli Maintainers

  • docs/commands/auth.md
  • docs/development/authentication.md
  • internal/auth/browserflow.go
  • internal/auth/browserflow_test.go

Review requested from the teams above. Labels will be removed automatically upon approval.

@ajalon1

ajalon1 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

I thought I had reviewed this already.

@ajalon1 ajalon1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/commands/auth.md
> 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

00 - Ready for Review go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants