Skip to content

Commit 2ae4a27

Browse files
cycomacheadclaude
authored andcommitted
fix: resolve mass-assignment and IDOR in RequestsController
- Remove :user_id from permitted params to prevent ownership hijacking - Scope assignment lookups to the current course to prevent cross-course IDOR - Verify student enrollment before allowing instructors to file requests - Offset User factory sequence to prevent UID collisions in tests Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 604d200 commit 2ae4a27

3 files changed

Lines changed: 162 additions & 4 deletions

File tree

app/controllers/requests_controller.rb

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,18 @@ def edit
7373

7474
def create
7575
Request.merge_date_and_time!(params[:request])
76+
assignment_id = request_params[:assignment_id]
77+
78+
if assignment_id.present? && !assignment_in_course?(assignment_id)
79+
redirect_to course_requests_path(@course), alert: 'Assignment not found for this course.'
80+
return
81+
end
82+
7683
@request = @course.requests.new(request_params.merge(user: @user))
7784

7885
# Check if the assignment already has a pending request, but only if assignment_id exists
79-
if request_params[:assignment_id].present? &&
80-
Assignment.find_by(id: request_params[:assignment_id])&.has_pending_request_for_user?(@user, @course)
86+
if assignment_id.present? &&
87+
Assignment.find_by(id: assignment_id)&.has_pending_request_for_user?(@user, @course)
8188
redirect_to course_requests_path(@course), alert: 'You already have a pending request for this assignment.'
8289
return
8390
end
@@ -95,8 +102,13 @@ def create_for_student
95102

96103
student = User.find_by(id: params[:request][:user_id])
97104
return redirect_to new_course_request_path(@course), alert: 'Student not found.' unless student
105+
return redirect_to new_course_request_path(@course), alert: 'Student is not enrolled in this course.' unless student_enrolled_in_course?(student)
98106

99107
assignment_id = params[:request][:assignment_id]
108+
if assignment_id.present? && !assignment_in_course?(assignment_id)
109+
return redirect_to new_course_request_path(@course), alert: 'Assignment not found for this course.'
110+
end
111+
100112
reject_other_student_requests(student, assignment_id) if assignment_id.present?
101113

102114
Request.merge_date_and_time!(params[:request])
@@ -116,6 +128,12 @@ def update
116128

117129
Request.merge_date_and_time!(params[:request])
118130

131+
assignment_id = request_params[:assignment_id]
132+
if assignment_id.present? && !assignment_in_course?(assignment_id)
133+
flash.now[:alert] = 'There was a problem updating the request.'
134+
return render :edit
135+
end
136+
119137
if @request.update(request_params)
120138
result = @request.process_update(@user)
121139
redirect_to result[:redirect_to], notice: result[:notice]
@@ -222,7 +240,21 @@ def set_course_role_from_settings
222240
end
223241

224242
def request_params
225-
params.require(:request).permit(:assignment_id, :reason, :documentation, :custom_q1, :custom_q2, :requested_due_date, :user_id)
243+
params.require(:request).permit(:assignment_id, :reason, :documentation, :custom_q1, :custom_q2, :requested_due_date)
244+
end
245+
246+
# Confirms the assignment belongs to one of this course's linked LMSs,
247+
# preventing a request from referencing an assignment in another course.
248+
def assignment_in_course?(assignment_id)
249+
return false if assignment_id.blank?
250+
251+
Assignment.joins(:course_to_lms).exists?(id: assignment_id, course_to_lms: { course_id: @course.id })
252+
end
253+
254+
# Confirms the target student is actually enrolled in this course before an
255+
# instructor can file a request on their behalf.
256+
def student_enrolled_in_course?(student)
257+
UserToCourse.exists?(user_id: student.id, course_id: @course.id, role: UserToCourse::STUDENT_ROLE)
226258
end
227259

228260
def authenticate_user

