Skip to content

Fix IDOR vulnerability#1347

Draft
RichDom2185 wants to merge 4 commits intomasterfrom
fix-idor
Draft

Fix IDOR vulnerability#1347
RichDom2185 wants to merge 4 commits intomasterfrom
fix-idor

Conversation

@RichDom2185
Copy link
Copy Markdown
Member

@RichDom2185 RichDom2185 commented Mar 25, 2026

Adds checks to ensure enforce valid associations among accessed route parameter tuples.

Commits with ai scope are generated by AI; I will manually vet everything later before merging.

@RichDom2185 RichDom2185 self-assigned this Mar 25, 2026
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the application's security posture by addressing Insecure Direct Object Reference (IDOR) vulnerabilities. It introduces a new plug that centralizes resource access validation, ensuring that all operations on specific entities (like assessments, questions, submissions, and teams) are strictly confined to the user's current course context. This change moves resource validation from individual controller actions into a reusable and robust plug, making the application more secure and maintainable by preventing unauthorized access to data across different courses.

Highlights

  • New Security Plug Introduced: A new CadetWeb.Plug.EnsureResourceScope plug was added to enforce resource ownership validation across various controllers, preventing Insecure Direct Object Reference (IDOR) vulnerabilities.
  • Scoped Resource Retrieval Functions: New functions like get_course_reg_in_course, get_team_in_course, get_assessment_in_course, get_question_in_course, get_submission_in_course, and get_answer_in_course were implemented to ensure that resources are fetched only if they are associated with the current course.
  • Controller Refactoring for Security: Numerous controller actions were refactored to utilize the new EnsureResourceScope plug, replacing direct Repo.get calls with checks against resources assigned by the plug, thereby centralizing and strengthening access control.
  • Enhanced Data Validation in Assessments: Functions related to grading and regrading submissions and answers in Cadet.Assessments were updated to include course_id in their validation logic, further securing operations against cross-course data access.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@RichDom2185 RichDom2185 changed the title Fix IDOR Fix IDOR vulnerability Mar 25, 2026
@coveralls
Copy link
Copy Markdown

coveralls commented Mar 25, 2026

Coverage Status

coverage: 88.687% (-0.2%) from 88.926%
when pulling 0e30ba2 on fix-idor
into 0ad90ab on master.

@gemini-code-assist
Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@RichDom2185 RichDom2185 requested a review from Copilot March 25, 2026 10:23
@RichDom2185
Copy link
Copy Markdown
Member Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new EnsureResourceScope Plug to centralize resource validation and fetching within the correct course context, enhancing security and reducing boilerplate in controllers. This involved adding new get_..._in_course functions in various modules and updating numerous controllers to utilize this plug. Corresponding test cases were also updated to reflect the new error handling. The review suggests an improvement in the get_answer_in_course function to combine two database queries into a single, more efficient one, avoiding an extra database roundtrip.

Comment on lines +3123 to +3136
def get_answer_in_course(answer_id, course_id)
when is_ecto_id(answer_id) and is_ecto_id(course_id) do
answer_query =
Answer
|> where(id: ^answer_id)
|> join(:inner, [a], q in assoc(a, :question))
|> join(:inner, [_, q], ast in assoc(q, :assessment))
|> where([_, _, ast], ast.course_id == ^course_id)

case Repo.exists?(answer_query) do
true -> get_answer(answer_id)
false -> {:error, {:forbidden, "Forbidden"}}
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function currently performs two separate database queries: one with Repo.exists? to check for the answer's existence within the course, and then another one inside get_answer to fetch the answer. This is inefficient.

You can combine these into a single, more efficient query that both verifies the scope and fetches the answer. This avoids the extra database roundtrip.

Assuming get_answer preloads common associations, the combined query should also include them. I've included some common preloads based on other functions in this file, but you should verify if get_answer preloaded others.

  def get_answer_in_course(answer_id, course_id)
      when is_ecto_id(answer_id) and is_ecto_id(course_id) do
    answer_query =
      Answer
      |> where(id: ^answer_id)
      |> join(:inner, [a], q in assoc(a, :question))
      |> join(:inner, [_, q], ast in assoc(q, :assessment))
      |> where([_, _, ast], ast.course_id == ^course_id)
      |> preload([:question, :submission, :history, :grading, :comments])

    case Repo.one(answer_query) do
      nil -> {:error, {:forbidden, "Forbidden"}}
      answer -> {:ok, answer}
    end
  end

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses an IDOR class of vulnerabilities by enforcing that resource IDs in requests (assessment/question/submission/answer/team/course_reg) belong to the current course scope, and updates controllers/tests to reflect the new authorization outcomes.

