Skip to content

Commit 16d01a0

Browse files
authored
Merge pull request #331 from open-craft/v4.5.0-security-update
Merge pull request from GHSA-7j9p-67mm-5g87
2 parents 682c90e + a13b35b commit 16d01a0

4 files changed

Lines changed: 133 additions & 6 deletions

File tree

lti_consumer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
from .apps import LTIConsumerApp
55
from .lti_xblock import LtiConsumerXBlock
66

7-
__version__ = '4.5.0'
7+
__version__ = '4.5.1'

lti_consumer/signals.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,34 @@ def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disabl
2222
in the LMS. Trying to trigger this signal from Studio (from the Django-admin interface, for example)
2323
throw an exception.
2424
"""
25+
line_item = instance.line_item
26+
lti_config = line_item.lti_configuration
27+
28+
# Only save score if the `line_item.resource_link_id` is the same as
29+
# `lti_configuration.location` to prevent LTI tools to alter grades they don't
30+
# have permissions to.
31+
# TODO: This security mechanism will need to be reworked once we enable LTI 1.3
32+
# reusability to allow one configuration to save scores on multiple placements,
33+
# but still locking down access to the items that are using the LTI configurtion.
34+
if line_item.resource_link_id != lti_config.location:
35+
log.warning(
36+
"LTI tool tried publishing score %r to block %s (outside allowed scope of: %s).",
37+
instance,
38+
line_item.resource_link_id,
39+
lti_config.location,
40+
)
41+
return
42+
2543
# Before starting to publish grades to the LMS, check that:
2644
# 1. The grade being submitted in the final one - `FullyGraded`
2745
# 2. This LineItem is linked to a LMS grade - the `LtiResouceLinkId` field is set
2846
# 3. There's a valid grade in this score - `scoreGiven` is set
2947
if instance.grading_progress == LtiAgsScore.FULLY_GRADED \
30-
and instance.line_item.resource_link_id \
48+
and line_item.resource_link_id \
3149
and instance.score_given:
3250
try:
3351
# Load block using LMS APIs and check if the block is graded and still accept grades.
34-
block = compat.load_block_as_anonymous_user(instance.line_item.resource_link_id)
52+
block = compat.load_block_as_anonymous_user(line_item.resource_link_id)
3553
if block.has_score and (not block.is_past_due() or block.accept_grades_past_due):
3654
# Map external ID to platform user
3755
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
5876
log.exception(
5977
"Error while publishing score %r to block %s to LMS: %s",
6078
instance,
61-
instance.line_item.resource_link_id,
79+
line_item.resource_link_id,
6280
exc,
6381
)
6482
raise exc

lti_consumer/tests/unit/test_models.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,6 @@ def setUp(self):
412412

413413
self.dummy_location = 'block-v1:course+test+2020+type@problem+block@test'
414414
self.lti_ags_model = LtiAgsLineItem.objects.create(
415-
lti_configuration=None,
416415
resource_id="test-id",
417416
label="this-is-a-test",
418417
resource_link_id=self.dummy_location,
@@ -449,8 +448,16 @@ def setUp(self):
449448
)
450449

451450
self.dummy_location = 'block-v1:course+test+2020+type@problem+block@test'
451+
452+
self.lti_config = LtiConfiguration.objects.create(
453+
config_id='6c440bf4-face-beef-face-e8bcfb1e53bd',
454+
location=self.dummy_location,
455+
version=LtiConfiguration.LTI_1P3,
456+
config_store=LtiConfiguration.CONFIG_ON_XBLOCK,
457+
)
458+
452459
self.line_item = LtiAgsLineItem.objects.create(
453-
lti_configuration=None,
460+
lti_configuration=self.lti_config,
454461
resource_id="test-id",
455462
label="this-is-a-test",
456463
resource_link_id=self.dummy_location,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Tests for LTI Advantage Assignments and Grades Service views.
3+
"""
4+
from datetime import datetime
5+
from unittest.mock import patch, Mock
6+
7+
from django.test import TestCase
8+
from opaque_keys.edx.keys import UsageKey
9+
10+
from lti_consumer.models import LtiConfiguration, LtiAgsLineItem, LtiAgsScore
11+
12+
13+
class PublishGradeOnScoreUpdateTest(TestCase):
14+
"""
15+
Test the `publish_grade_on_score_update` signal.
16+
"""
17+
18+
def setUp(self):
19+
"""
20+
Set up resources for signal testing.
21+
"""
22+
super().setUp()
23+
24+
self.location = UsageKey.from_string(
25+
"block-v1:course+test+2020+type@problem+block@test"
26+
)
27+
28+
# Create configuration
29+
self.lti_config = LtiConfiguration.objects.create(
30+
location=self.location,
31+
version=LtiConfiguration.LTI_1P3,
32+
)
33+
34+
# Patch internal method to avoid calls to modulestore
35+
self._block_mock = Mock()
36+
compat_mock = patch("lti_consumer.signals.compat")
37+
self.addCleanup(compat_mock.stop)
38+
self._compat_mock = compat_mock.start()
39+
self._compat_mock.get_user_from_external_user_id.return_value = Mock()
40+
self._compat_mock.load_block_as_anonymous_user.return_value = self._block_mock
41+
42+
def test_grade_publish_not_done_when_wrong_line_item(self):
43+
"""
44+
Test grade publish after for a different UsageKey than set on
45+
`lti_config.location`.
46+
"""
47+
# Create LineItem with `resource_link_id` != `lti_config.id`
48+
line_item = LtiAgsLineItem.objects.create(
49+
lti_configuration=self.lti_config,
50+
resource_id="test",
51+
resource_link_id=UsageKey.from_string(
52+
"block-v1:course+test+2020+type@problem+block@different"
53+
),
54+
label="test label",
55+
score_maximum=100
56+
)
57+
58+
# Save score and check that LMS method wasn't called.
59+
LtiAgsScore.objects.create(
60+
line_item=line_item,
61+
score_given=1,
62+
score_maximum=1,
63+
activity_progress=LtiAgsScore.COMPLETED,
64+
grading_progress=LtiAgsScore.FULLY_GRADED,
65+
user_id="test",
66+
timestamp=datetime.now(),
67+
)
68+
69+
# Check that methods to save grades are not called
70+
self._block_mock.set_user_module_score.assert_not_called()
71+
self._compat_mock.get_user_from_external_user_id.assert_not_called()
72+
self._compat_mock.load_block_as_anonymous_user.assert_not_called()
73+
74+
def test_grade_publish(self):
75+
"""
76+
Test grade publish after if the UsageKey is equal to
77+
the one on `lti_config.location`.
78+
"""
79+
# Create LineItem with `resource_link_id` != `lti_config.id`
80+
line_item = LtiAgsLineItem.objects.create(
81+
lti_configuration=self.lti_config,
82+
resource_id="test",
83+
resource_link_id=self.location,
84+
label="test label",
85+
score_maximum=100
86+
)
87+
88+
# Save score and check that LMS method wasn't called.
89+
LtiAgsScore.objects.create(
90+
line_item=line_item,
91+
score_given=1,
92+
score_maximum=1,
93+
activity_progress=LtiAgsScore.COMPLETED,
94+
grading_progress=LtiAgsScore.FULLY_GRADED,
95+
user_id="test",
96+
timestamp=datetime.now(),
97+
)
98+
99+
# Check that methods to save grades are called
100+
self._block_mock.set_user_module_score.assert_called_once()
101+
self._compat_mock.get_user_from_external_user_id.assert_called_once()
102+
self._compat_mock.load_block_as_anonymous_user.assert_called_once()

0 commit comments

Comments
 (0)