Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **Google auth URLs no longer reach user-facing error messages with their query
intact.** `client._handle_account_chooser`'s three raise sites interpolated
`page.url` verbatim, and Google's auth URLs carry `state`, `code_challenge`,
`client_id` and challenge tokens (`TL=…`). That text is the artifact users are
asked to paste into a GitHub issue.
- **Measured, not theorised:** a real `gflow image t2i --profile <name>` on
2026-09-10 exited 38 and printed
`accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ…` along with the
OAuth `state` and `client_id`. Re-running the identical command after the fix:
same exit 38, same landing named, **zero** secret matches.
- New `safe_page_url()` in `api/transports/_common.py` keeps scheme+host+path and
drops query+fragment; all four raise sites (the three in `client.py` plus
`raise_if_known_landing`) route through it rather than stripping inline.
- The landing is still named — knowing *where* the session stopped is the whole
value of the message; only the credentials are gone.

### Fixed
- **A known Flow landing page is no longer reported as selector drift**
([#756](https://github.com/ffroliva/gflow-cli/issues/756), and the 2026-09-10 RED
Expand Down
11 changes: 6 additions & 5 deletions src/gflow_cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
await_url_settled,
flow_host_kind,
raise_if_migrated,
safe_page_url,
)
from gflow_cli.api.transports.base import (
FlowTransportStrategy,
Expand Down Expand Up @@ -839,8 +840,8 @@ async def _handle_account_chooser(self, page: Page) -> bool:
if not email:
raise FlowAccountChooserError(
detail=(
f"Google sign-in/chooser displayed at {url} but no account is recorded "
f"in this profile to auto-select."
f"Google sign-in/chooser displayed at {safe_page_url(url)} but no "
f"account is recorded in this profile to auto-select."
)
)

Expand All @@ -865,8 +866,8 @@ async def _handle_account_chooser(self, page: Page) -> bool:
if count == 0:
raise FlowAccountChooserError(
detail=(
f"Account chooser displayed at {url} but recorded account '{email}' "
f"was not found among selectable accounts."
f"Account chooser displayed at {safe_page_url(url)} but recorded "
f"account '{email}' was not found among selectable accounts."
)
)

Expand Down Expand Up @@ -897,7 +898,7 @@ async def _handle_account_chooser(self, page: Page) -> bool:
raise FlowAccountChooserError(
detail=(
f"Clicked recorded account '{email}' on the chooser but the session "
f"did not reach Flow within 30s — it is at {landed}."
f"did not reach Flow within 30s — it is at {safe_page_url(landed)}."
)
) from exc
logger.info(
Expand Down
24 changes: 22 additions & 2 deletions src/gflow_cli/api/transports/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,27 @@ def flow_landing_kind(url: object) -> str | None:
return None


def safe_page_url(url: object) -> str:
"""A page URL reduced to scheme+host+path — safe to put in a user-facing message.

Google's auth URLs carry `state`, `code_challenge`, `client_id`, and challenge
tokens (`TL=...`) in the query. Error text is the artifact users are asked to paste
into GitHub issues, so the query and fragment have no business in it. Measured live
on 2026-09-10: a real `gflow image t2i` failure printed all of those.

Anything unparseable comes back as the empty string rather than raising — this is
only ever called while another failure is already being reported.
"""
text = str(url or "")
try:
parts = urlsplit(text)
except ValueError:
return ""
if not parts.scheme or not parts.netloc:
return text
return f"{parts.scheme}://{parts.netloc}{parts.path}"


def raise_if_known_landing(page: object, *, requested: str, at: str) -> None:
"""Replace an about-to-be-raised drift report when the page is a **known landing**.

Expand Down Expand Up @@ -211,8 +232,7 @@ def raise_if_known_landing(page: object, *, requested: str, at: str) -> None:
kind = flow_landing_kind(url)
if kind is None:
return
parts = urlsplit(url)
safe_url = f"{parts.scheme}://{parts.netloc}{parts.path}" if parts.scheme else url
safe_url = safe_page_url(url)
log.info("ui_driver.known_landing", at=at, kind=kind, url=safe_url, requested=requested)
if kind == "chooser":
# The existing class for "we are at the chooser and cannot proceed" (#763/#764,
Expand Down
72 changes: 72 additions & 0 deletions tests/api/test_bootstrap_chooser.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,75 @@ async def _click_moves_to_interstitial(*_args: object, **_kwargs: object) -> Non
# The CURRENT url, not the chooser url captured on entry: reporting the entry
# url would claim "still on the chooser" for a click that did navigate.
assert interstitial in str(exc_info.value)


async def test_chooser_error_message_carries_no_oauth_query_params(tmp_path: Path) -> None:
"""Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge
tokens in the query, and this message is the artifact users are asked to paste into
a GitHub issue.

Measured, not imagined: a real `gflow image t2i --profile denon82` on 2026-09-10
exited 38 and printed
`accounts.google.com/v3/signin/challenge/pwd?TL=ACv9tzFkh8ZJ...` together with the
OAuth `state` and `client_id`. The raise sites now route the URL through
`safe_page_url`, which keeps scheme+host+path and drops query+fragment.
"""
from gflow_cli.api.client import FlowApiClient
from gflow_cli.profile_store import ACCOUNT_FILE

profile = tmp_path / "profile_p1"
profile.mkdir()
(profile / ACCOUNT_FILE).write_text("recorded@example.com\n", encoding="utf-8")

noisy = (
"https://accounts.google.com/v3/signin/accountchooser"
"?client_id=365941595420-x.apps.googleusercontent.com"
"&code_challenge=rNzAdlPk4Ed_h7i0aIDLCGm6ZN4cAjFgfh_ZarFeUr8"
"&state=PKOA6qjxDhhwJkvwuMMWmPBAp0XlLtDinoP-RwgAz84"
"&TL=ACv9tzFkh8ZJPujsxSa7PrWFaArmMhVj"
)
client = FlowApiClient(profile_dir=profile)
page, _row = _chooser_page(noisy, row_count=0)
page.get_by_text = MagicMock(return_value=MagicMock(count=AsyncMock(return_value=0)))

with pytest.raises(FlowAccountChooserError) as exc_info:
await client._handle_account_chooser(page)

detail = str(exc_info.value)
assert "https://accounts.google.com/v3/signin/accountchooser" in detail, (
"the landing must still be named — knowing WHERE it stopped is the whole point"
)
for secret in ("client_id", "code_challenge", "state=", "TL=", "PKOA6qjxDh", "ACv9tzFkh8ZJ"):
assert secret not in detail, f"{secret!r} leaked into a user-pasteable message"


async def test_chooser_with_no_recorded_account_raises_and_redacts(tmp_path: Path) -> None:
"""A profile with no `.gflow_account` file, landing on a chooser.

This branch had no test at all — every other chooser test writes an account file
first — which is how the line stayed uncovered while the two raise sites beside it
were exercised. Found by SonarCloud's `new_coverage` gate on the redaction PR.

It is a real path: a profile authenticated before `.gflow_account` existed, or one
whose file was removed, has nothing to auto-select with.
"""
from gflow_cli.api.client import FlowApiClient

profile = tmp_path / "profile_p1"
profile.mkdir() # deliberately NO ACCOUNT_FILE

noisy = (
"https://accounts.google.com/v3/signin/accountchooser"
"?client_id=365941595420-x.apps.googleusercontent.com&state=PKOA6qjxDh"
)
client = FlowApiClient(profile_dir=profile)
page, _row = _chooser_page(noisy, row_count=1)

with pytest.raises(FlowAccountChooserError) as exc_info:
await client._handle_account_chooser(page)

detail = str(exc_info.value)
assert "no account is recorded" in detail.lower() or "auto-select" in detail
assert "https://accounts.google.com/v3/signin/accountchooser" in detail
for secret in ("client_id", "state=", "PKOA6qjxDh"):
assert secret not in detail, f"{secret!r} leaked into a user-pasteable message"
49 changes: 49 additions & 0 deletions tests/api/transports/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
flow_landing_kind,
interpret_response,
mint_batch_id,
safe_page_url,
)
from gflow_cli.errors import (
AuthExpiredError,
Expand Down Expand Up @@ -383,3 +384,51 @@ def test_total_by_construction(self, url: object) -> None:
probe error must never displace the real failure. Anything unparseable — or
not even a string — is None, exactly like its sibling `flow_host_kind`."""
assert flow_landing_kind(url) is None


class TestSafePageUrl:
"""`safe_page_url` is what keeps credentials out of user-pasteable error text.

Google's auth URLs carry `state`, `code_challenge`, `client_id` and challenge
tokens in the query, and a real `gflow image t2i` printed all of them on
2026-09-10 before this existed. Every branch is pinned here: the helper runs
while another failure is already being reported, so it must never raise.
"""

def test_strips_query_and_fragment_but_keeps_the_landing(self) -> None:
url = (
"https://accounts.google.com/v3/signin/challenge/pwd"
"?TL=ACv9tzFkh8ZJ&state=PKOA6qjxDh&client_id=365941595420-x#frag"
)
assert safe_page_url(url) == "https://accounts.google.com/v3/signin/challenge/pwd"

def test_a_clean_url_is_unchanged(self) -> None:
assert safe_page_url("https://flow.google.com/about") == "https://flow.google.com/about"

def test_keeps_the_path_when_there_is_no_query(self) -> None:
url = "https://flow.google.com/project/abc-123"
assert safe_page_url(url) == url

@pytest.mark.parametrize(
("value", "expected"),
[
(None, ""),
("", ""),
# Not a URL at all: returned verbatim, because a caller printing "the page
# is at <whatever playwright gave us>" is still more useful than an empty
# string, and there is no query to strip.
("not a url", "not a url"),
("about:blank", "about:blank"),
],
)
def test_degenerate_inputs(self, value: object, expected: str) -> None:
assert safe_page_url(value) == expected

def test_unparseable_url_returns_empty_rather_than_raising(self) -> None:
"""`urlsplit("https://[bad")` raises ValueError. This helper is only ever
called while another failure is being reported, so a probe error here would
displace the real one."""
assert safe_page_url("https://[bad") == ""

def test_non_string_input_is_coerced_not_crashed(self) -> None:
assert safe_page_url(12345) == "12345"