Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/20260504184023_djoseph_assets_proxy_paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Bugfix] Serve SCORM assets at the exact requested path, fixing blank-page rendering for packages containing duplicate basenames such as Articulate Rise packages with multiple `scormdriver.js` files. Also reject path traversal and absolute paths in asset URLs. (by @djoseph)
35 changes: 33 additions & 2 deletions openedxscorm/scormxblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,20 @@ def assets_proxy(self, request, suffix):
-------
Response object containing the content of the requested file with the appropriate content type.
"""
file_name = os.path.basename(suffix)
file_path = self.find_file_path(file_name)
try:
clean_suffix = self.clean_asset_path(suffix)
except ScormError:
logger.warning("Invalid asset path: %r", suffix)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
logger.warning("Invalid asset path: %r", suffix)
logger.error("Invalid asset path: %r", suffix)

return Response("Invalid asset path", status=400, content_type="text/plain")
file_name = os.path.basename(clean_suffix)
requested_path = os.path.join(self.extract_folder_path, clean_suffix)
if self.storage.exists(requested_path):
file_path = requested_path
else:
# Preserve legacy basename lookup for packages or callers that do
# not request the extracted relative path. Exact-path lookup above
# avoids this fallback for normal requests with duplicate basenames.
file_path = self.find_file_path(file_name)
file_type, _ = mimetypes.guess_type(file_name)
with self.storage.open(file_path) as response:
file_content = response.read()
Expand Down Expand Up @@ -419,6 +431,25 @@ def clean_path(self, path):
Removes query string from a path
"""
return path.split('?')[0] if path else path

def clean_asset_path(self, path):
"""
Clean and validate an asset path requested through the proxy.
"""
cleaned = urllib.parse.unquote(self.clean_path(path))
if not cleaned or "\x00" in cleaned or cleaned.endswith(("/", OS_PATH_ALT_SEP)):
raise ScormError("Invalid asset path")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think with the error, we should log what kind of path scorm is expecting. Similarly below.

normalized_separators_path = cleaned.replace(OS_PATH_ALT_SEP, os.path.sep)
path_parts = normalized_separators_path.split(os.path.sep)
cleaned = os.path.normpath(normalized_separators_path)
if (
os.path.isabs(cleaned)
or cleaned == os.curdir
or re.match(r"^[A-Za-z]:", cleaned)
or os.pardir in path_parts
):
raise ScormError("Invalid asset path")
return cleaned

def path_exists(self, path):
"""
Expand Down
98 changes: 97 additions & 1 deletion openedxscorm/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import mock
from xblock.field_data import DictFieldData

from .scormxblock import ScormXBlock
from .scormxblock import ScormError, ScormXBlock


@ddt
Expand All @@ -25,6 +25,14 @@ def make_one(**kw):
)
return block

@staticmethod
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I love the fact that you added tests. Thank you so much for this!

def make_storage_with_file(content=b"content"):
storage = mock.Mock()
opened_file = mock.MagicMock()
opened_file.__enter__.return_value.read.return_value = content
storage.open.return_value = opened_file
return storage

def test_fields_xblock(self):
block = self.make_one()
self.assertEqual(block.display_name, "Scorm")
Expand Down Expand Up @@ -124,6 +132,94 @@ def test_build_file_storage_path(self):

self.assertEqual(file_storage_path, "org/course/block_type/block_id/sha1.html")

@mock.patch.object(
ScormXBlock, "extract_folder_path", new_callable=mock.PropertyMock
)
def test_assets_proxy_serves_exact_requested_path(self, extract_folder_path):
block = self.make_one()
storage = self.make_storage_with_file(b"exact asset")
storage.exists.return_value = True
block._storage = storage
block.find_file_path = mock.Mock(return_value="scorm/block/sha1/other/app.js")
extract_folder_path.return_value = "scorm/block/sha1"

response = block.assets_proxy(mock.Mock(), "assets/app.js")

storage.exists.assert_called_once_with("scorm/block/sha1/assets/app.js")
block.find_file_path.assert_not_called()
storage.open.assert_called_once_with("scorm/block/sha1/assets/app.js")
self.assertEqual(response.body, b"exact asset")

@mock.patch.object(
ScormXBlock, "extract_folder_path", new_callable=mock.PropertyMock
)
def test_assets_proxy_fallback_uses_cleaned_basename(self, extract_folder_path):
block = self.make_one()
storage = self.make_storage_with_file()
storage.exists.return_value = False
block._storage = storage
block.find_file_path = mock.Mock(return_value="scorm/block/sha1/fallback/app.js")
extract_folder_path.return_value = "scorm/block/sha1"

block.assets_proxy(mock.Mock(), "assets/app.js?v=1")

storage.exists.assert_called_once_with("scorm/block/sha1/assets/app.js")
block.find_file_path.assert_called_once_with("app.js")
storage.open.assert_called_once_with("scorm/block/sha1/fallback/app.js")

@data(
"",
"?v=1",
".",
"/app.js",
"%2Fapp.js",
"%5Capp.js",
r"C:\app.js",
"C%3A/app.js",
"../app.js",
"%2E%2E/app.js",
"assets/",
"assets/..",
"assets/%2E%2E/app.js",
"assets/../../app.js",
"assets/%2E%2E/%2E%2E/app.js",
r"assets\..\app.js",
"assets/%00/app.js",
)
def test_assets_proxy_rejects_invalid_requested_paths(self, suffix):
block = self.make_one()

response = block.assets_proxy(mock.Mock(), suffix)

self.assertEqual(response.status_code, 400)
self.assertEqual(response.content_type, "text/plain")
self.assertEqual(response.body, b"Invalid asset path")

@data(
"",
"?v=1",
".",
"/app.js",
"%2Fapp.js",
"%5Capp.js",
r"C:\app.js",
"C%3A/app.js",
"../app.js",
"%2E%2E/app.js",
"assets/",
"assets/..",
"assets/%2E%2E/app.js",
"assets/../../app.js",
"assets/%2E%2E/%2E%2E/app.js",
r"assets\..\app.js",
"assets/%00/app.js",
)
def test_clean_asset_path_rejects_invalid_requested_paths(self, suffix):
block = self.make_one()

with self.assertRaises(ScormError):
block.clean_asset_path(suffix)

@mock.patch(
"openedxscorm.ScormXBlock._file_storage_path", return_value="file_storage_path"
)
Expand Down
Loading