|
32 | 32 | import random |
33 | 33 | import socket |
34 | 34 | import ssl |
| 35 | +import tempfile |
35 | 36 | import time |
36 | 37 | import unittest |
37 | 38 | from unittest import mock |
|
42 | 43 | from googleapiclient.discovery import build |
43 | 44 | from googleapiclient.errors import BatchError, HttpError, InvalidChunkSizeError |
44 | 45 | from googleapiclient.http import ( |
| 46 | + DEFAULT_CHUNK_SIZE, |
45 | 47 | MAX_URI_LENGTH, |
46 | 48 | BatchHttpRequest, |
47 | 49 | HttpMock, |
@@ -1729,6 +1731,164 @@ def test_build_http_default_308_is_excluded_as_redirect(self): |
1729 | 1731 | self.assertTrue(308 not in http.redirect_codes) |
1730 | 1732 |
|
1731 | 1733 |
|
| 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 | + |
1732 | 1892 | if __name__ == "__main__": |
1733 | 1893 | logging.getLogger().setLevel(logging.ERROR) |
1734 | 1894 | unittest.main() |
0 commit comments