Skip to content

Commit 8deaf8e

Browse files
cycomacheadclaude
authored andcommitted
fix: prevent account takeover by splitting user lookup paths
Address security vulnerability C4 by replacing the single user lookup with two distinct paths. The canonical production path now uses canvas_uid as the source of truth and refuses to link accounts when an email conflict is detected, preventing unauthorized takeovers. The legacy email-matching behavior is preserved in a separate developer path, which is strictly gated to local environments for safe testing and masquerading. - Implement canvas_lookup_or_create with strict UID matching - Implement developer_lookup_or_create for local impersonation - Add double-gated environment checks for developer logins - Add comprehensive test coverage for takeover refusal scenarios Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 604d200 commit 8deaf8e

3 files changed

Lines changed: 181 additions & 26 deletions

File tree

app/controllers/session_controller.rb

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,39 @@ def omniauth_callback
6565
expires_at: expires_at
6666
)
6767

68-
# Persist / update the user just like `create`
69-
user = find_or_create_user(user_data, access_token)
68+
# Choose the account-resolution path based on the provider.
69+
#
70+
# The :developer provider is a deliberate masquerade hole that lets a
71+
# developer log in *as* an existing user without going through Canvas. It
72+
# must never be reachable in production, so we gate it twice: the provider
73+
# is only registered in dev/test (config/initializers/omniauth.rb) AND we
74+
# re-check developer_login_allowed? here as defense in depth.
75+
developer = auth.provider == 'developer'
76+
if developer && !developer_login_allowed?
77+
Rails.logger.error('Developer login attempted in a non-permitted environment')
78+
redirect_to root_path, alert: 'Authentication failed. Please try again.'
79+
return
80+
end
7081

71-
# Auto-enroll developer login users in test courses
72-
if auth.provider == 'developer'
73-
ensure_developer_test_enrollments(user)
82+
user =
83+
if developer
84+
developer_lookup_or_create(user_data, access_token)
85+
else
86+
canvas_lookup_or_create(user_data, access_token)
87+
end
88+
89+
# canvas_lookup_or_create returns nil when it refuses to silently re-key an
90+
# existing account to a new canvas_uid (potential account takeover).
91+
if user.nil?
92+
redirect_to root_path,
93+
alert: 'We could not link your Canvas account to an existing ' \
94+
'account with the same email. Please contact an administrator.'
95+
return
7496
end
7597

98+
# Auto-enroll developer login users in test courses
99+
ensure_developer_test_enrollments(user) if developer
100+
76101
redirect_to courses_path, notice: "Logged in! Welcome, #{user_data['name']}!"
77102
rescue StandardError => e
78103
Rails.logger.error("OmniAuth callback error: #{e.message}")
@@ -101,10 +126,64 @@ def ensure_developer_test_enrollments(user)
101126
end
102127
end
103128