Changes:

  • Introduces CadetWeb.Plug.EnsureResourceScope to validate scoped access for common resource IDs.
  • Adds course-scoped getters in Cadet.Assessments, Cadet.Accounts.Teams, and Cadet.Accounts.CourseRegistrations.
  • Updates multiple controllers to apply scoping plugs and adjusts tests/error expectations (notably shifting some cases to 403/400).

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
test/cadet_web/controllers/teams_controller_test.exs Adds a cross-course assessment access test expecting 403.
test/cadet_web/controllers/assessments_controller_test.exs Adjusts expected status codes and adds scoped-access test for assessment.
test/cadet_web/controllers/answer_controller_test.exs Updates setup to reuse course from conn scope and adjusts expected error statuses.
test/cadet_web/controllers/ai_code_analysis_controller_test.exs Updates invalid-answer-id behavior expectation (but currently contains a failing assertion).
test/cadet_web/admin_controllers/admin_user_controller_test.exs Updates expected responses to 403 for cross-course / missing scoped course_reg cases.
test/cadet_web/admin_controllers/admin_teams_controller_test.exs Updates fixtures to ensure team belongs to course; adjusts invalid-id expectations.
test/cadet_web/admin_controllers/admin_grading_controller_test.exs Updates expected status codes for scoped access behavior.
lib/cadet_web/plug/ensure_resource_scope.ex New plug to enforce course scoping for resource IDs.
lib/cadet_web/controllers/team_controller.ex Applies scoping to assessment ID for team index.
lib/cadet_web/controllers/generate_ai_comments.ex Applies scoping to answer access for AI comment generation/finalization.
lib/cadet_web/controllers/assessments_controller.ex Applies scoping to assessment access for several actions and uses scoped ID for contest endpoints.
lib/cadet_web/controllers/answer_controller.ex Applies scoping to question access for submit/last-modified endpoints.
lib/cadet_web/admin_controllers/admin_user_controller.ex Applies course_reg scoping for admin actions on course registrations.
lib/cadet_web/admin_controllers/admin_teams_controller.ex Applies team scoping for update/delete and refactors usage to rely on scoped team.
lib/cadet_web/admin_controllers/admin_grading_controller.ex Applies scoping plugs for submission/question/assessment IDs for grading actions.
lib/cadet_web/admin_controllers/admin_goals_controller.ex Applies course_reg scoping for goals-with-progress/progress updates.
lib/cadet_web/admin_controllers/admin_assessments_controller.ex Applies scoping for course_reg and assessment IDs across admin assessment endpoints.
lib/cadet/assessments/assessments.ex Adds course-scoped retrieval helpers and scopes certain grading/regrade operations by course.
lib/cadet/accounts/teams.ex Adds get_team_in_course/2 for scoped team retrieval.
lib/cadet/accounts/course_registrations.ex Adds get_course_reg_in_course/2 for scoped course registration retrieval.
Comments suppressed due to low confidence (1)

lib/cadet_web/admin_controllers/admin_teams_controller.ex:62

  • EnsureResourceScope here only scopes the teamid, but assessmentId and student_ids in the update payload are not validated to belong to the current course. As written, a staff user in course A could update a team in course A to reference an assessment from course B and/or attach course_reg IDs from other courses. Add scoping/validation for the target assessment and for each student/course_reg ID (either via additional EnsureResourceScope plugs or checks inside Teams.update_team/3) and reject cross-course IDs with 403.
  def update(conn, %{"assessmentId" => assessmentId, "student_ids" => student_ids}) do
    team =
      conn.assigns.scoped_team
      |> Repo.preload(assessment: [:config], team_members: [student: [:user]])

    case Teams.update_team(team, assessmentId, student_ids) do
      {:ok, _updated_team} ->

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 100 to 106
response =
conn
|> sign_in(admin_user.user)
|> post(build_url_generate_ai_comments(course_with_llm.id, random_answer_id))
|> text_response(400)

assert response(response, 403) == "Forbidden"
end
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, response is a %Plug.Conn{} variable, but the assertion calls response(response, 403), which will fail (and also shadows the function name). Use the conn returned by post/3 and assert with response(conn, 403) (or text_response/2) instead.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +12
plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :question, param: "questionid", assign: :scoped_question]
when action in [:submit, :check_last_modified]
)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With EnsureResourceScope now handling missing/out-of-scope questions before the action runs, this controller no longer returns the documented 404 "Question not found" response from submit/check_last_modified. The Swagger spec below still advertises a 404 for this endpoint; update the Swagger responses to reflect the new 400/403 behavior (or reintroduce a 404 if that's still required).

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +25
plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :submission, param: "submissionid", assign: :scoped_submission]
when action in [
:show,
:update,
:unsubmit,
:unpublish_grades,
:publish_grades,
:autograde_submission,
:autograde_answer
]
)

plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :question, param: "questionid", assign: :scoped_question]
when action in [:update, :autograde_answer]
)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new scope plugs (and function heads) expect path params named submissionid/questionid/assessmentid, matching the router (/grading/:submissionid/...). However, the Swagger paths/params in this controller still use {submissionId} / {questionId} etc. This will produce confusing API docs; align the Swagger path placeholders and parameter names with the actual route param keys.

Copilot uses AI. Check for mistakes.
Comment on lines +3131 to +3134

case Repo.exists?(answer_query) do
true -> get_answer(answer_id)
false -> {:error, {:forbidden, "Forbidden"}}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_answer_in_course/2 currently issues two DB queries for the success case (Repo.exists? and then get_answer/1). Since this is used in a request plug, it adds avoidable overhead on every call. Consider fetching the scoped answer directly in a single query (e.g., join + preload and Repo.one) and returning nil/{:error, :forbidden} when not found, instead of exists? + re-fetch.

Suggested change
case Repo.exists?(answer_query) do
true -> get_answer(answer_id)
false -> {:error, {:forbidden, "Forbidden"}}
|> preload([a, q, _ast], question: q)
case Repo.one(answer_query) do
nil ->
{:error, {:forbidden, "Forbidden"}}
answer ->
# Mirror the voting-question sanitization in get_answer/1
answer =
if answer.question.type == :voting do
empty_contest_entries =
Map.put(answer.question.question, :contest_entries, [])
empty_popular_leaderboard =
Map.put(empty_contest_entries, :popular_leaderboard, [])
empty_contest_leaderboard =
Map.put(empty_popular_leaderboard, :contest_leaderboard, [])
question = Map.put(answer.question, :question, empty_contest_leaderboard)
Map.put(answer, :question, question)
else
answer
end
{:ok, answer}

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +31
plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :submission, param: "submissionid", assign: :scoped_submission]
when action in [
:show,
:update,
:unsubmit,
:unpublish_grades,
:publish_grades,
:autograde_submission,
:autograde_answer
]
)

plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :question, param: "questionid", assign: :scoped_question]
when action in [:update, :autograde_answer]
)

plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :assessment, param: "assessmentid", assign: :scoped_assessment]
when action in [:publish_all_grades, :unpublish_all_grades]
)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These EnsureResourceScope plugs load and assign %Submission{} / %Question{} / %Assessment{} records, but the actions in this controller still use the raw IDs and re-query (e.g., get_answers_in_submission/1, update_grading_info/3, etc.). This makes each request do extra DB work just for scoping. Either use the assigned scoped records in the actions (where possible) or add an option to the plug to validate scope without fetching the full record.

Copilot uses AI. Check for mistakes.
Comment on lines 498 to 503
@tag authenticate: :staff
test "missing parameter", %{conn: conn} do
course_id = conn.assigns.course_id
conn = post(conn, build_url(course_id, 1, 3), %{})
assert response(conn, 400) =~ "Missing parameter"
assert response(conn, 403) == "Forbidden"
end
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test no longer exercises the controller's "missing parameter" branch. With the new EnsureResourceScope plugs, hardcoded IDs (1, 3) will be rejected as out-of-scope/not found before the action runs, so the 403 is coming from the plug rather than from the missing-parameter validation. Seed a valid submission/question in the current course and omit the required body field so the action returns the intended 400 "Missing parameter".

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +12
plug(
CadetWeb.Plug.EnsureResourceScope,
[resource: :team, param: "teamid", assign: :scoped_team]
when action in [:update, :delete]
)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller now relies on the path param key teamid (router uses /teams/:teamid and this plug reads "teamid"), but the Swagger paths/parameters in this file still use {teamId} / teamId. This mismatch will generate misleading API docs; update the swagger path placeholders/parameter names to match the actual route param key.

Copilot uses AI. Check for mistakes.
Comment on lines +74 to 78
def delete(conn, %{"teamid" => _team_id}) do
team = conn.assigns.scoped_team

if team do
case Teams.delete_team(team) do
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because EnsureResourceScope runs for :delete and returns 403 when the team is not found/in another course, conn.assigns.scoped_team will never be nil here. The if team do ... else 404 "Team not found!" branch is now effectively dead code and the documented/expected behavior is 403 instead of 404; consider removing the nil-check branch (or adjusting the plug behavior if 404 is still desired).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants