Skip to content

Commit 62751de

Browse files
authored
fix(auth): prevent oauth callback token injection (#1718)
1 parent 7ca1d83 commit 62751de

5 files changed

Lines changed: 208 additions & 28 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.81"
3+
version = "2.10.82"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/_cli/_auth/_auth_server.py

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import hmac
23
import http.server
34
import json
45
import os
@@ -10,15 +11,6 @@
1011
PORT = 6234
1112

1213

13-
# Custom exception for token received
14-
class TokenReceivedSignal(Exception):
15-
"""Exception raised when a token is successfully received."""
16-
17-
def __init__(self, token_data):
18-
self.token_data = token_data
19-
super().__init__("Token received successfully")
20-
21-
2214
def make_request_handler_class(
2315
state, code_verifier, token_callback, domain, redirect_uri, client_id
2416
):
@@ -29,12 +21,60 @@ def log_message(self, format, *args) -> None:
2921
# do nothing
3022
pass
3123

24+
def _is_host_allowed(self) -> bool:
25+
"""Reject requests whose Host header is not loopback.
26+
27+
Defends against DNS rebinding since the legitimate flow
28+
always lands on localhost.
29+
"""
30+
host = self.headers.get("Host", "")
31+
hostname = host.rsplit(":", 1)[0]
32+
return hostname in ("localhost", "127.0.0.1")
33+
34+
def _handle_host_error(self) -> bool:
35+
"""Return True if a host error was identified and handled (403)."""
36+
if not self._is_host_allowed():
37+
self.send_error(403, "Invalid host")
38+
return True
39+
return False
40+
41+
def _state_is_valid(self) -> bool:
42+
"""Validate the OAuth state supplied."""
43+
received = self.headers.get("X-Auth-State", "")
44+
return hmac.compare_digest(received, state)
45+
46+
def _handle_state_error(self) -> bool:
47+
"""Return True if a state error was identified and handled (403)."""
48+
if not self._state_is_valid():
49+
self.send_error(403, "Invalid or missing state")
50+
return True
51+
return False
52+
53+
def _read_json_body(self):
54+
"""Read and parse the JSON request body.
55+
56+
Returns the decoded object, or None if the
57+
expected headers are missing or body is malformed.
58+
"""
59+
try:
60+
content_length = int(self.headers["Content-Length"])
61+
post_data = self.rfile.read(content_length)
62+
return json.loads(post_data.decode("utf-8"))
63+
except (KeyError, TypeError, ValueError):
64+
self.send_error(400, "Invalid request")
65+
return None
66+
3267
def do_POST(self):
3368
"""Handle POST requests to /set_token."""
69+
if self._handle_host_error():
70+
return
3471
if self.path == "/set_token":
35-
content_length = int(self.headers["Content-Length"])
36-
post_data = self.rfile.read(content_length)
37-
token_data = json.loads(post_data.decode("utf-8"))
72+
if self._handle_state_error():
73+
return
74+
75+
token_data = self._read_json_body()
76+
if token_data is None:
77+
return
3878

3979
self.send_response(200)
4080
self.end_headers()
@@ -44,9 +84,13 @@ def do_POST(self):
4484

4585
token_callback(token_data)
4686
elif self.path == "/log":
47-
content_length = int(self.headers["Content-Length"])
48-
post_data = self.rfile.read(content_length)
49-
logs = json.loads(post_data.decode("utf-8"))
87+
if self._handle_state_error():
88+
return
89+
90+
logs = self._read_json_body()
91+
if logs is None:
92+
return
93+
5094
# Write logs to .uipath/.error_log file
5195
uipath_dir = os.path.join(os.getcwd(), ".uipath")
5296
os.makedirs(uipath_dir, exist_ok=True)
@@ -66,6 +110,8 @@ def do_POST(self):
66110

67111
def do_GET(self):
68112
"""Handle GET requests by serving index.html."""
113+
if self._handle_host_error():
114+
return
69115
# Always serve index.html regardless of the path
70116
try:
71117
index_path = os.path.join(os.path.dirname(__file__), "index.html")
@@ -86,16 +132,6 @@ def do_GET(self):
86132
except FileNotFoundError:
87133
self.send_error(404, "File not found")
88134

89-
def end_headers(self):
90-
self.send_header("Access-Control-Allow-Origin", "*")
91-
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
92-
self.send_header("Access-Control-Allow-Headers", "Content-Type")
93-
super().end_headers()
94-
95-
def do_OPTIONS(self):
96-
self.send_response(200)
97-
self.end_headers()
98-
99135
return SimpleHTTPSRequestHandler
100136

101137

@@ -149,7 +185,7 @@ def create_server(self, state, code_verifier, domain):
149185
self.redirect_uri,
150186
self.client_id,
151187
)
152-
self.httpd = socketserver.TCPServer(("", self.port), handler)
188+
self.httpd = socketserver.TCPServer(("127.0.0.1", self.port), handler)
153189
return self.httpd
154190