104-
# TODO: Refactor.
105-
def find_or_create_user(user_data, auth_token)
106-
auth_token.token
107-
user = nil
129+
# Whether the developer (masquerade) login path may be used.
130+
#
131+
# This mirrors the env guard that registers the :developer OmniAuth provider
132+
# in config/initializers/omniauth.rb -- both must be true for developer login
133+
# to be reachable. Keeping the check here (and not only in the initializer)
134+
# ensures the impersonation path can never run in production even if a
135+
# :developer callback somehow reaches the controller.
136+
def developer_login_allowed?
137+
# Rails.env.local? is true only in development and test, matching the env
138+
# guard around the :developer provider in config/initializers/omniauth.rb.
139+
Rails.env.local?
140+
end
141+
142+
# Canonical production login path.
143+
#
144+
# Canvas is the source of truth for identity, so a user is keyed by their
145+
# canvas_uid:
146+
# * If we recognize the canvas_uid, we refresh the email from Canvas.
147+
# * If we DON'T recognize the canvas_uid but the incoming email already
148+
# belongs to another account, we REFUSE to re-key that account to the new
149+
# canvas_uid and return nil. Silently re-keying would let anyone able to
150+
# create a Canvas account with a victim's email take over the victim's
151+
# account here.
152+
# * Otherwise we create a brand new account.
153+
def canvas_lookup_or_create(user_data, auth_token)
154+
user = User.find_by(canvas_uid: user_data['id'])
155+
156+
if user
157+
user.email = user_data['email']
158+
elsif User.exists?(email: user_data['primary_email'])
159+
# A different canvas_uid is presenting an email we already know. Refuse
160+
# the silent account re-key (potential takeover) and let the caller
161+
# surface an error to the user.
162+
Rails.logger.warn(
163+
"Refusing to link canvas_uid=#{user_data['id']} to existing account " \
164+
"with email=#{user_data['primary_email']}"
165+
)
166+
return nil
167+
else
168+
user = User.new(canvas_uid: user_data['id'])
169+
user.assign_attributes(
170+
email: user_data['email'],
171+
name: user_data['name']
172+
)
173+
end
174+
175+
persist_login!(user, auth_token)
176+
end
177+
178+
# Developer / test masquerade path.
179+
#
180+
# This INTENTIONALLY preserves the legacy email-first matching so a developer
181+
# can log in *as* an existing user without going through Canvas. It is a
182+
# deliberate impersonation hole and is only ever reached when
183+
# developer_login_allowed? is true (see omniauth_callback and omniauth.rb).
184+
# Do NOT use this logic for the production Canvas path -- see
185+
# canvas_lookup_or_create for why email-first matching is unsafe there.
186+
def developer_lookup_or_create(user_data, auth_token)
108187
if User.exists?(email: user_data['primary_email'])
109188
user = User.find_by(email: user_data['primary_email'])
110189
user.canvas_uid = user_data['id']
@@ -118,6 +197,13 @@ def find_or_create_user(user_data, auth_token)
118197
name: user_data['name']
119198
)
120199
end
200+
201+
persist_login!(user, auth_token)
202+
end
203+
204+
# Shared finalization for both login paths: persist the user, refresh their
205+
# LMS credentials, and establish the session.
206+
def persist_login!(user, auth_token)
121207
user.save!
122208
update_user_credential(user, auth_token)
123209

config/initializers/omniauth.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ def build_access_token
1818
# URL-encode the scopes defined in CanvasFacade
1919
encoded_scopes = CGI.escape(CanvasFacade::CANVAS_API_SCOPES)
2020

21+
# SECURITY: The :developer provider enables the masquerade login path in
22+
# SessionController (developer_lookup_or_create), which lets a developer log
23+
# in as any existing user by email without Canvas. It must NEVER be available
24+
# in production. This env guard is one of two gates; the controller also
25+
# re-checks developer_login_allowed? before taking that path.
2126
provider :developer, fields: [:email] if Rails.env.development? || Rails.env.test?
2227

2328
provider :canvas,

spec/controllers/session_controller_spec.rb

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
require 'rails_helper'
22

