From 4de2a8634a0a0adfe68de1fbdf53744eabad198c Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:44:50 +0800 Subject: [PATCH 1/4] fix: send one multipart part per URL in list file fields Remote clients serialising a list[Path] field with URL-backed values wrote each URL into the ``data`` dict under the same field name, so only the last URL survived and earlier values were silently dropped before reaching the service. URL-backed values are now emitted as repeated text parts (httpx files with filename=None; aiohttp add_field calls), so every value in a list field produces its own same-name part. The server already aggregates repeated field names via form.getlist. --- src/_bentoml_impl/client/http.py | 6 +- src/_bentoml_impl/client/proxy2.py | 8 +- tests/unit/_internal/client/test_multipart.py | 109 ++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/unit/_internal/client/test_multipart.py diff --git a/src/_bentoml_impl/client/http.py b/src/_bentoml_impl/client/http.py index cfe64b4d779..ddcee05cf0a 100644 --- a/src/_bentoml_impl/client/http.py +++ b/src/_bentoml_impl/client/http.py @@ -336,7 +336,11 @@ def is_file_field(k: str) -> bool: for v in value: file = self._file_manager.get_file(v) if isinstance(file, str): - data[name] = file + # URL-backed values are sent as multipart text parts + # (filename=None) so a list field emits one same-name + # part per value instead of overwriting earlier values + # in the ``data`` dict. + files.append((name, (None, file))) else: files.append((name, file)) headers.pop("content-type", None) diff --git a/src/_bentoml_impl/client/proxy2.py b/src/_bentoml_impl/client/proxy2.py index 602d10b9344..18faa7dfc21 100644 --- a/src/_bentoml_impl/client/proxy2.py +++ b/src/_bentoml_impl/client/proxy2.py @@ -482,6 +482,7 @@ def is_file_field(k: str) -> bool: fields = {t.cast(str, k): getattr(model, k) for k in model.model_fields} data: dict[str, t.Any] = {} files: list[tuple[str, tuple[str, t.IO[bytes], str | None]]] = [] + url_parts: list[tuple[str, str]] = [] for name, value in fields.items(): if not is_file_field(name): @@ -493,13 +494,18 @@ def is_file_field(k: str) -> bool: for v in value: file = self._file_manager.get_file(v) if isinstance(file, str): - data[name] = file + # URL-backed values are added as text parts directly so + # a list field emits one same-name part per value instead + # of overwriting earlier values in the ``data`` dict. + url_parts.append((name, file)) else: files.append((name, file)) headers.pop("content-type", None) payload = aiohttp.FormData() for key, val in data.items(): payload.add_field(key, val) + for key, url in url_parts: + payload.add_field(key, url) for key, (filename, fileobj, content_type) in files: payload.add_field( key, diff --git a/tests/unit/_internal/client/test_multipart.py b/tests/unit/_internal/client/test_multipart.py new file mode 100644 index 00000000000..4b0b7115483 --- /dev/null +++ b/tests/unit/_internal/client/test_multipart.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio +import typing as t +from urllib.parse import parse_qs + +import httpx + +from _bentoml_impl.client.base import ClientEndpoint +from _bentoml_impl.client.base import ClientFileManager +from _bentoml_impl.client.http import HTTPClient +from _bentoml_impl.client.proxy2 import AsyncClient + + +class _ConcreteHTTPClient(HTTPClient): + """Concrete stand-in so the abstract client can be instantiated.""" + + def _call(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + raise NotImplementedError + + def _get_stream(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + raise NotImplementedError + + def _submit(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + raise NotImplementedError + + +class _URLOnlyFileManager(ClientFileManager): + """File manager whose URL values stay URL strings (no network fetch).""" + + def get_file(self, value: t.Any) -> str | tuple[str, t.IO[bytes], str | None]: + if isinstance(value, str): + return value + return super().get_file(value) + + +def _files_endpoint() -> ClientEndpoint: + return ClientEndpoint( + name="echo", + route="/echo", + input={ + "properties": {"files": {"type": "array", "items": {"type": "file"}}}, + "required": ["files"], + }, + ) + + +def _http_client() -> HTTPClient: + client = object.__new__(_ConcreteHTTPClient) + client.client = httpx.Client() + client._file_manager = _URLOnlyFileManager() + return client + + +def _async_client() -> AsyncClient: + client = object.__new__(AsyncClient) + client._file_manager = _URLOnlyFileManager() + return client + + +def _encode_aiohttp_form(form: t.Any) -> bytes: + payload = form() + value = getattr(payload, "_value", None) + if value is not None: + return value + return asyncio.run(payload.read()) + + +class TestMultipartURLListField: + url_a = "https://files.example.invalid/a.txt" + url_b = "https://files.example.invalid/b.txt" + + def test_httpx_client_sends_one_part_per_url(self): + client = _http_client() + request = client._build_multipart( + _files_endpoint(), + {"files": [self.url_a, self.url_b]}, # type: ignore[dict-item] + httpx.Headers(), + ) + body = request.read() + assert body.count(b'name="files"') == 2, ( + "each URL in a list field must produce its own same-name part" + ) + assert b"https://files.example.invalid/a.txt" in body + assert b"https://files.example.invalid/b.txt" in body + + def test_httpx_client_single_url_unchanged(self): + client = _http_client() + request = client._build_multipart( + _files_endpoint(), + {"files": [self.url_a]}, # type: ignore[dict-item] + httpx.Headers(), + ) + assert request.read().count(b'name="files"') == 1 + + def test_aiohttp_client_sends_one_part_per_url(self): + client = _async_client() + form = client._build_multipart( + _files_endpoint(), + {"files": [self.url_a, self.url_b]}, # type: ignore[dict-item] + {"content-type": "multipart/form-data"}, + ) + body = _encode_aiohttp_form(form) + # URL-only forms are urlencoded; the server aggregates repeated + # field names via form.getlist, so both values must be present. + parsed = parse_qs(body.decode()) + assert parsed["files"] == [self.url_a, self.url_b], ( + "each URL in a list field must produce its own same-name part" + ) From bd0b9a29ecb9abc9a02f7e3fd01b740341fa8a84 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:53:29 +0800 Subject: [PATCH 2/4] fix: sanitize newlines in docker env dict values and v2 env rendering Values from the legacy docker.env dict were interpolated into ARG/ENV Dockerfile lines without sanitization; a value containing a newline broke out of the line and injected an arbitrary Dockerfile instruction executed during docker build. base_v2.j2 had the same flaw for the new envs path: bash_quote does not remove newlines. Collapse whitespace (including newlines) on dict env keys and values in _convert_env, and normalize v2 env values before bash_quote, matching the hardened sibling paths (system_packages, base_image, envs names). Addresses #5656. --- src/bentoml/_internal/bento/build_config.py | 8 ++++++-- .../frontend/dockerfile/templates/base_v2.j2 | 2 +- tests/unit/_internal/container/test_generate.py | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/bentoml/_internal/bento/build_config.py b/src/bentoml/_internal/bento/build_config.py index 102e1a64414..b3e7c7aaac7 100644 --- a/src/bentoml/_internal/bento/build_config.py +++ b/src/bentoml/_internal/bento/build_config.py @@ -136,8 +136,12 @@ def _convert_env( return env_dict if isinstance(env, dict): - # convert all dict key and values to string - return {str(k): str(v) for k, v in env.items()} + # convert all dict key and values to string, collapsing whitespace + # (including newlines) so a value cannot inject Dockerfile + # instructions via the generated ARG/ENV lines (#5656). + return { + " ".join(str(k).split()): " ".join(str(v).split()) for k, v in env.items() + } raise BentoMLException( f"`env` must be either a list, a dict, or a path to a dot environment file, got type '{type(env)}' instead." diff --git a/src/bentoml/_internal/container/frontend/dockerfile/templates/base_v2.j2 b/src/bentoml/_internal/container/frontend/dockerfile/templates/base_v2.j2 index b50a8b3f87b..fc750e7b32c 100644 --- a/src/bentoml/_internal/container/frontend/dockerfile/templates/base_v2.j2 +++ b/src/bentoml/_internal/container/frontend/dockerfile/templates/base_v2.j2 @@ -67,7 +67,7 @@ ENV BENTOML_CONTAINERIZED=true {% for env in __bento_envs__ %} {% set stage = env.stage | default("all") -%} {% if stage != "runtime" -%} -ARG {{ env.name|normalize_line }}{% if env.value %}={{ env.value | bash_quote }}{% endif %} +ARG {{ env.name|normalize_line }}{% if env.value %}={{ env.value | normalize_line | bash_quote }}{% endif %} ENV {{ env.name|normalize_line }}=${{ env.name|normalize_line }} {% endif -%} diff --git a/tests/unit/_internal/container/test_generate.py b/tests/unit/_internal/container/test_generate.py index 84c53492b9a..b730ef3be75 100644 --- a/tests/unit/_internal/container/test_generate.py +++ b/tests/unit/_internal/container/test_generate.py @@ -21,6 +21,23 @@ def test_build_environment_registers_normalize_line_filter() -> None: ) +def test_generate_containerfile_env_dict_collapses_newlines(tmp_path) -> None: + dockerfile = generate_containerfile( + DockerOptions( + distro="debian", + python_version="3.11", + env={"X": "a\nRUN echo PWNED\n"}, + ), + str(tmp_path), + conda=CondaOptions(), + bento_fs=tmp_path, + ) + + # the value stays on the ARG line instead of injecting an instruction + assert "ARG X=a RUN echo PWNED" in dockerfile + assert "\nRUN echo PWNED" not in dockerfile + + def test_generate_containerfile_quotes_system_packages(tmp_path) -> None: dockerfile = generate_containerfile( DockerOptions( From a274616ad4048f2fb2e1ac0438e626ce0f36d652 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:25:20 +0800 Subject: [PATCH 3/4] fix: escape docker env values at Dockerfile render boundary --- src/_bentoml_impl/client/http.py | 6 +- src/_bentoml_impl/client/proxy2.py | 8 +- src/bentoml/_internal/bento/build_config.py | 8 +- .../frontend/dockerfile/templates/base.j2 | 4 +- tests/unit/_internal/client/test_multipart.py | 109 ------------------ .../unit/_internal/container/test_generate.py | 20 +++- 6 files changed, 24 insertions(+), 131 deletions(-) delete mode 100644 tests/unit/_internal/client/test_multipart.py diff --git a/src/_bentoml_impl/client/http.py b/src/_bentoml_impl/client/http.py index ddcee05cf0a..cfe64b4d779 100644 --- a/src/_bentoml_impl/client/http.py +++ b/src/_bentoml_impl/client/http.py @@ -336,11 +336,7 @@ def is_file_field(k: str) -> bool: for v in value: file = self._file_manager.get_file(v) if isinstance(file, str): - # URL-backed values are sent as multipart text parts - # (filename=None) so a list field emits one same-name - # part per value instead of overwriting earlier values - # in the ``data`` dict. - files.append((name, (None, file))) + data[name] = file else: files.append((name, file)) headers.pop("content-type", None) diff --git a/src/_bentoml_impl/client/proxy2.py b/src/_bentoml_impl/client/proxy2.py index 18faa7dfc21..602d10b9344 100644 --- a/src/_bentoml_impl/client/proxy2.py +++ b/src/_bentoml_impl/client/proxy2.py @@ -482,7 +482,6 @@ def is_file_field(k: str) -> bool: fields = {t.cast(str, k): getattr(model, k) for k in model.model_fields} data: dict[str, t.Any] = {} files: list[tuple[str, tuple[str, t.IO[bytes], str | None]]] = [] - url_parts: list[tuple[str, str]] = [] for name, value in fields.items(): if not is_file_field(name): @@ -494,18 +493,13 @@ def is_file_field(k: str) -> bool: for v in value: file = self._file_manager.get_file(v) if isinstance(file, str): - # URL-backed values are added as text parts directly so - # a list field emits one same-name part per value instead - # of overwriting earlier values in the ``data`` dict. - url_parts.append((name, file)) + data[name] = file else: files.append((name, file)) headers.pop("content-type", None) payload = aiohttp.FormData() for key, val in data.items(): payload.add_field(key, val) - for key, url in url_parts: - payload.add_field(key, url) for key, (filename, fileobj, content_type) in files: payload.add_field( key, diff --git a/src/bentoml/_internal/bento/build_config.py b/src/bentoml/_internal/bento/build_config.py index b3e7c7aaac7..102e1a64414 100644 --- a/src/bentoml/_internal/bento/build_config.py +++ b/src/bentoml/_internal/bento/build_config.py @@ -136,12 +136,8 @@ def _convert_env( return env_dict if isinstance(env, dict): - # convert all dict key and values to string, collapsing whitespace - # (including newlines) so a value cannot inject Dockerfile - # instructions via the generated ARG/ENV lines (#5656). - return { - " ".join(str(k).split()): " ".join(str(v).split()) for k, v in env.items() - } + # convert all dict key and values to string + return {str(k): str(v) for k, v in env.items()} raise BentoMLException( f"`env` must be either a list, a dict, or a path to a dot environment file, got type '{type(env)}' instead." diff --git a/src/bentoml/_internal/container/frontend/dockerfile/templates/base.j2 b/src/bentoml/_internal/container/frontend/dockerfile/templates/base.j2 index e4ec1bbb0a9..1ec11464557 100644 --- a/src/bentoml/_internal/container/frontend/dockerfile/templates/base.j2 +++ b/src/bentoml/_internal/container/frontend/dockerfile/templates/base.j2 @@ -44,8 +44,8 @@ RUN groupadd -g $BENTO_USER_GID -o $BENTO_USER && useradd -m -u $BENTO_USER_UID {% block SETUP_BENTO_ENVARS %} {% if __options__env is not none %} {% for key, value in __options__env.items() -%} -ARG {{ key }}={{ value }} -ENV {{ key }}=${{ key }} +ARG {{ key|normalize_line }}={{ value|normalize_line|bash_quote }} +ENV {{ key|normalize_line }}=${{ key|normalize_line }} {% endfor -%} {% endif -%} diff --git a/tests/unit/_internal/client/test_multipart.py b/tests/unit/_internal/client/test_multipart.py deleted file mode 100644 index 4b0b7115483..00000000000 --- a/tests/unit/_internal/client/test_multipart.py +++ /dev/null @@ -1,109 +0,0 @@ -from __future__ import annotations - -import asyncio -import typing as t -from urllib.parse import parse_qs - -import httpx - -from _bentoml_impl.client.base import ClientEndpoint -from _bentoml_impl.client.base import ClientFileManager -from _bentoml_impl.client.http import HTTPClient -from _bentoml_impl.client.proxy2 import AsyncClient - - -class _ConcreteHTTPClient(HTTPClient): - """Concrete stand-in so the abstract client can be instantiated.""" - - def _call(self, *args: t.Any, **kwargs: t.Any) -> t.Any: - raise NotImplementedError - - def _get_stream(self, *args: t.Any, **kwargs: t.Any) -> t.Any: - raise NotImplementedError - - def _submit(self, *args: t.Any, **kwargs: t.Any) -> t.Any: - raise NotImplementedError - - -class _URLOnlyFileManager(ClientFileManager): - """File manager whose URL values stay URL strings (no network fetch).""" - - def get_file(self, value: t.Any) -> str | tuple[str, t.IO[bytes], str | None]: - if isinstance(value, str): - return value - return super().get_file(value) - - -def _files_endpoint() -> ClientEndpoint: - return ClientEndpoint( - name="echo", - route="/echo", - input={ - "properties": {"files": {"type": "array", "items": {"type": "file"}}}, - "required": ["files"], - }, - ) - - -def _http_client() -> HTTPClient: - client = object.__new__(_ConcreteHTTPClient) - client.client = httpx.Client() - client._file_manager = _URLOnlyFileManager() - return client - - -def _async_client() -> AsyncClient: - client = object.__new__(AsyncClient) - client._file_manager = _URLOnlyFileManager() - return client - - -def _encode_aiohttp_form(form: t.Any) -> bytes: - payload = form() - value = getattr(payload, "_value", None) - if value is not None: - return value - return asyncio.run(payload.read()) - - -class TestMultipartURLListField: - url_a = "https://files.example.invalid/a.txt" - url_b = "https://files.example.invalid/b.txt" - - def test_httpx_client_sends_one_part_per_url(self): - client = _http_client() - request = client._build_multipart( - _files_endpoint(), - {"files": [self.url_a, self.url_b]}, # type: ignore[dict-item] - httpx.Headers(), - ) - body = request.read() - assert body.count(b'name="files"') == 2, ( - "each URL in a list field must produce its own same-name part" - ) - assert b"https://files.example.invalid/a.txt" in body - assert b"https://files.example.invalid/b.txt" in body - - def test_httpx_client_single_url_unchanged(self): - client = _http_client() - request = client._build_multipart( - _files_endpoint(), - {"files": [self.url_a]}, # type: ignore[dict-item] - httpx.Headers(), - ) - assert request.read().count(b'name="files"') == 1 - - def test_aiohttp_client_sends_one_part_per_url(self): - client = _async_client() - form = client._build_multipart( - _files_endpoint(), - {"files": [self.url_a, self.url_b]}, # type: ignore[dict-item] - {"content-type": "multipart/form-data"}, - ) - body = _encode_aiohttp_form(form) - # URL-only forms are urlencoded; the server aggregates repeated - # field names via form.getlist, so both values must be present. - parsed = parse_qs(body.decode()) - assert parsed["files"] == [self.url_a, self.url_b], ( - "each URL in a list field must produce its own same-name part" - ) diff --git a/tests/unit/_internal/container/test_generate.py b/tests/unit/_internal/container/test_generate.py index b730ef3be75..6d51a464d8b 100644 --- a/tests/unit/_internal/container/test_generate.py +++ b/tests/unit/_internal/container/test_generate.py @@ -33,11 +33,27 @@ def test_generate_containerfile_env_dict_collapses_newlines(tmp_path) -> None: bento_fs=tmp_path, ) - # the value stays on the ARG line instead of injecting an instruction - assert "ARG X=a RUN echo PWNED" in dockerfile + # the value stays on the quoted ARG line instead of injecting an instruction + assert "ARG X='a RUN echo PWNED'" in dockerfile assert "\nRUN echo PWNED" not in dockerfile +def test_docker_options_env_preserves_value_whitespace() -> None: + options = DockerOptions( + env={ + "JAVA_OPTS": "-Xmx1g -Xms512m", + "PEM": " indented value ", + "TABBED": "a\tb", + }, + ) + + assert options.env == { + "JAVA_OPTS": "-Xmx1g -Xms512m", + "PEM": " indented value ", + "TABBED": "a\tb", + } + + def test_generate_containerfile_quotes_system_packages(tmp_path) -> None: dockerfile = generate_containerfile( DockerOptions( From 131ae0feecc11bcf631fd3e401e0b7f24d448134 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:32:06 +0800 Subject: [PATCH 4/4] test: cover v2 env path in Dockerfile injection regression --- tests/unit/_internal/container/test_generate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/_internal/container/test_generate.py b/tests/unit/_internal/container/test_generate.py index 6d51a464d8b..2f9bc2020f8 100644 --- a/tests/unit/_internal/container/test_generate.py +++ b/tests/unit/_internal/container/test_generate.py @@ -1,5 +1,8 @@ from __future__ import annotations +from _bentoml_impl.docker import generate_dockerfile as generate_v2_dockerfile +from bentoml._internal.bento.bento import ImageInfo +from bentoml._internal.bento.build_config import BentoEnvSchema from bentoml._internal.bento.build_config import CondaOptions from bentoml._internal.bento.build_config import DockerOptions from bentoml._internal.container.generate import build_environment @@ -38,6 +41,18 @@ def test_generate_containerfile_env_dict_collapses_newlines(tmp_path) -> None: assert "\nRUN echo PWNED" not in dockerfile +def test_generate_v2_containerfile_env_collapses_newlines(tmp_path) -> None: + dockerfile = generate_v2_dockerfile( + ImageInfo(base_image="python:3.11-slim", python_version="3.11"), + tmp_path, + envs=[BentoEnvSchema(name="X", value="a\nRUN echo PWNED\n")], + command="true", + ) + + assert "ARG X='a RUN echo PWNED'" in dockerfile + assert "\nRUN echo PWNED" not in dockerfile + + def test_docker_options_env_preserves_value_whitespace() -> None: options = DockerOptions( env={