Reproduction steps:
- An operator runs the sami666/google-sheets module's
google.sheets_bootstrap_oauth command (handlers/handler.py) to set up
OAuth: {"client_id": "...", "client_secret": "..."}. This builds a Google
consent URL, opens it in the operator's browser, and starts a one-shot
HTTP server on 127.0.0.1:<port> (default 8765, logged/returned to the
caller) to catch the redirect — waiting up to timeout_s (default 180s).
- Drive the REAL
_capture_oauth_code(port, timeout_s)directly: start it
listening, then send a single unsolicited GET to
http://127.0.0.1:<port>/callback?code=ATTACKER_INJECTED_AUTH_CODE — no
state/nonce, nothing proving the request originated from Google's real
redirect.
- Separately, drive the REAL
google_sheets_bootstrap_oauth(...)far enough
to capture the literal consent URL it builds (via
_open_url_best_effort) and inspect its query parameters.
- Run repro_google_sheets_oauth_state_csrf_v131.py against a clean v1.3.1
extraction. Output:
PROOF 1: CONFIRMED — code the REAL _capture_oauth_code() returned:
'ATTACKER_INJECTED_AUTH_CODE_4f9c21' (exactly the forged value)
PROOF 2: CONFIRMED — auth_url query parameters present:
['access_type', 'client_id', 'prompt', 'redirect_uri', 'response_type',
'scope'] (no 'state')
Expected:
An OAuth authorization-code flow that finishes by writing a DEFAULT,
auto-activated credential into the vault (`_save_to_named_vault(..., "id":
"google-sheets", "set_default": True)`, which subsequentgoogle.sheets_append_row/google.sheets_get_metadata calls trust
unconditionally) must bind the outbound consent request to the inbound
callback with an unguessable, single-use state value, and the callback
handler must reject any request whose state doesn't match — this is
RFC 6749 §10.12's documented mitigation for exactly this flow shape, and is
what stops a request the operator never initiated from completing the
"grant".
Actual:
The consent URL built in google_sheets_bootstrap_oauth (handler.py:535-545)
omits state entirely — its urlencode({...}) dict carries only client_id/
redirect_uri/response_type/scope/access_type/prompt. _capture_oauth_code
(handler.py:427-479) treats the FIRST ?code= value received on the listening
port as authoritative, with no check of any kind against who sent it or
whether it corresponds to the request the operator's browser made. Because
the redirect URI is a fixed, predictable loopback address
(http://127.0.0.1:8765/callback by default — and the port is even echoed
back to the caller, so it's discoverable even when non-default) and the
listener is a plain HTTP server that answers any client, ANY request
delivered to that port during the up-to-180s window wins if it arrives
before the operator's real redirect — including a plain cross-origin GET
(no CORS preflight needed against a non-browser-aware HTTP server) issued by
a malicious page the operator's browser has open, or any other local
process. The attacker does not need to steal the operator's Google session:
they run their OWN authorization against the (public, non-secret) client_id/
redirect_uri pair — visible right in the consent URL the module itself opens
and logs — using an account they control, and race their resulting code to
the loopback port. The station then exchanges that code, and_save_to_named_vault installs the resulting refresh_token as the DEFAULT
google-sheets credential (set_default: true), immediately upgrading the
provider to activated. Every subsequent google.sheets_append_row /google.sheets_get_metadata call — which the platform treats as ordinary
governed writes/reads, with no signal anything is wrong — now operates
against the ATTACKER's Google Sheets identity: real automated writes get
redirected into a spreadsheet the attacker controls (silent exfiltration of
whatever data the workflow appends), or reads pull from a sheet the attacker
populated (data poisoning of anything downstream that trusts the read). This
is credential/session SUBSTITUTION rather than credential theft, but the
practical blast radius on an automation platform whose whole model is
"governed effects land where the operator configured" is the same class of
harm as the platform's other cross-identity-redirection findings (e.g. the
dag/run context-override that retargets an approved send).
Suggested fix:
Bind the flow with a per-run state value the callback verifies before
accepting a code:
import secrets
state = secrets.token_urlsafe(24)
auth_url = ("https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({
"client_id": cid, "redirect_uri": redirect, "response_type": "code",
"scope": scope, "access_type": "offline", "prompt": "consent",
"state": state,
}))
...
code = _capture_oauth_code(port, timeout_s, expected_state=state)
def _capture_oauth_code(port, timeout_s, expected_state):
...
def do_GET(self):
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
got_state = (params.get("state") or [None])[0]
code = (params.get("code") or [None])[0]
if code and got_state != expected_state and not captured["code"]:
# Wrong/missing state — reject, keep listening for the real one.
self.send_response(400); ...; return
...
A request whose state doesn't match the one this specific run generated
must never be treated as "the" redirect, regardless of which request arrives
first.