Skip to content
Snippets Groups Projects
Unverified Commit 252f94bd authored by Giovanni Cimolin da Silva's avatar Giovanni Cimolin da Silva Committed by GitHub
Browse files

Merge pull request from GHSA-7j9p-67mm-5g87


* fix: Tool can only push grade to value in config

Before this commit, LTI tools were able to push grades to any block
simply by modifying or creating a new line item with a `resource_link_id` containing a valid block.

This commit closes that loophole and resolves
security advisory GHSA-7j9p-67mm-5g87.

* chore: create release version

Co-authored-by: default avatarZach Hancock <zhancock@edx.org>
parent 8da48aa3
No related branches found
No related tags found
No related merge requests found
...@@ -16,6 +16,10 @@ Please See the [releases tab](https://github.com/openedx/xblock-lti-consumer/rel ...@@ -16,6 +16,10 @@ Please See the [releases tab](https://github.com/openedx/xblock-lti-consumer/rel
Unreleased Unreleased
~~~~~~~~~~ ~~~~~~~~~~
7.2.2 - 2023-01-12
------------------
* Fixes LTI 1.3 grade injection vulnerability that allowed LTI integrations to modify scores for any block.
7.2.1 - 2023-01-10 7.2.1 - 2023-01-10
------------------ ------------------
* Adds support for LTI_BASE and LTI_API_BASE Django settings to allow URL configuration independent of LMS settings. * Adds support for LTI_BASE and LTI_API_BASE Django settings to allow URL configuration independent of LMS settings.
......
...@@ -4,4 +4,4 @@ Runtime will load the XBlock class from here. ...@@ -4,4 +4,4 @@ Runtime will load the XBlock class from here.
from .apps import LTIConsumerApp from .apps import LTIConsumerApp
from .lti_xblock import LtiConsumerXBlock from .lti_xblock import LtiConsumerXBlock
__version__ = '7.2.1' __version__ = '7.2.2'
...@@ -22,16 +22,34 @@ def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disabl ...@@ -22,16 +22,34 @@ def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disabl
in the LMS. Trying to trigger this signal from Studio (from the Django-admin interface, for example) in the LMS. Trying to trigger this signal from Studio (from the Django-admin interface, for example)
throw an exception. throw an exception.
""" """
line_item = instance.line_item
lti_config = line_item.lti_configuration
# Only save score if the `line_item.resource_link_id` is the same as
# `lti_configuration.location` to prevent LTI tools to alter grades they don't
# have permissions to.
# TODO: This security mechanism will need to be reworked once we enable LTI 1.3
# reusability to allow one configuration to save scores on multiple placements,
# but still locking down access to the items that are using the LTI configurtion.
if line_item.resource_link_id != lti_config.location:
log.warning(
"LTI tool tried publishing score %r to block %s (outside allowed scope of: %s).",
instance,
line_item.resource_link_id,
lti_config.location,
)
return
# Before starting to publish grades to the LMS, check that: # Before starting to publish grades to the LMS, check that:
# 1. The grade being submitted in the final one - `FullyGraded` # 1. The grade being submitted in the final one - `FullyGraded`
# 2. This LineItem is linked to a LMS grade - the `LtiResouceLinkId` field is set # 2. This LineItem is linked to a LMS grade - the `LtiResouceLinkId` field is set
# 3. There's a valid grade in this score - `scoreGiven` is set # 3. There's a valid grade in this score - `scoreGiven` is set
if instance.grading_progress == LtiAgsScore.FULLY_GRADED \ if instance.grading_progress == LtiAgsScore.FULLY_GRADED \
and instance.line_item.resource_link_id \ and line_item.resource_link_id \
and instance.score_given: and instance.score_given:
try: try:
# Load block using LMS APIs and check if the block is graded and still accept grades. # Load block using LMS APIs and check if the block is graded and still accept grades.
block = compat.load_block_as_user(instance.line_item.resource_link_id) block = compat.load_block_as_user(line_item.resource_link_id)
if block.has_score and (not block.is_past_due() or block.accept_grades_past_due): if block.has_score and (not block.is_past_due() or block.accept_grades_past_due):
# Map external ID to platform user # Map external ID to platform user
user = compat.get_user_from_external_user_id(instance.user_id) user = compat.get_user_from_external_user_id(instance.user_id)
...@@ -58,7 +76,7 @@ def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disabl ...@@ -58,7 +76,7 @@ def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disabl
log.exception( log.exception(
"Error while publishing score %r to block %s to LMS: %s", "Error while publishing score %r to block %s to LMS: %s",
instance, instance,
instance.line_item.resource_link_id, line_item.resource_link_id,
exc, exc,
) )
raise exc raise exc
......
"""
Tests for LTI Advantage Assignments and Grades Service views.
"""
from datetime import datetime
from unittest.mock import patch, Mock
from django.test import TestCase
from opaque_keys.edx.keys import UsageKey
from lti_consumer.models import LtiConfiguration, LtiAgsLineItem, LtiAgsScore
class PublishGradeOnScoreUpdateTest(TestCase):
"""
Test the `publish_grade_on_score_update` signal.
"""
def setUp(self):
"""
Set up resources for signal testing.
"""
self.location = UsageKey.from_string(
"block-v1:course+test+2020+type@problem+block@test"
)
# Create configuration
self.lti_config = LtiConfiguration.objects.create(
location=self.location,
version=LtiConfiguration.LTI_1P3,
)
# Patch internal method to avoid calls to modulestore
self._block_mock = Mock()
compat_mock = patch("lti_consumer.signals.signals.compat")
self.addCleanup(compat_mock.stop)
self._compat_mock = compat_mock.start()
self._compat_mock.get_user_from_external_user_id.return_value = Mock()
self._compat_mock.load_block_as_user.return_value = self._block_mock
def test_grade_publish_not_done_when_wrong_line_item(self):
"""
Test grade publish after for a different UsageKey than set on
`lti_config.location`.
"""
# Create LineItem with `resource_link_id` != `lti_config.id`
line_item = LtiAgsLineItem.objects.create(
lti_configuration=self.lti_config,
resource_id="test",
resource_link_id=UsageKey.from_string(
"block-v1:course+test+2020+type@problem+block@different"
),
label="test label",
score_maximum=100
)
# Save score and check that LMS method wasn't called.
LtiAgsScore.objects.create(
line_item=line_item,
score_given=1,
score_maximum=1,
activity_progress=LtiAgsScore.COMPLETED,
grading_progress=LtiAgsScore.FULLY_GRADED,
user_id="test",
timestamp=datetime.now(),
)
# Check that methods to save grades are not called
self._block_mock.set_user_module_score.assert_not_called()
self._compat_mock.get_user_from_external_user_id.assert_not_called()
self._compat_mock.load_block_as_user.assert_not_called()
def test_grade_publish(self):
"""
Test grade publish after if the UsageKey is equal to
the one on `lti_config.location`.
"""
# Create LineItem with `resource_link_id` != `lti_config.id`
line_item = LtiAgsLineItem.objects.create(
lti_configuration=self.lti_config,
resource_id="test",
resource_link_id=self.location,
label="test label",
score_maximum=100
)
# Save score and check that LMS method wasn't called.
LtiAgsScore.objects.create(
line_item=line_item,
score_given=1,
score_maximum=1,
activity_progress=LtiAgsScore.COMPLETED,
grading_progress=LtiAgsScore.FULLY_GRADED,
user_id="test",
timestamp=datetime.now(),
)
# Check that methods to save grades are called
self._block_mock.set_user_module_score.assert_called_once()
self._compat_mock.get_user_from_external_user_id.assert_called_once()
self._compat_mock.load_block_as_user.assert_called_once()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment