Skip to content

Commit 21c1616

Browse files
committed
Validate state in Teams identity callback to prevent login CSRF
1 parent d374e90 commit 21c1616

3 files changed

Lines changed: 185 additions & 39 deletions

File tree

lib/livebook/zta/livebook_teams.ex

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -76,41 +76,22 @@ defmodule Livebook.ZTA.LivebookTeams do
7676
|> redirect(external: url)
7777
end
7878

79-
defp handle_request(name, conn, team, %{"teams_identity" => _, "code" => code}) do
80-
with {:ok, access_token} <- retrieve_access_token(team, code),
81-
{:ok, payload} <- Teams.Requests.get_user_info(team, access_token, false) do
82-
metadata = build_metadata(team.id, payload)
83-
exp = System.os_time(:second) + 3 * 3600
84-
:ets.insert(name, {access_token, {exp, metadata}})
85-
86-
{conn
87-
|> put_session(:livebook_teams_access_token, access_token)
88-
|> put_session(:livebook_teams_metadata_node, node())
89-
|> redirect(to: conn.request_path)
90-
|> halt(), metadata}
79+
defp handle_request(name, conn, team, %{"teams_identity" => _} = params) do
80+
if valid_auth_state?(conn, params) do
81+
conn = delete_session(conn, :teams_auth_state)
82+
handle_identity_callback(name, conn, team, params)
9183
else
92-
_ ->
93-
{conn
94-
|> put_session(:teams_error, true)
95-
|> redirect(to: conn.request_path)
96-
|> halt(), nil}
84+
restart_user_authentication(conn)
9785
end
9886
end
9987

100-
defp handle_request(_name, conn, _team, %{"teams_identity" => _, "failed_reason" => reason}) do
101-
{conn
102-
|> put_session(:teams_failed_reason, reason)
103-
|> redirect(to: conn.request_path)
104-
|> halt(), nil}
105-
end
106-
10788
defp handle_request(_name, conn, team, %{"teams_redirect" => _, "redirect_to" => redirect_to}) do
10889
case Teams.Requests.create_auth_request(team) do
10990
{:ok, %{"authorize_uri" => authorize_uri}} ->
11091
uri =
11192
authorize_uri
11293
|> URI.new!()
113-
|> URI.append_query("redirect_to=#{redirect_to}")
94+
|> URI.append_query(URI.encode_query(%{"redirect_to" => redirect_to}))
11495

