Skip to content

Commit 9da4685

Browse files
committed
Fix footguns: AAI_AUTH_PORT import crash + spawn fd leak
- auth/endpoints.py parsed AAI_AUTH_PORT with a module-level int() at import time. This module is on the CLI's import hot path, so a malformed value (e.g. AAI_AUTH_PORT=abc) raised a raw ValueError that crashed *every* aai command, even 'aai --help' — not just 'aai login'. Resolve the port lazily in a validated loopback_port() that raises a clean CLIError (with range check) only on the login path. - init/runner.spawn() passed log_path.open("w") straight to Popen and never closed the parent's handle, leaking a file descriptor for the lifetime of the (long-lived) tunnel process. Close it after Popen returns; the child keeps its own dup. Tests updated to drive the port via AAI_AUTH_PORT, with added boundary coverage (1, 65535, 65536, 0, non-integer).
1 parent efbd2a1 commit 9da4685

6 files changed

Lines changed: 98 additions & 20 deletions

File tree

aai_cli/auth/endpoints.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,47 @@
33
import os
44

55
from aai_cli import environments
6+
from aai_cli.errors import CLIError
67

78
# Constant across environments.
89
STYTCH_OAUTH_PROVIDER = "google"
910
CLI_TOKEN_NAME = "AssemblyAI CLI" # noqa: S105 - display name, not a credential
1011

1112
# Fixed loopback (Stytch does exact-match redirect validation; 8585 is registered).
1213
LOOPBACK_HOST = "127.0.0.1"
13-
LOOPBACK_PORT = int(os.environ.get("AAI_AUTH_PORT", "8585"))
1414
LOOPBACK_PATH = "/callback"
15+
_DEFAULT_LOOPBACK_PORT = 8585
16+
_MAX_PORT = 65535 # highest valid TCP port
17+
18+
19+
def _invalid_auth_port(raw: str) -> CLIError:
20+
return CLIError(
21+
f"AAI_AUTH_PORT must be a port number in 1-65535, got {raw!r}.",
22+
error_type="invalid_env",
23+
exit_code=2,
24+
suggestion="Unset AAI_AUTH_PORT, or set it to a free port in 1-65535.",
25+
)
26+
27+
28+
def loopback_port() -> int:
29+
"""The loopback callback port, overridable via ``AAI_AUTH_PORT`` (dev/test only).
30+
31+
Resolved lazily — never at import — and validated, so a malformed override
32+
surfaces as a clean CLIError on the login path instead of the raw ``ValueError``
33+
a module-level ``int(...)`` would raise. This module sits on the CLI's import hot
34+
path, so that ValueError would otherwise crash *every* ``aai`` command (even
35+
``--help``), not just ``aai login``.
36+
"""
37+
raw = os.environ.get("AAI_AUTH_PORT")
38+
if raw is None:
39+
return _DEFAULT_LOOPBACK_PORT
40+
try:
41+
port = int(raw)
42+
except ValueError as exc:
43+
raise _invalid_auth_port(raw) from exc
44+
if not 1 <= port <= _MAX_PORT:
45+
raise _invalid_auth_port(raw)
46+
return port
1547

1648

1749
# Environment-specific values resolve from the active environment (see
@@ -36,4 +68,4 @@ def signup_url() -> str:
3668

3769
def redirect_uri() -> str:
3870
"""The exact loopback redirect URL registered in Stytch."""
39-
return f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}{LOOPBACK_PATH}"
71+
return f"http://{LOOPBACK_HOST}:{loopback_port()}{LOOPBACK_PATH}"

aai_cli/auth/loopback.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,13 @@ def do_GET(self) -> None: # stdlib API name
7070
def log_message(self, format: str, *args: object) -> None: # silence stderr logging
7171
pass
7272

73+
port = endpoints.loopback_port()
7374
try:
74-
server = HTTPServer((endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT), Handler)
75+
server = HTTPServer((endpoints.LOOPBACK_HOST, port), Handler)
7576
except OSError as exc:
7677
raise APIError(
7778
f"Could not start the login callback server on "
78-
f"{endpoints.LOOPBACK_HOST}:{endpoints.LOOPBACK_PORT} ({exc}). "
79+
f"{endpoints.LOOPBACK_HOST}:{port} ({exc}). "
7980
"Close whatever is using that port and run 'aai login' again."
8081
) from exc
8182
thread = threading.Thread(target=server.serve_forever, daemon=True)

aai_cli/init/runner.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,21 @@ def spawn(
8484
used to capture cloudflared's output for URL discovery. Without it, stdio is inherited.
8585
"""
8686
if log_path is not None:
87-
return subprocess.Popen(
88-
command,
89-
cwd=cwd,
90-
env=env,
91-
stdout=log_path.open("w"),
92-
stderr=subprocess.STDOUT,
93-
text=True,
94-
)
87+
# The child gets its own dup of the fd once Popen returns, so close the
88+
# parent's handle straight away instead of leaking it for the (long-lived)
89+
# process's whole lifetime.
90+
log = log_path.open("w")
91+
try:
92+
return subprocess.Popen(
93+
command,
94+
cwd=cwd,
95+
env=env,
96+
stdout=log,
97+
stderr=subprocess.STDOUT,
98+
text=True,
99+
)
100+
finally:
101+
log.close()
95102
return subprocess.Popen(command, cwd=cwd, env=env, text=True)
96103

97104

tests/test_auth_endpoints.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import pytest
2+
13
from aai_cli.auth import endpoints
4+
from aai_cli.errors import CLIError
25

36

47
def test_redirect_uri_is_fixed_loopback():
@@ -19,5 +22,37 @@ def test_constants_are_environment_independent():
1922

2023

2124
def test_env_override_changes_redirect_uri(monkeypatch):
22-
monkeypatch.setattr(endpoints, "LOOPBACK_PORT", 9999)
25+
monkeypatch.setenv("AAI_AUTH_PORT", "9999")
2326
assert endpoints.redirect_uri() == "http://127.0.0.1:9999/callback"
27+
28+
29+
def test_loopback_port_rejects_non_integer(monkeypatch):
30+
# A typo'd AAI_AUTH_PORT must surface as a clean CLIError on the login path,
31+
# not a raw ValueError that would crash every command at import time.
32+
monkeypatch.setenv("AAI_AUTH_PORT", "abc")
33+
with pytest.raises(CLIError) as excinfo:
34+
endpoints.loopback_port()
35+
assert excinfo.value.exit_code == 2
36+
assert "AAI_AUTH_PORT" in str(excinfo.value)
37+
38+
39+
def test_loopback_port_rejects_above_max(monkeypatch):
40+
# 65535 is the highest valid TCP port; one past it must be rejected.
41+
monkeypatch.setenv("AAI_AUTH_PORT", "65536")
42+
with pytest.raises(CLIError):
43+
endpoints.loopback_port()
44+
45+
46+
def test_loopback_port_accepts_boundary_values(monkeypatch):
47+
# The valid range is exactly 1..65535 inclusive.
48+
monkeypatch.setenv("AAI_AUTH_PORT", "1")
49+
assert endpoints.loopback_port() == 1
50+
monkeypatch.setenv("AAI_AUTH_PORT", "65535")
51+
assert endpoints.loopback_port() == 65535
52+
53+
54+
def test_loopback_port_rejects_zero(monkeypatch):
55+
# Port 0 (OS-assign) is meaningless for a fixed, pre-registered redirect URI.
56+
monkeypatch.setenv("AAI_AUTH_PORT", "0")
57+
with pytest.raises(CLIError):
58+
endpoints.loopback_port()

tests/test_auth_loopback.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def _unique_loopback_port(monkeypatch):
2323
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
2424
probe.bind((endpoints.LOOPBACK_HOST, 0))
2525
port = probe.getsockname()[1]
26-
monkeypatch.setattr(endpoints, "LOOPBACK_PORT", port)
26+
monkeypatch.setenv("AAI_AUTH_PORT", str(port))
2727

2828

2929
def _hit(path: str) -> int | None:
@@ -35,7 +35,7 @@ def _hit(path: str) -> int | None:
3535
# Retry briefly until the server thread is bound.
3636
for _ in range(50):
3737
conn = http.client.HTTPConnection(
38-
endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT, timeout=2
38+
endpoints.LOOPBACK_HOST, endpoints.loopback_port(), timeout=2
3939
)
4040
try:
4141
conn.request("GET", path)
@@ -91,7 +91,7 @@ def _body(path: str) -> bytes:
9191
Callers first confirm the server is bound via `_hit`, so no readiness loop is
9292
needed here.
9393
"""
94-
conn = http.client.HTTPConnection(endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT, timeout=2)
94+
conn = http.client.HTTPConnection(endpoints.LOOPBACK_HOST, endpoints.loopback_port(), timeout=2)
9595
try:
9696
conn.request("GET", path)
9797
return conn.getresponse().read()
@@ -146,7 +146,7 @@ def test_capture_raises_clean_error_when_port_unavailable(monkeypatch):
146146
busy.bind((endpoints.LOOPBACK_HOST, 0))
147147
busy.listen(1)
148148
port = busy.getsockname()[1]
149-
monkeypatch.setattr(endpoints, "LOOPBACK_PORT", port)
149+
monkeypatch.setenv("AAI_AUTH_PORT", str(port))
150150
try:
151151
with pytest.raises(APIError):
152152
loopback.capture_callback(timeout=1.0)

tests/test_init_runner.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,12 @@ def fake_popen(cmd, **kwargs):
214214
runner.spawn(["cloudflared"], cwd=tmp_path, log_path=log)
215215
assert captured["kwargs"]["stderr"] is runner.subprocess.STDOUT
216216
assert captured["kwargs"]["text"] is True
217-
# stdout is an open writable handle to the log file
218-
assert captured["kwargs"]["stdout"].writable()
219-
captured["kwargs"]["stdout"].close()
217+
stdout = captured["kwargs"]["stdout"]
218+
# spawn writes the child's stdout to the log file...
219+
assert stdout.name == str(log)
220+
# ...and closes the parent's handle once Popen returns (the child keeps its dup),
221+
# so the file descriptor isn't leaked for the process's whole lifetime.
222+
assert stdout.closed is True
220223

221224

222225
def test_run_server_passes_command_and_env(monkeypatch):

0 commit comments

Comments
 (0)