155191
def _run_server(self):

packages/uipath/src/uipath/_cli/_auth/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,9 @@ <h1 class="auth-title" id="main-title">Authenticate CLI</h1>
519519
async function sendLogs(logs) {
520520
await fetch(`${baseUrl}/log`, {
521521
method: 'POST',
522+
headers: {
523+
'X-Auth-State': "__PY_REPLACE_EXPECTED_STATE__"
524+
},
522525
body: JSON.stringify(logs)
523526
});
524527
}
@@ -559,6 +562,9 @@ <h1 class="auth-title" id="main-title">Authenticate CLI</h1>
559562
await sendLogs(logs);
560563
await fetch(`${baseUrl}/set_token`, {
561564
method: 'POST',
565+
headers: {
566+
'X-Auth-State': state
567+
},
562568
body: JSON.stringify(tokenData)
563569
});
564570

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Security tests for the OAuth local callback server.
2+
3+
Covers GHSA-32xc-7x5c-8vmf: the `/set_token` and `/log` endpoints must reject
4+
unauthenticated POSTs (no / wrong OAuth `state`), and the server must bind to
5+
loopback only rather than all interfaces.
6+
"""
7+
8+
import json
9+
import os
10+
import threading
11+
import urllib.error
12+
import urllib.request
13+
14+
from uipath._cli._auth._auth_server import HTTPServer
15+
16+
STATE = "LEGITIMATE_OAUTH_STATE_ABCDE12345"
17+
CODE_VERIFIER = "LEGITIMATE_PKCE_CODE_VERIFIER"
18+
DOMAIN = "cloud.uipath.com"
19+
20+
ATTACKER_PAYLOAD = {
21+
"access_token": "attacker-token",
22+
"refresh_token": "attacker-refresh",
23+
"expires_in": 3600,
24+
"token_type": "Bearer",
25+
"scope": "offline_access",
26+
}
27+
28+
29+
def _request(port, path, data, headers=None, method="POST"):
30+
req = urllib.request.Request(
31+
f"http://127.0.0.1:{port}{path}",
32+
data=data,
33+
headers=headers or {},
34+
method=method,
35+
)
36+
try:
37+
with urllib.request.urlopen(req, timeout=5) as resp:
38+
return resp.status, resp.read().decode("utf-8")
39+
except urllib.error.HTTPError as exc:
40+
return exc.code, exc.read().decode("utf-8")
41+
42+
43+
def _post(port, path, body, headers=None):
44+
return _request(
45+
port,
46+
path,
47+
json.dumps(body).encode("utf-8"),
48+
{"Content-Type": "application/json", **(headers or {})},
49+
)
50+
51+
52+
async def test_endpoints_reject_unauthenticated_posts(tmp_path, monkeypatch):
53+
"""Only requests carrying the matching OAuth state are accepted.
54+
55+
Exercises both /set_token and /log with missing, wrong, and valid state.
56+
"""
57+
monkeypatch.chdir(tmp_path)
58+
59+
# Binding happens in create_server; the listen socket is up before the
60+
# handler thread starts, so connections queue and no readiness sleep is
61+
# needed. redirect_uri/client_id are required by the GET (index.html) path.
62+
server = HTTPServer(
63+
port=0, redirect_uri="http://localhost/callback", client_id="test-client"
64+
)
65+
httpd = server.create_server(STATE, CODE_VERIFIER, DOMAIN)
66+
port = httpd.server_address[1]
67+
68+
results = {}
69+
70+
def client():
71+
# DNS rebinding
72+
results["rebind_get"] = _request(
73+
port, "/", None, {"Host": "not-localhost.com"}, method="GET"
74+
)
75+
results["rebind_post"] = _post(
76+
port,
77+
"/set_token",
78+
ATTACKER_PAYLOAD,
79+
{"X-Auth-State": STATE, "Host": "evil.com"},
80+
)
81+
# GET serves index.html with the OAuth params substituted in.
82+
results["get"] = _request(port, "/anything", None, method="GET")
83+
# /set_token: missing and wrong state are rejected.
84+
results["set_missing"] = _post(port, "/set_token", ATTACKER_PAYLOAD)
85+
results["set_wrong"] = _post(
86+
port, "/set_token", ATTACKER_PAYLOAD, {"X-Auth-State": "not-the-state"}
87+
)
88+
# Valid state but a non-JSON body -> graceful 400, not 500.
89+
results["set_malformed"] = _request(
90+
port, "/set_token", b"not json", {"X-Auth-State": STATE}
91+
)
92+
# Unknown path -> 404.
93+
results["unknown"] = _post(port, "/nope", {"x": 1}, {"X-Auth-State": STATE})
94+
# /log: missing and valid state.
95+
results["log_missing"] = _post(port, "/log", {"msg": "x"})
96+
results["log_valid"] = _post(
97+
port, "/log", {"msg": "x"}, {"X-Auth-State": STATE}
98+
)
99+
# Valid /set_token last, to capture the token and unblock start().
100+
results["set_valid"] = _post(
101+
port, "/set_token", {"access_token": "real"}, {"X-Auth-State": STATE}
102+
)
103+
104+
t = threading.Thread(target=client, daemon=True)
105+
t.start()
106+
token_data = await server.start(STATE, CODE_VERIFIER, DOMAIN)
107+
t.join(timeout=5)
108+
109+
# DNS rebinding: forged Host is rejected on both GET and POST.
110+
assert results["rebind_get"][0] == 403
111+
assert results["rebind_post"][0] == 403
112+
113+
# GET returns the page with the real state injected, placeholder gone.
114+
assert results["get"][0] == 200
115+
assert STATE in results["get"][1]
116+
assert "__PY_REPLACE_EXPECTED_STATE__" not in results["get"][1]
117+
118+
assert results["set_missing"][0] == 403
119+
assert results["set_wrong"][0] == 403
120+
assert results["set_malformed"][0] == 400
121+
assert results["unknown"][0] == 404
122+
assert results["log_missing"][0] == 403
123+
assert results["log_valid"][0] == 200
124+
assert results["set_valid"][0] == 200
125+
126+
# Only the valid, state-bearing request was accepted.
127+
assert token_data == {"access_token": "real"}
128+
# The state-protected /log write happened for the valid request only.
129+
assert os.path.exists(tmp_path / ".uipath" / ".error_log")
130+
131+
132+
def test_server_binds_to_loopback_only():
133+
server = HTTPServer(port=0)
134+
httpd = server.create_server(STATE, CODE_VERIFIER, DOMAIN)
135+
try:
136+
assert httpd.server_address[0] == "127.0.0.1"
137+
finally:
138+
httpd.server_close()

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)