11596
{conn
11697
|> redirect(external: URI.to_string(uri))
@@ -153,6 +134,38 @@ defmodule Livebook.ZTA.LivebookTeams do
153134
end
154135
end
155136

137+
defp handle_identity_callback(name, conn, team, %{"code" => code}) do
138+
with {:ok, access_token} <- retrieve_access_token(team, code),
139+
{:ok, payload} <- Teams.Requests.get_user_info(team, access_token, false) do
140+
metadata = build_metadata(team.id, payload)
141+
exp = System.os_time(:second) + 3 * 3600
142+
:ets.insert(name, {access_token, {exp, metadata}})
143+
144+
{conn
145+
|> put_session(:livebook_teams_access_token, access_token)
146+
|> put_session(:livebook_teams_metadata_node, node())
147+
|> redirect(to: conn.request_path)
148+
|> halt(), metadata}
149+
else
150+
_ ->
151+
{conn
152+
|> put_session(:teams_error, true)
153+
|> redirect(to: conn.request_path)
154+
|> halt(), nil}
155+
end
156+
end
157+
158+
defp handle_identity_callback(_name, conn, _team, %{"failed_reason" => reason}) do
159+
{conn
160+
|> put_session(:teams_failed_reason, reason)
161+
|> redirect(to: conn.request_path)
162+
|> halt(), nil}
163+
end
164+
165+
defp handle_identity_callback(_name, conn, _team, _params) do
166+
restart_user_authentication(conn)
167+
end
168+
156169
defp retrieve_access_token(team, code) do
157170
with {:ok, %{"access_token" => access_token}} <-
158171
Teams.Requests.retrieve_access_token(team, code) do
@@ -161,6 +174,13 @@ defmodule Livebook.ZTA.LivebookTeams do
161174
end
162175

163176
defp request_user_authentication(conn) do
177+
# The state binds the authentication flow to this browser session,
178+
# so that we only accept a code that we asked Livebook Teams for.
179+
# Otherwise anyone could get a code for their own identity and have
180+
# the browser complete the flow with it, effectively signing the
181+
# user into someone else's account.
182+
state = Livebook.Utils.random_long_id()
183+
164184
# We have the browser do the redirect because the browser
165185
# knows the current page location. Unfortunately, it is quite
166186
# complex to know the actual host on the server, because the
@@ -174,7 +194,8 @@ defmodule Livebook.ZTA.LivebookTeams do
174194
<title>Redirecting...</title>
175195
<script>
176196
const redirectTo = new URL(window.location.href);
177-
redirectTo.searchParams.append("teams_identity", "");
197+
redirectTo.searchParams.set("teams_identity", "");
198+
redirectTo.searchParams.set("teams_state", "#{state}");
178199
179200
const url = new URL(window.location.href);
180201
url.searchParams.set("redirect_to", redirectTo.toString());
@@ -186,7 +207,23 @@ defmodule Livebook.ZTA.LivebookTeams do
186207
</html>
187208
"""
188209

189-
{conn |> html(html_document) |> halt(), nil}
210+
{conn |> put_session(:teams_auth_state, state) |> html(html_document) |> halt(), nil}
211+
end
212+
213+
defp valid_auth_state?(conn, params) do
214+
with state when is_binary(state) <- get_session(conn, :teams_auth_state),
215+
param when is_binary(param) <- params["teams_state"] do
216+
Plug.Crypto.secure_compare(state, param)
217+
else
218+
_ -> false
219+
end
220+
end
221+
222+
defp restart_user_authentication(conn) do
223+
# We redirect instead of rendering the authentication page right
224+
# away, so that the parameters of the stale callback are not
225+
# carried over into the new flow
226+
{conn |> redirect(to: conn.request_path) |> halt(), nil}
190227
end
191228

192229
defp validate_access_token(name, conn, team, access_token) do

test/livebook_teams/zta/livebook_teams_test.exs

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,34 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
3030
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
3131
assert html_response(conn, 200) =~ "teams_redirect"
3232

33+
session = get_session(conn)
34+
assert state = session["teams_auth_state"]
35+
3336
redirect_to =
3437
LivebookWeb.Endpoint.url()
3538
|> URI.new!()
36-
|> URI.append_query("teams_identity")
39+
|> URI.append_query(URI.encode_query(%{"teams_identity" => "", "teams_state" => state}))
40+
|> URI.to_string()
3741

3842
# Step 2: Checks if the given request belongs to a browser
3943
conn =
40-
build_conn(:get, "/", %{teams_redirect: "", redirect_to: URI.to_string(redirect_to)})
41-
|> init_test_session(%{})
44+
build_conn(:get, "/", %{teams_redirect: "", redirect_to: redirect_to})
45+
|> init_test_session(session)
4246

4347
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
4448

4549
# Step 3: Get redirected to Livebook Teams
4650
location = Phoenix.ConnTest.redirected_to(conn)
4751
uri = URI.parse(location)
4852
assert uri.path == "/identity/authorize"
49-
assert %{"token" => token} = URI.decode_query(uri.query)
53+
assert %{"token" => token, "redirect_to" => ^redirect_to} = URI.decode_query(uri.query)
5054

5155
%{code: code} = TeamsRPC.allow_auth_request(node, token)
5256

5357
# Step 4: Emulate the redirect back with the code for validation
5458
conn =
55-
build_conn(:get, "/", %{teams_identity: "", code: code})
56-
|> init_test_session(Plug.Conn.get_session(conn))
59+
build_conn(:get, "/", %{teams_identity: "", teams_state: state, code: code})
60+
|> init_test_session(session)
5761

5862
assert {conn, %{id: _id, name: _, email: _, payload: %{}} = metadata} =
5963
LivebookTeams.authenticate(test, conn, [])
@@ -68,21 +72,82 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
6872
assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])
6973
end
7074

75+
test "does not accept a code obtained in another authentication flow",
76+
%{conn: conn, node: node, test: test} do
77+
# Someone goes through the authentication flow themselves, up to
78+
# the point where they have a code for their own identity
79+
attacker_conn = init_test_session(conn, %{})
80+
{attacker_conn, nil} = LivebookTeams.authenticate(test, attacker_conn, [])
81+
assert attacker_state = get_session(attacker_conn, :teams_auth_state)
82+
83+
redirect_to =
84+
LivebookWeb.Endpoint.url()
85+
|> URI.new!()
86+
|> URI.append_query(
87+
URI.encode_query(%{"teams_identity" => "", "teams_state" => attacker_state})
88+
)
89+
90+
attacker_conn =
91+
build_conn(:get, "/", %{teams_redirect: "", redirect_to: URI.to_string(redirect_to)})
92+
|> init_test_session(get_session(attacker_conn))
93+
94+
{attacker_conn, nil} = LivebookTeams.authenticate(test, attacker_conn, [])
95+
96+
uri = attacker_conn |> Phoenix.ConnTest.redirected_to() |> URI.parse()
97+
assert %{"token" => token} = URI.decode_query(uri.query)
98+
99+
%{code: code} = TeamsRPC.allow_auth_request(node, token)
100+
101+
# Meanwhile the victim visits Livebook and starts their own flow
102+
victim_conn = init_test_session(conn, %{})
103+
{victim_conn, nil} = LivebookTeams.authenticate(test, victim_conn, [])
104+
victim_session = get_session(victim_conn)
105+
106+
# Making the victim's browser complete the flow with the code has
107+
# no effect, no matter which state it is presented with
108+
for params <- [
109+
%{teams_identity: "", teams_state: attacker_state, code: code},
110+
%{teams_identity: "", code: code}
111+
] do
112+
conn =
113+
build_conn(:get, "/", params)
114+
|> init_test_session(victim_session)
115+
116+
assert {conn, nil} = LivebookTeams.authenticate(test, conn, [])
117+
assert redirected_to(conn, 302) == "/"
118+
refute get_session(conn, :livebook_teams_access_token)
119+
120+
# Following the redirect starts the authentication flow over
121+
conn =
122+
build_conn(:get, "/")
123+
|> init_test_session(get_session(conn))
124+
125+
assert {conn, nil} = LivebookTeams.authenticate(test, conn, [])
126+
assert html_response(conn, 200) =~ "teams_redirect"
127+
end
128+
end
129+
71130
test "shows an error when the user does not belong to the org", %{conn: conn, test: test} do
72-
# Step 1: Emulate a request coming from Teams saying the user does belong to the org
131+
# Step 1: Start the authentication flow, which stores the state in the session
73132
conn = init_test_session(conn, %{})
133+
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
134+
assert state = get_session(conn, :teams_auth_state)
74135

136+
# Step 2: Emulate a request coming from Teams saying the user does not belong to the org
75137
params_from_teams = %{
76138
"teams_identity" => "",
139+
"teams_state" => state,
77140
"failed_reason" => "you do not belong to this org"
78141
}
79142

80-
conn = %{conn | params: params_from_teams}
143+
conn =
144+
build_conn(:get, "/", params_from_teams)
145+
|> init_test_session(get_session(conn))
81146

82147
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
83148
assert conn.status == 302
84149

85-
# Step 2: follow the redirect keeping the session set in previous request
150+
# Step 3: follow the redirect keeping the session set in previous request
86151
conn =
87152
build_conn(:get, redirected_to(conn))
88153
|> init_test_session(get_session(conn))
@@ -93,6 +158,40 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
93158
"Failed to authenticate with Livebook Teams: you do not belong to this org"
94159
end
95160

161+
test "starts over when the callback carries neither code nor failure reason",
162+
%{conn: conn, test: test} do
163+
conn = init_test_session(conn, %{})
164+
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
165+
assert state = get_session(conn, :teams_auth_state)
166+
167+
conn =
168+
build_conn(:get, "/", %{teams_identity: "", teams_state: state})
169+
|> init_test_session(get_session(conn))
170+
171+
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
172+
assert redirected_to(conn, 302) == "/"
173+
end
174+
175+
test "ignores a failure reason from an unknown authentication flow",
176+
%{conn: conn, test: test} do
177+
conn = init_test_session(conn, %{})
178+
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
179+
180+
params_from_teams = %{
181+
"teams_identity" => "",
182+
"teams_state" => "invalid",
183+
"failed_reason" => "you do not belong to this org"
184+
}
185+
186+
conn =
187+
build_conn(:get, "/", params_from_teams)
188+
|> init_test_session(get_session(conn))
189+
190+
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
191+
assert redirected_to(conn, 302) == "/"
192+
refute get_session(conn, :teams_failed_reason)
193+
end
194+
96195
test "deletes the cache if access token is invalid",
97196
%{test: test, node: node, team: team} do
98197
{conn, code} = authenticate_user_on_teams(test, node, team)

test/support/integration/teams_tests.ex

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,20 @@ defmodule Livebook.TeamsIntegrationHelper do
186186
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"
187187
)
188188

189+
# The page that starts the flow stores the state in the session,
190+
# which we then need to pass back, as the browser would
191+
session =
192+
conn
193+
|> LivebookWeb.ConnCase.with_authorization(team.id, name)
194+
|> get("/")
195+
|> Plug.Conn.get_session()
196+
197+
state = session["teams_auth_state"]
198+
189199
redirect_to =
190200
LivebookWeb.Endpoint.url()
191201
|> URI.new!()
192-
|> URI.append_query("teams_identity")
202+
|> URI.append_query(URI.encode_query(%{"teams_identity" => "", "teams_state" => state}))
193203

194204
uri =
195205
conn
@@ -203,8 +213,8 @@ defmodule Livebook.TeamsIntegrationHelper do
203213

204214
session =
205215
conn
206-
|> LivebookWeb.ConnCase.with_authorization(team.id, name)
207-
|> get("/", %{teams_identity: "", code: code})
216+
|> Plug.Test.init_test_session(session)
217+
|> get("/", %{teams_identity: "", teams_state: state, code: code})
208218
|> Plug.Conn.get_session()
209219

210220
authenticated_conn = Plug.Test.init_test_session(conn, session)

0 commit comments

Comments
 (0)