Skip to content

Commit 00e95fd

Browse files
authored
fix: validate module, class, and filename during MediaUpload deserialization (#2796)
* fix: validate module, class, and filename during MediaUpload deserialization * address feedback * address feedback * fix build * address feedback * address feedback * add comment
1 parent bd65277 commit 00e95fd

2 files changed

Lines changed: 256 additions & 16 deletions

File tree

‎googleapiclient/http.py‎

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -410,19 +410,36 @@ def new_from_json(cls, s):
410410
representation produced by to_json().
411411
412412
Args:
413-
s: string, JSON from to_json().
413+
s: string, JSON string to parse.
414414
415415
Returns:
416-
An instance of the subclass of MediaUpload that was serialized with
417-
to_json().
416+
An instance of the MediaUpload subclass specified in the JSON.
417+
418+
Raises:
419+
ValueError: If the serialized data is not a dictionary, or specifies an
420+
untrusted module or unsupported class name.
421+
TypeError: If `s` is not a string.
422+
OSError: If an underlying file cannot be opened (for file-based uploads).
418423
"""
419424
data = json.loads(s)
420-
# Find and call the right classmethod from_json() to restore the object.
421-
module = data["_module"]
422-
m = __import__(module, fromlist=module.split(".")[:-1])
423-
kls = getattr(m, data["_class"])
424-
from_json = getattr(kls, "from_json")
425-
return from_json(s)
425+
if not isinstance(data, dict):
426+
raise ValueError("Serialized MediaUpload data must be a JSON object.")
427+
428+
module = data.get("_module")
429+
class_name = data.get("_class")
430+
431+
# Security check (CWE-502): Reject any module outside of googleapiclient.http
432+
# and any class not explicitly allowlisted in _ALLOWED_MEDIA_UPLOAD_CLASSES.
433+
if (
434+
module != "googleapiclient.http"
435+
or class_name not in _ALLOWED_MEDIA_UPLOAD_CLASSES
436+
):
437+
raise ValueError(
438+
f"Refusing to deserialize untrusted class: {module}.{class_name}"
439+
)
440+
441+
kls = _ALLOWED_MEDIA_UPLOAD_CLASSES[class_name]
442+
return kls.from_json(s)
426443

427444

428445
class MediaIoBaseUpload(MediaUpload):
@@ -615,17 +632,80 @@ def to_json(self):
615632
"""
616633
return self._to_json(strip=["_fd"])
617634

618-
@staticmethod
619-
def from_json(s):
635+
@classmethod
636+
def from_json(cls, s):
637+
"""Reconstructs a MediaFileUpload instance from a JSON string.
638+
639+
Args:
640+
s: str, JSON-encoded string produced by MediaFileUpload.to_json().
641+
642+
Returns:
643+
A MediaFileUpload instance (or instance of a subclass).
644+
645+
Raises:
646+
ValueError: If the JSON payload is invalid, is not a dictionary, or
647+
contains missing, malformed, or invalid fields.
648+
TypeError: If `s` is not a string, or if parameters passed to the
649+
constructor have invalid types.
650+
OSError: If the file specified by `_filename` cannot be opened or read
651+
(e.g., FileNotFoundError, PermissionError).
652+
"""
620653
d = json.loads(s)
621-
return MediaFileUpload(
622-
d["_filename"],
623-
mimetype=d["_mimetype"],
624-
chunksize=d["_chunksize"],
625-
resumable=d["_resumable"],
654+
if not isinstance(d, dict):
655+
raise ValueError("Serialized MediaFileUpload data must be a JSON object.")
656+
657+
filename = d.get("_filename")
658+
# Check for null bytes to prevent null-byte injection / path truncation attacks
659+
# when opening files on the local filesystem.
660+
if not isinstance(filename, str) or not filename or "\x00" in filename:
661+
raise ValueError(
662+
"Invalid or missing '_filename' in serialized MediaFileUpload."
663+
)
664+
665+
chunksize = d.get("_chunksize")
666+
if chunksize is None:
667+
chunksize = DEFAULT_CHUNK_SIZE
668+
elif not isinstance(chunksize, int) or isinstance(chunksize, bool):
669+
raise ValueError("'_chunksize' must be an integer.")
670+
671+
resumable = d.get("_resumable")
672+
if resumable is None:
673+
resumable = False
674+
elif not isinstance(resumable, bool):
675+
raise ValueError("'_resumable' must be a boolean.")
676+
677+
mimetype = d.get("_mimetype")
678+
if mimetype is not None and not isinstance(mimetype, str):
679+
raise ValueError("'_mimetype' must be a string.")
680+
681+
return cls(
682+
filename,
683+
mimetype=mimetype,
684+
chunksize=chunksize,
685+
resumable=resumable,
626686
)
627687

628688

689+
# Safe Deserialization Class Map (CWE-502 Mitigation)
690+
# Unsafe dynamic deserialization vulnerability:
691+
# In previous versions, MediaUpload.new_from_json() dynamically executed:
692+
# m = __import__(module, fromlist=module.split(".")[:-1])
693+
# kls = getattr(m, data["_class"])
694+
# from_json = getattr(kls, "from_json")
695+
# return from_json(s)
696+
#
697+
# Passing untrusted JSON to __import__() and getattr() allowed attackers who could
698+
# tamper with serialized state (e.g., in databases, task queues, or caches) to:
699+
# 1. Force arbitrary module loading from sys.path (leading to Remote Code Execution).
700+
# 2. Instantiate arbitrary classes within googleapiclient.http or other reachable modules.
701+
#
702+
# To eliminate reflection and prevent CWE-502, we strictly map allowed class names
703+
# directly to their factory/class references.
704+
_ALLOWED_MEDIA_UPLOAD_CLASSES = {
705+
"MediaFileUpload": MediaFileUpload,
706+
}
707+
708+
629709
class MediaInMemoryUpload(MediaIoBaseUpload):
630710
"""MediaUpload for a chunk of bytes.
631711

‎tests/test_http.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import random
3333
import socket
3434
import ssl
35+
import tempfile
3536
import time
3637
import unittest
3738
from unittest import mock
@@ -42,6 +43,7 @@
4243
from googleapiclient.discovery import build
4344
from googleapiclient.errors import BatchError, HttpError, InvalidChunkSizeError
4445
from googleapiclient.http import (
46+
DEFAULT_CHUNK_SIZE,
4547
MAX_URI_LENGTH,
4648
BatchHttpRequest,
4749
HttpMock,
@@ -1729,6 +1731,164 @@ def test_build_http_default_308_is_excluded_as_redirect(self):
17291731
self.assertTrue(308 not in http.redirect_codes)
17301732

17311733

1734+
class TestMediaUploadSerialization(unittest.TestCase):
1735+
"""Tests input validation and safe reconstruction behavior for MediaUpload.
1736+
1737+
Covers mitigations for CWE-502 (Deserialization of Untrusted Data) and validates
1738+
that arbitrary reflection and file manipulation vectors are strictly blocked.
1739+
"""
1740+
1741+
def test_deserialize_untrusted_class_raises_value_error(self):
1742+
"""Verify that MediaUpload.new_from_json strictly rejects untrusted classes."""
1743+
cases = [
1744+
# Security test: Reject arbitrary standard library / built-in modules.
1745+
# Prevents untrusted JSON from importing modules like 'os' or looking up
1746+
# dangerous callables (e.g. 'os.system').
1747+
("os", "system"),
1748+
# Security test: Reject non-upload classes in googleapiclient.http.
1749+
# Even if the module name is valid, non-MediaUpload classes like
1750+
# HttpRequest must be rejected to prevent unexpected dispatch.
1751+
("googleapiclient.http", "HttpRequest"),
1752+
# Security test: Reject arbitrary external modules from sys.path.
1753+
# Prevents untrusted JSON from triggering dynamic __import__() on modules
1754+
# that might exist in /tmp, shared volumes, or writable site-packages (RCE).
1755+
("nonexistent_module", "CustomClass"),
1756+
]
1757+
for module, class_name in cases:
1758+
with self.subTest(module=module, class_name=class_name):
1759+
payload = json.dumps({"_module": module, "_class": class_name})
1760+
with self.assertRaisesRegex(
1761+
ValueError, "Refusing to deserialize untrusted class"
1762+
):
1763+
MediaUpload.new_from_json(payload)
1764+
1765+
def test_deserialize_invalid_filename_raises_value_error(self):
1766+
"""Verify that MediaFileUpload.from_json rejects malformed filenames.
1767+
1768+
Guards against type confusion and null-byte injection during filename parsing.
1769+
"""
1770+
cases = [
1771+
None,
1772+
"",
1773+
123,
1774+
"/path/with/\x00/nullbyte",
1775+
]
1776+
for invalid_filename in cases:
1777+
with self.subTest(invalid_filename=invalid_filename):
1778+
payload = json.dumps(
1779+
{
1780+
"_module": "googleapiclient.http",
1781+
"_class": "MediaFileUpload",
1782+
"_filename": invalid_filename,
1783+
"_mimetype": "text/plain",
1784+
"_chunksize": 1048576,
1785+
"_resumable": True,
1786+
}
1787+
)
1788+
with self.assertRaisesRegex(
1789+
ValueError, "Invalid or missing '_filename'"
1790+
):
1791+
MediaUpload.new_from_json(payload)
1792+
1793+
def test_deserialize_valid_media_file_upload_roundtrip(self):
1794+
"""Verify legitimate MediaFileUpload roundtrip serialization.
1795+
1796+
Ensures that valid MediaFileUpload instances continue to serialize and
1797+
reconstruct correctly without breaking backwards compatibility.
1798+
"""
1799+
with tempfile.TemporaryDirectory() as tmpdir:
1800+
test_file = os.path.join(tmpdir, "test.txt")
1801+
with open(test_file, "wb") as f:
1802+
f.write(b"valid content")
1803+
1804+
upload = MediaFileUpload(test_file, mimetype="text/plain", resumable=True)
1805+
serialized = upload.to_json()
1806+
1807+
deserialized = MediaUpload.new_from_json(serialized)
1808+
self.assertIsInstance(deserialized, MediaFileUpload)
1809+
self.assertEqual(deserialized.getbytes(0, 13), b"valid content")
1810+
1811+
def test_deserialize_media_file_upload_default_fallbacks(self):
1812+
"""Verify fallback handling when _chunksize or _resumable are missing/null."""
1813+
with tempfile.TemporaryDirectory() as tmpdir:
1814+
test_file = os.path.join(tmpdir, "test.txt")
1815+
with open(test_file, "wb") as f:
1816+
f.write(b"fallback test content")
1817+
1818+
# Payload omitting _chunksize and _resumable (or with explicit null values)
1819+
payload = json.dumps(
1820+
{
1821+
"_module": "googleapiclient.http",
1822+
"_class": "MediaFileUpload",
1823+
"_filename": test_file,
1824+
"_chunksize": None,
1825+
"_resumable": None,
1826+
}
1827+
)
1828+
1829+
deserialized = MediaUpload.new_from_json(payload)
1830+
self.assertIsInstance(deserialized, MediaFileUpload)
1831+
self.assertEqual(deserialized.chunksize(), DEFAULT_CHUNK_SIZE)
1832+
self.assertFalse(deserialized.resumable())
1833+
self.assertEqual(deserialized.getbytes(0, 21), b"fallback test content")
1834+
1835+
def test_deserialize_invalid_field_types_raises_value_error(self):
1836+
"""Verify that MediaFileUpload.from_json rejects invalid field types."""
1837+
cases = [
1838+
# Invalid _chunksize
1839+
({"_chunksize": "not_an_int"}, "'_chunksize' must be an integer."),
1840+
({"_chunksize": True}, "'_chunksize' must be an integer."),
1841+
({"_chunksize": 1.5}, "'_chunksize' must be an integer."),
1842+
# Invalid _resumable
1843+
({"_resumable": "true"}, "'_resumable' must be a boolean."),
1844+
({"_resumable": 1}, "'_resumable' must be a boolean."),
1845+
# Invalid _mimetype
1846+
({"_mimetype": 123}, "'_mimetype' must be a string."),
1847+
({"_mimetype": False}, "'_mimetype' must be a string."),
1848+
]
1849+
1850+
with tempfile.TemporaryDirectory() as tmpdir:
1851+
test_file = os.path.join(tmpdir, "test.txt")
1852+
with open(test_file, "wb") as f:
1853+
f.write(b"content")
1854+
1855+
for override, error_msg in cases:
1856+
with self.subTest(override=override):
1857+
data = {
1858+
"_module": "googleapiclient.http",
1859+
"_class": "MediaFileUpload",
1860+
"_filename": test_file,
1861+
"_chunksize": DEFAULT_CHUNK_SIZE,
1862+
"_resumable": True,
1863+
"_mimetype": "text/plain",
1864+
}
1865+
data.update(override)
1866+
payload = json.dumps(data)
1867+
with self.assertRaisesRegex(ValueError, error_msg):
1868+
MediaUpload.new_from_json(payload)
1869+
1870+
def test_deserialize_non_dict_payload_raises_value_error(self):
1871+
"""Verify that non-dictionary JSON payloads raise ValueError."""
1872+
cases = [
1873+
"[]",
1874+
'"string_payload"',
1875+
"123",
1876+
"true",
1877+
"null",
1878+
]
1879+
for payload in cases:
1880+
with self.subTest(payload=payload):
1881+
with self.assertRaisesRegex(
1882+
ValueError, "Serialized MediaUpload data must be a JSON object."
1883+
):
1884+
MediaUpload.new_from_json(payload)
1885+
1886+
with self.assertRaisesRegex(
1887+
ValueError, "Serialized MediaFileUpload data must be a JSON object."
1888+
):
1889+
MediaFileUpload.from_json(payload)
1890+
1891+
17321892
if __name__ == "__main__":
17331893
logging.getLogger().setLevel(logging.ERROR)
17341894
unittest.main()

0 commit comments

Comments
 (0)