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

Ouverte
#18,296 0 commentaires 0 réactions 1 personne assignée Réclamée par @daniel-sanche Voir sur GitHub
priority: p2 type: bug
Langage dominant
Python
Étoiles
5.4k
Forks
1.8k
Merge moyen
3 j 4 h
PR mergées (30 j)
122

Description

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

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.