33
RSpec.describe SessionController, type: :controller do
4+
let(:user_data) do
5+
{
6+
'id' => '12345',
7+
'name' => 'Test User',
8+
'primary_email' => 'test@example.com',
9+
'email' => 'test@example.com'
10+
}
11+
end
12+
13+
let(:mock_token) do
14+
instance_double(OAuth2::AccessToken,
15+
token: 'new-token',
16+
refresh_token: 'new-refresh',
17+
expires_at: 2.hours.from_now.to_i)
18+
end
19+
420
describe 'GET #omniauth_callback' do
521
let(:auth_hash) do
622
OmniAuth::AuthHash.new(
@@ -34,6 +50,18 @@
3450
end
3551
end
3652

53+
context 'when a different canvas_uid presents an existing account email' do
54+
let!(:existing_user) { User.create!(email: 'test@example.com', canvas_uid: 'old_uid') }
55+
56+
it 'refuses the login and redirects to root with an error flash' do
57+
get :omniauth_callback, params: { provider: 'canvas' }
58+
59+
expect(existing_user.reload.canvas_uid).to eq('old_uid')
60+
expect(response).to redirect_to(root_path)
61+
expect(flash[:alert]).to include('could not link your Canvas account')
62+
end
63+
end
64+
3765
context 'when no auth hash is present' do
3866
before { request.env.delete('omniauth.auth') }
3967

@@ -58,29 +86,54 @@
5886
end
5987
end
6088

61-
describe '#find_or_create_user and #update_user_credential' do
62-
let(:user_data) do
63-
{
64-
'id' => '12345',
65-
'name' => 'Test User',
66-
'primary_email' => 'test@example.com',
67-
'email' => 'test@example.com'
68-
}
89+
describe '#canvas_lookup_or_create and #update_user_credential' do
90+
context 'when user exists by canvas_uid' do
91+
let!(:existing_user) { User.create!(email: 'old@example.com', canvas_uid: '12345') }
92+
93+
it 'updates email and LMS credentials' do
94+
expect do
95+
controller.send(:canvas_lookup_or_create, user_data, mock_token)
96+
end.to change { existing_user.reload.email }.from('old@example.com').to('test@example.com')
97+
98+
creds = existing_user.reload.lms_credentials.first
99+
expect(creds.token).to eq('new-token')
100+
end
69101
end
70102

71-
let(:mock_token) do
72-
instance_double(OAuth2::AccessToken,
73-
token: 'new-token',
74-
refresh_token: 'new-refresh',
75-
expires_at: 2.hours.from_now.to_i)
103+
context 'when a different canvas_uid presents an already-known email' do
104+
let!(:existing_user) { User.create!(email: 'test@example.com', canvas_uid: 'old_uid') }
105+
106+
it 'refuses to re-key the account, returns nil, and leaves it untouched' do
107+
result = nil
108+
expect do
109+
result = controller.send(:canvas_lookup_or_create, user_data, mock_token)
110+
end.not_to(change { existing_user.reload.canvas_uid })
111+
112+
expect(result).to be_nil
113+
expect(existing_user.reload.canvas_uid).to eq('old_uid')
114+
end
76115
end
77116

117+
context 'when user is new' do
118+
it 'creates the user and LMS credentials' do
119+
expect do
120+
controller.send(:canvas_lookup_or_create, user_data, mock_token)
121+
end.to change(User, :count).by(1)
122+
123+
user = User.find_by(canvas_uid: '12345')
124+
expect(user.email).to eq('test@example.com')
125+
expect(user.lms_credentials.first.token).to eq('new-token')
126+
end
127+
end
128+
end
129+
130+
describe '#developer_lookup_or_create (masquerade path)' do
78131
context 'when user exists by email' do
79132
let!(:existing_user) { User.create!(email: 'test@example.com', canvas_uid: 'old_uid') }
80133

81-
it 'updates canvas_uid and LMS credentials' do
134+
it 'masquerades by re-keying canvas_uid and updates LMS credentials' do
82135
expect do
83-
controller.send(:find_or_create_user, user_data, mock_token)
136+
controller.send(:developer_lookup_or_create, user_data, mock_token)
84137
end.to change { existing_user.reload.canvas_uid }.from('old_uid').to('12345')
85138

86139
creds = existing_user.reload.lms_credentials.first
@@ -93,15 +146,15 @@
93146

94147
it 'updates email and LMS credentials' do
95148
expect do
96-
controller.send(:find_or_create_user, user_data, mock_token)
149+
controller.send(:developer_lookup_or_create, user_data, mock_token)
97150
end.to change { existing_user.reload.email }.from('old@example.com').to('test@example.com')
98151
end
99152
end
100153

101154
context 'when user is new' do
102155
it 'creates the user and LMS credentials' do
103156
expect do
104-
controller.send(:find_or_create_user, user_data, mock_token)
157+
controller.send(:developer_lookup_or_create, user_data, mock_token)
105158
end.to change(User, :count).by(1)
106159

107160
user = User.find_by(canvas_uid: '12345')
@@ -111,6 +164,17 @@
111164
end
112165
end
113166

167+
describe '#developer_login_allowed?' do
168+
it 'is permitted in the test environment' do
169+
expect(controller.send(:developer_login_allowed?)).to be(true)
170+
end
171+
172+
it 'is not permitted in production' do
173+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
174+
expect(controller.send(:developer_login_allowed?)).to be(false)
175+
end
176+
end
177+
114178
describe 'GET #omniauth_callback (developer provider)' do
115179
let(:dev_auth_hash) do
116180
OmniAuth::AuthHash.new(

0 commit comments

Comments
 (0)