spec/controllers/requests_controller_spec.rb

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,4 +720,128 @@
720720
expect(flash[:notice]).not_to match(/has been approved/)
721721
end
722722
end
723+
724+
describe 'Mass-assignment and IDOR protections' do
725+
let(:other_user) { User.create!(email: 'other@example.com', canvas_uid: '777', name: 'Other Student') }
726+
let(:foreign_course) { create(:course, course_name: 'Foreign Course', canvas_id: '888', course_code: 'FOR101') }
727+
let(:foreign_assignment) { foreign_course.assignments.first }
728+
729+
describe 'POST #create' do
730+
it 'ignores user_id in params and assigns the request to the current user' do
731+
post :create, params: {
732+
course_id: course.id,
733+
request: {
734+
assignment_id: assignment.id,
735+
reason: 'Sick',
736+
requested_due_date: Date.tomorrow.to_s,
737+
due_time: '10:00',
738+
user_id: other_user.id
739+
}
740+
}
741+
742+
expect(Request.last.user).to eq(user)
743+
expect(Request.last.user).not_to eq(other_user)
744+
end
745+
746+
it 'rejects an assignment that belongs to another course' do
747+
post :create, params: {
748+
course_id: course.id,
749+
request: {
750+
assignment_id: foreign_assignment.id,
751+
reason: 'Sick',
752+
requested_due_date: Date.tomorrow.to_s,
753+
due_time: '10:00'
754+
}
755+
}
756+
757+
expect(response).to redirect_to(course_requests_path(course))
758+
expect(flash[:alert]).to match(/Assignment not found for this course/)
759+
expect(Request.last).to be_nil
760+
end
761+
end
762+
763+
describe 'PATCH #update' do
764+
it 'rejects reassigning to an assignment from another course' do
765+
original_assignment_id = request.assignment_id
766+
767+
patch :update, params: {
768+
course_id: course.id,
769+
id: request.id,
770+
request: {
771+
assignment_id: foreign_assignment.id,
772+
reason: 'Updated reason',
773+
requested_due_date: Date.tomorrow.to_s,
774+
due_time: '12:00'
775+
}
776+
}
777+
778+
expect(response).to render_template(:edit)
779+
expect(flash[:alert]).to match(/problem updating the request/)
780+
expect(request.reload.assignment_id).to eq(original_assignment_id)
781+
end
782+
end
783+
784+
describe 'POST #create_for_student' do
785+
let(:enrolled_student) { User.create!(email: 'enrolled@example.com', canvas_uid: '901', name: 'Enrolled Student') }
786+
let(:unenrolled_student) { User.create!(email: 'unenrolled@example.com', canvas_uid: '902', name: 'Unenrolled Student') }
787+
788+
before do
789+
session[:user_id] = instructor.canvas_uid
790+
UserToCourse.create!(user: instructor, course: course, role: 'teacher')
791+
end
792+
793+
it 'rejects filing on behalf of a student who is not enrolled in the course' do
794+
post :create_for_student, params: {
795+
course_id: course.id,
796+
request: {
797+
user_id: unenrolled_student.id,
798+
assignment_id: assignment.id,
799+
reason: 'Sick',
800+
requested_due_date: Date.tomorrow.to_s,
801+
due_time: '10:00'
802+
}
803+
}
804+
805+
expect(response).to redirect_to(new_course_request_path(course))
806+
expect(flash[:alert]).to match(/not enrolled/)
807+
expect(Request.where(user: unenrolled_student)).to be_empty
808+
end
809+
810+
it 'creates a request for an enrolled student' do
811+
UserToCourse.create!(user: enrolled_student, course: course, role: 'student')
812+
813+
post :create_for_student, params: {
814+
course_id: course.id,
815+
request: {
816+
user_id: enrolled_student.id,
817+
assignment_id: assignment.id,
818+
reason: 'Sick',
819+
requested_due_date: Date.tomorrow.to_s,
820+
due_time: '10:00'
821+
}
822+
}
823+
824+
expect(Request.last.user).to eq(enrolled_student)
825+
end
826+
827+
it 'rejects an assignment that belongs to another course' do
828+
UserToCourse.create!(user: enrolled_student, course: course, role: 'student')
829+
830+
post :create_for_student, params: {
831+
course_id: course.id,
832+
request: {
833+
user_id: enrolled_student.id,
834+
assignment_id: foreign_assignment.id,
835+
reason: 'Sick',
836+
requested_due_date: Date.tomorrow.to_s,
837+
due_time: '10:00'
838+
}
839+
}
840+
841+
expect(response).to redirect_to(new_course_request_path(course))
842+
expect(flash[:alert]).to match(/Assignment not found for this course/)
843+
expect(Request.where(user: enrolled_student)).to be_empty
844+
end
845+
end
846+
end
723847
end

spec/factories/users.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
FactoryBot.define do
2020
factory :user do
2121
sequence(:email) { |n| "user#{n}@example.com" }
22-
sequence(:canvas_uid, &:to_s)
22+
# Offset so generated UIDs never collide with the low, hand-picked
23+
# canvas_uid literals (e.g. '123', '566') used directly in specs.
24+
sequence(:canvas_uid) { |n| (n + 100_000).to_s }
2325
sequence(:name) { |n| "User #{n}" }
2426

2527
factory :admin do

0 commit comments

Comments
 (0)