diff --git a/.github/workflows/cifuzz.yml b/.github/workflows/cifuzz.yml index 869f7e5..1c1f26a 100644 --- a/.github/workflows/cifuzz.yml +++ b/.github/workflows/cifuzz.yml @@ -25,13 +25,13 @@ jobs: steps: - name: Build Fuzzers id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@675ddfb89ae1c614f1dfa99d18b91cd6d1d6b88b # master 2026-04-10 + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@9ff5089dbb11800055b6bc1af919a84b06dee2c8 # master 2026-04-27 with: oss-fuzz-project-name: "python-multipart" language: python - name: Run Fuzzers - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@675ddfb89ae1c614f1dfa99d18b91cd6d1d6b88b # master 2026-04-10 + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@9ff5089dbb11800055b6bc1af919a84b06dee2c8 # master 2026-04-27 with: oss-fuzz-project-name: "python-multipart" language: python diff --git a/CHANGELOG.md b/CHANGELOG.md index fa11f03..d915b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.29 (2026-05-17) + +* Handle malformed RFC 2231 continuations in `parse_options_header` [#270](https://github.com/Kludex/python-multipart/pull/270). + ## 0.0.28 (2026-05-10) * Speed up partial-boundary tail scan via `bytes.find` [#281](https://github.com/Kludex/python-multipart/pull/281). diff --git a/pyproject.toml b/pyproject.toml index 513c24c..7d1236a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ dev = [ "pymdown-extensions>=10.21.2", ] +[tool.uv] +exclude-newer = "7 days" + [tool.uv.pip] reinstall-package = ["python-multipart"] diff --git a/python_multipart/__init__.py b/python_multipart/__init__.py index 360fa1e..be89e91 100644 --- a/python_multipart/__init__.py +++ b/python_multipart/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.0.28" +__version__ = "0.0.29" from .multipart import ( BaseParser, diff --git a/python_multipart/multipart.py b/python_multipart/multipart.py index 035a3c5..02f34fe 100644 --- a/python_multipart/multipart.py +++ b/python_multipart/multipart.py @@ -179,7 +179,15 @@ def parse_options_header(value: str | bytes | None) -> tuple[bytes, dict[bytes, # ctype, rest = value.split(b';', 1) message = Message() message["content-type"] = value - params = message.get_params() + # `get_params()` can raise on malformed RFC 2231 headers found via fuzzing: + # - ValueError on oversized continuation indices (all supported versions). + # - TypeError on mixed `filename*` + `filename*0*` continuations (Python 3.12 only; + # 3.13+ silently picks a value). + # TODO: drop `TypeError` once Python 3.12 reaches EOL (October 2028). + try: + params = message.get_params() + except (TypeError, ValueError): # pragma: no cover + return (value.split(";", 1)[0].lower().strip().encode("latin-1"), {}) # If there were no parameters, this would have already returned above assert params, "At least the content type value should be present" ctype = params.pop(0)[0].encode("latin-1") diff --git a/tests/test_multipart.py b/tests/test_multipart.py index fb09504..6c2d43b 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -304,6 +304,19 @@ def test_handles_rfc_2231(self) -> None: self.assertEqual(p[b"param"], b"encoded message") + def test_rejects_oversized_rfc_2231_index(self) -> None: + t, p = parse_options_header("text/plain; filename*" + ("1" * 4301) + "*=utf-8''x") + + self.assertEqual(t, b"text/plain") + self.assertEqual(p, {}) + + @pytest.mark.skipif(sys.version_info >= (3, 13), reason="email parser only raises TypeError on Python 3.12") + def test_rejects_mixed_rfc_2231_continuations(self) -> None: + t, p = parse_options_header("text/plain; filename*=utf-8''a; filename*0*=utf-8''b") + + self.assertEqual(t, b"text/plain") + self.assertEqual(p, {}) + class TestBaseParser(unittest.TestCase): def setUp(self) -> None: diff --git a/uv.lock b/uv.lock index e5f092f..e006aa4 100644 --- a/uv.lock +++ b/uv.lock @@ -1205,11 +1205,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]