googleapis / googleapis/google-cloud-python
google-auth-oauthlib: run_local_server advertises http://localhost:PORT but binds IPv4 only, so on Windows the OAuth callback can be silently delivered to another process
- Vorherrschende Sprache
- Python
- Sterne
- 5.4k
- Forks
- 1.8k
- Ø Merge
- 3 T. 4 Std.
- Gemergte PRs (30 T.)
- 122
Beschreibung
### Determine this is the right repository
- [x] I determined this is the correct repository in which to report this bug.
### Summary of the issue
**Context**
On Windows, `localhost` resolves `::1` before `127.0.0.1`. `InstalledAppFlow.run_local_server()` advertises the *name* `localhost` in `redirect_uri`, but the server it starts is `AF_INET` only and never listens on `::1`.
If any other process is listening on `[::]:PORT` (the default for Go's `net.Listen("tcp", ":PORT")`), the browser's redirect goes to that process instead. The authorization code is delivered to it and `run_local_server()` waits forever.
**Expected Behavior:**
Either the callback reaches the local server, or the bind fails with a clear error.
**Actual Behavior:**
The bind succeeds, the browser shows the other process's response, and `run_local_server()` hangs. Neither side reports an error.
The `SO_EXCLUSIVEADDRUSE` fix added in #18166 does not prevent this. I measured it - see "Reproduction steps: actual results".
### API client name and version
google-auth-oauthlib (vendored in Google Cloud SDK 548.0.0; same code path in current main of packages/google-auth-oauthlib)
### Reproduction steps: code
Reproduced through `gcloud auth login --launch-browser`, which calls `run_local_server()`.
The relevant code, `packages/google-auth-oauthlib/google_auth_oauthlib/flow.py`:
```python
def run_local_server(
self,
host="localhost",
...
local_server = wsgiref.simple_server.make_server(
bind_addr or host, port, wsgi_app,
server_class=_ExclusiveWSGIServer, handler_class=_WSGIRequestHandler,
)
...
self.redirect_uri = redirect_uri_format.format(host, local_server.server_port)
```
`wsgiref.simple_server.WSGIServer.address_family` is `socket.AF_INET`, and `_ExclusiveWSGIServer` overrides only `server_bind`, so the address family is unchanged. `host="localhost"` resolves to `127.0.0.1` and the server never listens on `::1`, while `redirect_uri` still carries the name `localhost`.
file: bind_test.py
```python
import socket
PORT = 8085
def try_bind(label, family, addr, exclusive):
s = socket.socket(family, socket.SOCK_STREAM)
try:
if exclusive:
s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
s.bind((addr, PORT))
s.listen(1)
print(f"[bind OK] {label}")
except OSError as e:
print(f"[bind FAIL] {label}: {e}")
finally:
s.close()
try_bind("127.0.0.1 without SO_EXCLUSIVEADDRUSE", socket.AF_INET, "127.0.0.1", False)
try_bind("127.0.0.1 with SO_EXCLUSIVEADDRUSE", socket.AF_INET, "127.0.0.1", True)
try_bind("::1 without SO_EXCLUSIVEADDRUSE", socket.AF_INET6, "::1", False)
```
### Reproduction steps: supporting files
Any process holding the IPv6 wildcard on the port is enough. A Go program using the default listen address does this:
file: other.go
```go
package main
import "net/http"
func main() {
// net.Listen("tcp", ":8085") creates a dual-stack IPv6 socket bound to [::]
http.ListenAndServe(":8085", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"error":"Unauthorized","success":false}`))
}))
}
```
Run it first, then run `gcloud auth login --launch-browser` (or any `run_local_server()` call) in another shell.
### Reproduction steps: actual results
With a Go service holding `[::]:8085`:
```
> Get-NetTCPConnection -LocalPort 8nd the browser still reaches the other process.
The third line matters for the fix: `::1` is bindable even while `[::]` is held.085 -State Listen |
ForEach-Object { $p = Get-Process -Id $_.OwningProcess; "{0,-12} PID={1,-6} {2}" -f $_.LocalAddress, $_.OwningProcess, $p.ProcessName }
:: PID=11696 main <- the other service
127.0.0.1 PID=30012 python <- the callback server
```
`localhost` resolution on this machine (hosts file has both entries commented out; Windows resolves it internally):
```
Name Type IPAddress
localhost AAAA ::1 <- tried first
localhost A 127.0.0.1
```
gcloud printed `redirect_uri=http://localhost:8085/`. After completing the consent screen the browser landed on `http://localhost:8085/?state=...&code=...` and displayed the other service's body:
```json
{"error":"Unauthorized","success":false}
```
`run_local_server()` never returned.
bind_test.py, run while the Go service holds `[::]:8085`:
```
[bind OK] 127.0.0.1 without SO_EXCLUSIVEADDRUSE
[bind OK] 127.0.0.1 with SO_EXCLUSIVEADDRUSE
[bind OK] ::1 without SO_EXCLUSIVEADDRUSE
```
On Windows a wildcard `[::]` bind and a specific-address bind coexist, so `SO_EXCLUSIVEADDRUSE` sees no conflict. The bind succeeds, the server believes it owns the port, a
### Reproduction steps: expected results
Either of these would be correct:
- The callback reaches the local server and `run_local_server()` returns credentials, or
- The bind fails with a clear error, e.g.
```
[bind FAIL] 127.0.0.1 with SO_EXCLUSIVEADDRUSE: [WinError 10048] Only one usage of each socket address ... is normally permitted
```
What must not happen is the current outcome: a successful bind, an authorization code handed to an unrelated local process, and a silent hang.
### OS & version + platform
Windows 11 Home 10.0.26200.0
### Python environment
Python 3.12.10 (bundled with Google Cloud SDK)
### Python dependencies
google-auth-oauthlib as vendored in Google Cloud SDK 548.0.0 (lib/third_party/google_auth_oauthlib), not a pip install.
The same code path is present in current main of packages/google-auth-oauthlib.
### Additional context
**Suggested fix**
Any one of these removes the mismatch:
1. Advertise the loopback IP literal instead of the name - build `redirect_uri` from `127.0.0.1`. RFC 8252 section 7.3 recommends this precisely because name resolution is outside the client's control, and Google's OAuth documentation accepts `http://127.0.0.1:`.
2. When the advertised host is `localhost`, listen on both `127.0.0.1` and `::1`. The measurement above shows `::1` is bindable in this situation.
3. At minimum, make the failure visible: after binding, request the advertised `redirect_uri` and verify the response came from this server. Today the failure mode is complete silence on both sides, which is what makes it expensive to diagnose.
**Workaround for users hitting this**
Re-open the redirect URL with the host replaced by the IP literal, keeping the query string intact:
```
http://127.0.0.1:8085/?state=...&code=...&scope=...
```
This reaches the IPv4 bind, the code is received and the login completes.
**Note for the Cloud SDK**
gcloud 548.0.0 vendors a copy of `google_auth_oauthlib` that predates #18166 - it has no `_ExclusiveWSGIServer` and no `SO_EXCLUSIVEADDRUSE` (zero matches in `lib/third_party/google_auth_oauthlib/flow.py`). Re-vendoring is worth doing on its own merits, but as measured above it would not fix this particular failure.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.