From 6820df6cb1977f5b8e574ded981ae9e37456c2c0 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:00 -0400 Subject: [PATCH 1/3] Neutralize path traversal and credential exposure in generated code Three fixes applied via post-generation hooks, mirroring the existing AuthenticatedClient.token repr hook, so regeneration preserves them: - Path parameters are routed through the new ionq_core._url.quote_path_param, which raises ValueError for "", ".", and ".." before a request is built. urllib.parse.quote never encodes dots, so ".." survived into the URL and RFC 3986 normalization (applied by httpx and any server) deleted the preceding fixed segment, e.g. GET /sessions/../jobs became the unscoped account-wide GET /jobs. (CWE-23) - AuthenticatedClient no longer writes the Authorization value into self._headers when the httpx clients are built. That dict is included in the attrs repr (defeating token's repr=False after first use) and is the very dict the caller passed as headers=, so the key bled into any other client sharing it. The header is merged into a method-local dict handed straight to httpx instead. (CWE-532) - QctrlQaoaJobCreationPayloadExternalSettings.api_credentials (a Q-CTRL API key) is excluded from the attrs repr, so logging or echoing a job payload cannot disclose it. to_dict() and the wire format are unchanged. (CWE-532) --- ionq_core/_url.py | 24 +++++++ ionq_core/api/backends/get_backend.py | 4 +- ionq_core/api/backends/get_backends.py | 2 +- .../characterizations/get_characterization.py | 4 +- .../get_characterizations_for_backend.py | 4 +- ionq_core/api/default/cancel_job.py | 4 +- ionq_core/api/default/cancel_jobs.py | 2 +- ionq_core/api/default/clone_job.py | 4 +- ionq_core/api/default/create_job.py | 2 +- ionq_core/api/default/create_session.py | 2 +- ionq_core/api/default/delete_job.py | 4 +- ionq_core/api/default/delete_jobs.py | 2 +- ionq_core/api/default/end_session.py | 4 +- ionq_core/api/default/estimate_job_cost.py | 2 +- ionq_core/api/default/get_job.py | 4 +- ionq_core/api/default/get_job_artifact.py | 4 +- ionq_core/api/default/get_job_cost.py | 4 +- .../api/default/get_job_probabilities.py | 4 +- ionq_core/api/default/get_jobs.py | 2 +- ionq_core/api/default/get_session.py | 4 +- ionq_core/api/default/get_session_jobs.py | 4 +- ionq_core/api/default/get_sessions.py | 2 +- .../api/default/get_variant_histogram.py | 4 +- .../api/default/get_variant_probabilities.py | 4 +- ionq_core/api/default/get_variant_shots.py | 4 +- ionq_core/api/usage/get_usages.py | 4 +- ionq_core/api/whoami/get_whoami.py | 2 +- ionq_core/client.py | 8 +-- ..._job_creation_payload_external_settings.py | 2 +- openapi-python-client-config.yaml | 11 +++ tests/test_models.py | 36 ++++++++++ tests/test_url.py | 70 +++++++++++++++++++ 32 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 ionq_core/_url.py create mode 100644 tests/test_url.py diff --git a/ionq_core/_url.py b/ionq_core/_url.py new file mode 100644 index 0000000..9a50c7f --- /dev/null +++ b/ionq_core/_url.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""URL path-parameter encoding for the generated endpoint modules, wired in by +a post-generation hook in ``openapi-python-client-config.yaml``.""" + +from urllib.parse import quote + + +def quote_path_param(value: object) -> str: + """Percent-encode ``value`` as a single URL path segment. + + Rejects ``""``, ``"."``, and ``".."``: ``quote`` never encodes dots, so + those values would survive into the URL verbatim and collapse a fixed path + segment under RFC 3986 normalization (e.g. ``/sessions/../jobs`` -> + ``/jobs``, turning a session-scoped request into an account-wide one). + + Raises: + ValueError: If the value is ``""``, ``"."``, or ``".."``. + """ + segment = str(value) + if segment in ("", ".", ".."): + raise ValueError(f"Invalid URL path parameter {segment!r}: it would escape its path segment") + return quote(segment, safe="") diff --git a/ionq_core/api/backends/get_backend.py b/ionq_core/api/backends/get_backend.py index 1be4cbe..51d86b2 100644 --- a/ionq_core/api/backends/get_backend.py +++ b/ionq_core/api/backends/get_backend.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -31,7 +31,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/backends/{backend}".format(backend=quote(str(backend), safe=""),), + "url": "/backends/{backend}".format(backend=quote_path_param(backend),), } diff --git a/ionq_core/api/backends/get_backends.py b/ionq_core/api/backends/get_backends.py index 8a1bf11..3f2068f 100644 --- a/ionq_core/api/backends/get_backends.py +++ b/ionq_core/api/backends/get_backends.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/characterizations/get_characterization.py b/ionq_core/api/characterizations/get_characterization.py index 867829d..358b36f 100644 --- a/ionq_core/api/characterizations/get_characterization.py +++ b/ionq_core/api/characterizations/get_characterization.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -33,7 +33,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/backends/{backend}/characterizations/{uuid}".format(backend=quote(str(backend), safe=""),uuid=quote(str(uuid), safe=""),), + "url": "/backends/{backend}/characterizations/{uuid}".format(backend=quote_path_param(backend),uuid=quote_path_param(uuid),), } diff --git a/ionq_core/api/characterizations/get_characterizations_for_backend.py b/ionq_core/api/characterizations/get_characterizations_for_backend.py index 6b68ba3..24cf1bd 100644 --- a/ionq_core/api/characterizations/get_characterizations_for_backend.py +++ b/ionq_core/api/characterizations/get_characterizations_for_backend.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -49,7 +49,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/backends/{backend}/characterizations".format(backend=quote(str(backend), safe=""),), + "url": "/backends/{backend}/characterizations".format(backend=quote_path_param(backend),), "params": params, } diff --git a/ionq_core/api/default/cancel_job.py b/ionq_core/api/default/cancel_job.py index fed6b2a..7ab8085 100644 --- a/ionq_core/api/default/cancel_job.py +++ b/ionq_core/api/default/cancel_job.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -29,7 +29,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "put", - "url": "/jobs/{uuid}/status/cancel".format(uuid=quote(str(uuid), safe=""),), + "url": "/jobs/{uuid}/status/cancel".format(uuid=quote_path_param(uuid),), } diff --git a/ionq_core/api/default/cancel_jobs.py b/ionq_core/api/default/cancel_jobs.py index 389caea..f5d7b73 100644 --- a/ionq_core/api/default/cancel_jobs.py +++ b/ionq_core/api/default/cancel_jobs.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/clone_job.py b/ionq_core/api/default/clone_job.py index 8dbd54a..030b46a 100644 --- a/ionq_core/api/default/clone_job.py +++ b/ionq_core/api/default/clone_job.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -33,7 +33,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "post", - "url": "/jobs/{uuid}/clone".format(uuid=quote(str(uuid), safe=""),), + "url": "/jobs/{uuid}/clone".format(uuid=quote_path_param(uuid),), } _kwargs["json"] = body.to_dict() diff --git a/ionq_core/api/default/create_job.py b/ionq_core/api/default/create_job.py index 14e3cfc..b5bc148 100644 --- a/ionq_core/api/default/create_job.py +++ b/ionq_core/api/default/create_job.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/create_session.py b/ionq_core/api/default/create_session.py index 97a40f3..0a21c52 100644 --- a/ionq_core/api/default/create_session.py +++ b/ionq_core/api/default/create_session.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/delete_job.py b/ionq_core/api/default/delete_job.py index b1b9fc4..251bc12 100644 --- a/ionq_core/api/default/delete_job.py +++ b/ionq_core/api/default/delete_job.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -29,7 +29,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "delete", - "url": "/jobs/{uuid}".format(uuid=quote(str(uuid), safe=""),), + "url": "/jobs/{uuid}".format(uuid=quote_path_param(uuid),), } diff --git a/ionq_core/api/default/delete_jobs.py b/ionq_core/api/default/delete_jobs.py index 34711b0..d1501ab 100644 --- a/ionq_core/api/default/delete_jobs.py +++ b/ionq_core/api/default/delete_jobs.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/end_session.py b/ionq_core/api/default/end_session.py index 705b564..eafa588 100644 --- a/ionq_core/api/default/end_session.py +++ b/ionq_core/api/default/end_session.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -29,7 +29,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "post", - "url": "/sessions/{session_id}/end".format(session_id=quote(str(session_id), safe=""),), + "url": "/sessions/{session_id}/end".format(session_id=quote_path_param(session_id),), } diff --git a/ionq_core/api/default/estimate_job_cost.py b/ionq_core/api/default/estimate_job_cost.py index 03237ea..1bcd870 100644 --- a/ionq_core/api/default/estimate_job_cost.py +++ b/ionq_core/api/default/estimate_job_cost.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/get_job.py b/ionq_core/api/default/get_job.py index d1f58f5..73eb11e 100644 --- a/ionq_core/api/default/get_job.py +++ b/ionq_core/api/default/get_job.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -29,7 +29,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}".format(uuid=quote(str(uuid), safe=""),), + "url": "/jobs/{uuid}".format(uuid=quote_path_param(uuid),), } diff --git a/ionq_core/api/default/get_job_artifact.py b/ionq_core/api/default/get_job_artifact.py index 7999017..06bb14a 100644 --- a/ionq_core/api/default/get_job_artifact.py +++ b/ionq_core/api/default/get_job_artifact.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -28,7 +28,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}/artifacts/{artifact_id}".format(uuid=quote(str(uuid), safe=""),artifact_id=quote(str(artifact_id), safe=""),), + "url": "/jobs/{uuid}/artifacts/{artifact_id}".format(uuid=quote_path_param(uuid),artifact_id=quote_path_param(artifact_id),), } diff --git a/ionq_core/api/default/get_job_cost.py b/ionq_core/api/default/get_job_cost.py index e54ff07..131f05f 100644 --- a/ionq_core/api/default/get_job_cost.py +++ b/ionq_core/api/default/get_job_cost.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -29,7 +29,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}/cost".format(uuid=quote(str(uuid), safe=""),), + "url": "/jobs/{uuid}/cost".format(uuid=quote_path_param(uuid),), } diff --git a/ionq_core/api/default/get_job_probabilities.py b/ionq_core/api/default/get_job_probabilities.py index 94e8c5e..1c67f51 100644 --- a/ionq_core/api/default/get_job_probabilities.py +++ b/ionq_core/api/default/get_job_probabilities.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -38,7 +38,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}/results/probabilities".format(uuid=quote(str(uuid), safe=""),), + "url": "/jobs/{uuid}/results/probabilities".format(uuid=quote_path_param(uuid),), "params": params, } diff --git a/ionq_core/api/default/get_jobs.py b/ionq_core/api/default/get_jobs.py index 1744ac2..14da4e4 100644 --- a/ionq_core/api/default/get_jobs.py +++ b/ionq_core/api/default/get_jobs.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/get_session.py b/ionq_core/api/default/get_session.py index 10c258c..c961af1 100644 --- a/ionq_core/api/default/get_session.py +++ b/ionq_core/api/default/get_session.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -29,7 +29,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/sessions/{session_id}".format(session_id=quote(str(session_id), safe=""),), + "url": "/sessions/{session_id}".format(session_id=quote_path_param(session_id),), } diff --git a/ionq_core/api/default/get_session_jobs.py b/ionq_core/api/default/get_session_jobs.py index 13f675b..3910bca 100644 --- a/ionq_core/api/default/get_session_jobs.py +++ b/ionq_core/api/default/get_session_jobs.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -70,7 +70,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/sessions/{session_id_path}/jobs".format(session_id_path=quote(str(session_id_path), safe=""),), + "url": "/sessions/{session_id_path}/jobs".format(session_id_path=quote_path_param(session_id_path),), "params": params, } diff --git a/ionq_core/api/default/get_sessions.py b/ionq_core/api/default/get_sessions.py index 4e4f5ea..8841aa2 100644 --- a/ionq_core/api/default/get_sessions.py +++ b/ionq_core/api/default/get_sessions.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/api/default/get_variant_histogram.py b/ionq_core/api/default/get_variant_histogram.py index 16a0b9d..c404e99 100644 --- a/ionq_core/api/default/get_variant_histogram.py +++ b/ionq_core/api/default/get_variant_histogram.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -30,7 +30,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}/variants/{variant_id}/results/histogram".format(uuid=quote(str(uuid), safe=""),variant_id=quote(str(variant_id), safe=""),), + "url": "/jobs/{uuid}/variants/{variant_id}/results/histogram".format(uuid=quote_path_param(uuid),variant_id=quote_path_param(variant_id),), } diff --git a/ionq_core/api/default/get_variant_probabilities.py b/ionq_core/api/default/get_variant_probabilities.py index 7af55e0..6f1a6f7 100644 --- a/ionq_core/api/default/get_variant_probabilities.py +++ b/ionq_core/api/default/get_variant_probabilities.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -30,7 +30,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}/variants/{variant_id}/results/probabilities".format(uuid=quote(str(uuid), safe=""),variant_id=quote(str(variant_id), safe=""),), + "url": "/jobs/{uuid}/variants/{variant_id}/results/probabilities".format(uuid=quote_path_param(uuid),variant_id=quote_path_param(variant_id),), } diff --git a/ionq_core/api/default/get_variant_shots.py b/ionq_core/api/default/get_variant_shots.py index d48281c..0fa597a 100644 --- a/ionq_core/api/default/get_variant_shots.py +++ b/ionq_core/api/default/get_variant_shots.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -30,7 +30,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/jobs/{uuid}/variants/{variant_id}/results/shots".format(uuid=quote(str(uuid), safe=""),variant_id=quote(str(variant_id), safe=""),), + "url": "/jobs/{uuid}/variants/{variant_id}/results/shots".format(uuid=quote_path_param(uuid),variant_id=quote_path_param(variant_id),), } diff --git a/ionq_core/api/usage/get_usages.py b/ionq_core/api/usage/get_usages.py index 8d12949..7461c54 100644 --- a/ionq_core/api/usage/get_usages.py +++ b/ionq_core/api/usage/get_usages.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx @@ -56,7 +56,7 @@ def _get_kwargs( _kwargs: dict[str, Any] = { "method": "get", - "url": "/organizations/{organization_id}/usage".format(organization_id=quote(str(organization_id), safe=""),), + "url": "/organizations/{organization_id}/usage".format(organization_id=quote_path_param(organization_id),), "params": params, } diff --git a/ionq_core/api/whoami/get_whoami.py b/ionq_core/api/whoami/get_whoami.py index 8dc0f67..b05e7a3 100644 --- a/ionq_core/api/whoami/get_whoami.py +++ b/ionq_core/api/whoami/get_whoami.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any, cast -from urllib.parse import quote +from ..._url import quote_path_param import httpx diff --git a/ionq_core/client.py b/ionq_core/client.py index 35c8097..fb2466b 100644 --- a/ionq_core/client.py +++ b/ionq_core/client.py @@ -220,11 +220,11 @@ def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": def get_httpx_client(self) -> httpx.Client: """Get the underlying httpx.Client, constructing a new one if not previously set""" if self._client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + _auth_headers = {**self._headers, self.auth_header_name: f"{self.prefix} {self.token}" if self.prefix else self.token} self._client = httpx.Client( base_url=self._base_url, cookies=self._cookies, - headers=self._headers, + headers=_auth_headers, timeout=self._timeout, verify=self._verify_ssl, follow_redirects=self._follow_redirects, @@ -252,11 +252,11 @@ def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Authentica def get_async_httpx_client(self) -> httpx.AsyncClient: """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" if self._async_client is None: - self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + _auth_headers = {**self._headers, self.auth_header_name: f"{self.prefix} {self.token}" if self.prefix else self.token} self._async_client = httpx.AsyncClient( base_url=self._base_url, cookies=self._cookies, - headers=self._headers, + headers=_auth_headers, timeout=self._timeout, verify=self._verify_ssl, follow_redirects=self._follow_redirects, diff --git a/ionq_core/models/qctrl_qaoa_job_creation_payload_external_settings.py b/ionq_core/models/qctrl_qaoa_job_creation_payload_external_settings.py index 62b145d..8cbfec3 100644 --- a/ionq_core/models/qctrl_qaoa_job_creation_payload_external_settings.py +++ b/ionq_core/models/qctrl_qaoa_job_creation_payload_external_settings.py @@ -31,7 +31,7 @@ class QctrlQaoaJobCreationPayloadExternalSettings: external_organization (str | Unset): Optional unique slug for your target Q-CTRL organization """ - api_credentials: str + api_credentials: str = _attrs_field(repr=False) external_organization: str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) diff --git a/openapi-python-client-config.yaml b/openapi-python-client-config.yaml index 347043c..89906a0 100644 --- a/openapi-python-client-config.yaml +++ b/openapi-python-client-config.yaml @@ -4,6 +4,17 @@ literal_enums: true post_hooks: - "perl -pi -e 's/token: str\\K$/ = field(repr=False)/' client.py" + # Merge the Authorization header into a method-local dict instead of writing it + # into self._headers, which is repr-visible and caller-owned (the key would leak + # via repr(client) and into any other client sharing the headers dict). + - "perl -0777 -pi -e 's/self\\._headers\\[self\\.auth_header_name\\] = (.*?)\\n(.*?)headers=self\\._headers,/_auth_headers = {**self._headers, self.auth_header_name: $1}\\n$2headers=_auth_headers,/gs' client.py" + # api_credentials is a Q-CTRL API key; keep it out of the attrs-generated repr + # so logging/echoing a job payload cannot disclose it (SECURITY.md in-scope). + - "perl -pi -e 's/^ api_credentials: str\\K$/ = _attrs_field(repr=False)/' models/qctrl_qaoa_job_creation_payload_external_settings.py" + # Route path parameters through ionq_core._url.quote_path_param, which rejects + # "", ".", and "..": quote() leaves dots unencoded, so ".." would delete a fixed + # URL segment under RFC 3986 normalization (/sessions/../jobs -> /jobs). + - "perl -pi -e 's/^from urllib.parse import quote$/from ..._url import quote_path_param/; s/quote\\(str\\((\\w+)\\), safe=\"\"\\)/quote_path_param($1)/g' $(find api -name '*.py')" - "perl -0777 -pi -e '$y=(gmtime)[5]+1900;s/\\A(?!# SPDX-FileCopyrightText)/# SPDX-FileCopyrightText: $y IonQ, Inc.\\n# SPDX-License-Identifier: Apache-2.0\\n# \\@generated\\n\\n/' $(find . -name '*.py')" - "ruff check . --fix-only" - "ruff format ." diff --git a/tests/test_models.py b/tests/test_models.py index 1022f58..889ed5d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,10 @@ from ionq_core.models.backend import Backend from ionq_core.models.base_job import BaseJob from ionq_core.models.job_creation_response import JobCreationResponse +from ionq_core.models.qctrl_qaoa_job_creation_payload import QctrlQaoaJobCreationPayload +from ionq_core.models.qctrl_qaoa_job_creation_payload_external_settings import ( + QctrlQaoaJobCreationPayloadExternalSettings, +) from ionq_core.models.session import Session from ionq_core.models.whoami import Whoami @@ -131,3 +135,35 @@ def test_round_trip(self): result = Session.from_dict(SESSION_SAMPLE).to_dict() for key in ["id", "active", "status", "organization_id"]: assert result[key] == SESSION_SAMPLE[key] + + +class TestQctrlCredentialMasking: + """api_credentials is a Q-CTRL API key; repr()/str() of the payload models + must never disclose it (CWE-532), while the wire format stays intact.""" + + SECRET = "qctrl-secret-key-123" + + def _payload(self): + return QctrlQaoaJobCreationPayload.from_dict( + { + "backend": "simulator", + "type": "qctrl.qaoa.v1", + "input": {"problem_type": "maxcut", "problem": {}}, + "external_settings": {"api_credentials": self.SECRET}, + } + ) + + def test_external_settings_repr_masked(self): + settings = QctrlQaoaJobCreationPayloadExternalSettings(api_credentials=self.SECRET) + assert self.SECRET not in repr(settings) + assert self.SECRET not in str(settings) + + def test_containing_payload_repr_masked(self): + payload = self._payload() + assert self.SECRET not in repr(payload) + assert self.SECRET not in str(payload) + + def test_credential_round_trips_to_wire_format(self): + payload = self._payload() + assert payload.external_settings.api_credentials == self.SECRET + assert payload.to_dict()["external_settings"]["api_credentials"] == self.SECRET diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 0000000..2befa07 --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,70 @@ +import pytest + +from ionq_core._url import quote_path_param +from ionq_core.api.backends import get_backend +from ionq_core.api.default import ( + get_job_cost, + get_session, + get_session_jobs, + get_variant_shots, +) + + +class TestQuotePathParam: + @pytest.mark.parametrize("value", ["..", ".", ""]) + def test_rejects_segment_escaping_values(self, value): + with pytest.raises(ValueError, match="path parameter"): + quote_path_param(value) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("abc-123", "abc-123"), + ("a.b", "a.b"), # interior dots are legitimate + ("...", "..."), # only exact dot segments escape; three dots do not + ("a/../b", "a%2F..%2Fb"), # slashes cannot smuggle dot segments + ("..%2F", "..%252F"), # pre-encoded input is re-encoded, not decoded + ("café", "caf%C3%A9"), + ], + ) + def test_encodes_to_a_single_segment(self, value, expected): + assert quote_path_param(value) == expected + + def test_non_string_values_are_stringified(self): + assert quote_path_param(42) == "42" + + +class TestEndpointPathParamRejection: + """A traversal-shaped identifier must fail before any request is built: + quote() leaves "." unencoded, so ".." would otherwise collapse a fixed + path segment under RFC 3986 normalization (CWE-23), + e.g. /sessions/../jobs -> /jobs.""" + + @pytest.mark.parametrize("bad", ["..", ".", ""]) + def test_session_jobs_rejects(self, auth_client, bad): + with pytest.raises(ValueError, match="path parameter"): + get_session_jobs.sync_detailed(bad, client=auth_client) + + @pytest.mark.parametrize("bad", ["..", ".", ""]) + async def test_session_jobs_rejects_async(self, auth_client, bad): + with pytest.raises(ValueError, match="path parameter"): + await get_session_jobs.asyncio_detailed(bad, client=auth_client) + + @pytest.mark.parametrize( + "call", + [ + lambda c: get_variant_shots.sync_detailed("job-uuid", "..", client=c), + lambda c: get_session.sync_detailed("..", client=c), + lambda c: get_job_cost.sync_detailed("..", client=c), + lambda c: get_backend.sync_detailed("..", client=c), + ], + ) + def test_other_endpoints_reject(self, auth_client, call): + with pytest.raises(ValueError, match="path parameter"): + call(auth_client) + + def test_fixed_segments_survive_hostile_ids(self): + kwargs = get_session_jobs._get_kwargs("../jobs") + assert kwargs["url"] == "/sessions/..%2Fjobs/jobs" + kwargs = get_variant_shots._get_kwargs("job-1", "v.1") + assert kwargs["url"] == "/jobs/job-1/variants/v.1/results/shots" From 87efc8285f92672ed7fc47aa31826f4f4546001b Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:01 -0400 Subject: [PATCH 2/3] Bound server-controlled inputs and honor verify_ssl in the transport - Clamp Retry-After to [0, 300] seconds and discard non-finite values ("inf", "nan", "1e309") before exposing RateLimitError.retry_after, so a forged header cannot drive callers that sleep on the documented attribute into an unbounded wait or an OverflowError. (CWE-1284) - Read at most 64 KiB (decoded) of an error-response body via streaming instead of response.read(). httpx transparently applies the server-chosen Content-Encoding, so the unbounded read let a small gzip-bombed error body allocate memory proportional to its decompressed size. (CWE-409) - Stop retrying POSTs: the API has no idempotency keys, so replaying create_job/create_session/end_session after an ambiguous gateway 5xx could duplicate billable work. Idempotent methods retry as before. (CWE-837) - Apply verify_ssl to the transports that terminate connections. httpx ignores client-level verify whenever a custom transport is supplied, so every verify_ssl value passed to IonQClient (False, CA bundle path, or a pinned ssl.SSLContext) was silently discarded on both the sync and async paths. (CWE-295) - Abort pagination with IonQError when the server-supplied next cursor is empty or repeats. The cursor was the loop's only exit condition, so a hostile server could keep iter_jobs and friends issuing authenticated requests forever while the consumer blocked in next(). (CWE-835) --- ionq_core/_transport.py | 129 ++++++++++++++++++++++++--------- ionq_core/exceptions.py | 6 +- ionq_core/ionq_client.py | 18 +++-- ionq_core/pagination.py | 28 ++++++- tests/test_docs_consistency.py | 8 +- tests/test_ionq_client.py | 52 ++++++++++++- tests/test_pagination.py | 23 ++++++ tests/test_transport.py | 126 +++++++++++++++++++++++++++++++- 8 files changed, 338 insertions(+), 52 deletions(-) diff --git a/ionq_core/_transport.py b/ionq_core/_transport.py index 1550635..b3a8bca 100644 --- a/ionq_core/_transport.py +++ b/ionq_core/_transport.py @@ -3,16 +3,23 @@ """Transport layer: retry via httpx-retries, error raising for IonQ API responses. -This module provides the `ErrorRaisingTransport` that wraps httpx transports -to convert HTTP error responses and connection failures into structured -`IonQError` exceptions. The `build_transport` factory creates the default -transport stack: ``RetryTransport`` (from httpx-retries) wrapped by -``ErrorRaisingTransport``. - -The default retry configuration retries on status codes 429, 500, 502, 503, -and 520-529 with exponential backoff (factor 0.5, jitter 0.5, max 60s). +`ErrorRaisingTransport` converts HTTP error responses and connection failures +into structured `IonQError` exceptions; `build_transport` assembles the default +stack used by `IonQClient`. Idempotent methods are retried on status codes 429, +500, 502, 503, and 520-529 with exponential backoff (factor 0.5, jitter 0.5, +max 60s); POST is never retried because the API has no idempotency keys, so a +replay after an ambiguous 5xx could duplicate billable work. + +Error handling bounds what it trusts from the server: at most +`MAX_ERROR_BODY_BYTES` decoded bytes of an error body are read, and +``Retry-After`` is clamped to `MAX_RETRY_AFTER` seconds (non-finite values are +discarded) before being exposed on `RateLimitError.retry_after`. """ +import json +import math +import ssl + import httpx from httpx_retries import Retry, RetryTransport @@ -24,38 +31,86 @@ DEFAULT_MAX_RETRIES: int = 2 """Default number of retry attempts for transient errors.""" +MAX_RETRY_AFTER: float = 300.0 +"""Cap (seconds) on the server-supplied ``Retry-After``: callers are documented +to sleep on `RateLimitError.retry_after`, so a forged header must stay bounded.""" + +MAX_ERROR_BODY_BYTES: int = 64 * 1024 +"""Maximum decoded bytes read from an error response body.""" + + +def _read_error_body(response: httpx.Response) -> bytes: + """Read at most `MAX_ERROR_BODY_BYTES` decoded bytes of an error body. + + Streaming with a cap (instead of ``response.read()``) keeps a small + compressed body from inflating without limit in client memory: httpx + transparently applies whatever ``Content-Encoding`` the server chose. + """ + body = bytearray() + try: + for chunk in response.iter_bytes(): + body += chunk + if len(body) >= MAX_ERROR_BODY_BYTES: + break + finally: + response.close() + return bytes(body[:MAX_ERROR_BODY_BYTES]) + + +async def _aread_error_body(response: httpx.Response) -> bytes: + """Async variant of `_read_error_body`.""" + body = bytearray() + try: + async for chunk in response.aiter_bytes(): + body += chunk + if len(body) >= MAX_ERROR_BODY_BYTES: + break + finally: + await response.aclose() + return bytes(body[:MAX_ERROR_BODY_BYTES]) -def _raise_for_response(response: httpx.Response) -> None: + +def _raise_for_response(response: httpx.Response, content: bytes) -> None: try: - body: dict | str | None = response.json() + body: dict | str | None = json.loads(content) except (ValueError, UnicodeDecodeError): # json.JSONDecodeError subclasses ValueError; UnicodeDecodeError covers # bodies that aren't decodable in the declared (or guessed) encoding. - body = (response.text or "")[:500] or None + body = content.decode(response.encoding or "utf-8", errors="replace")[:500] or None message = (body.get("message") or body.get("error")) if isinstance(body, dict) else None try: - retry_after = max(0.0, float(response.headers["retry-after"])) + parsed = float(response.headers["retry-after"]) except (KeyError, ValueError): retry_after = None + else: + # float() accepts "inf" and overflow forms like "1e309"; a non-finite + # value is garbage, not advice, so treat it as absent. + retry_after = min(max(parsed, 0.0), MAX_RETRY_AFTER) if math.isfinite(parsed) else None raise_for_status(response.status_code, body, retry_after, message, request_id=response.headers.get("x-request-id")) class ErrorRaisingTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): """Wraps a transport to raise structured IonQ exceptions on error responses. - For HTTP 4xx/5xx responses, reads the response body and raises the - appropriate `APIError` subclass. For connection and timeout errors from - httpx, raises `APIConnectionError` or `APITimeoutError` respectively. + For HTTP 4xx/5xx responses, reads the response body (capped at + `MAX_ERROR_BODY_BYTES` decoded bytes) and raises the appropriate + `APIError` subclass. For connection and timeout errors from httpx, + raises `APIConnectionError` or `APITimeoutError` respectively. This class implements both sync and async transport interfaces so a single instance works with both ``httpx.Client`` and ``httpx.AsyncClient``. Args: - transport: The inner transport to wrap (typically a ``RetryTransport``). + transport: Inner transport for sync requests (typically a + ``RetryTransport``). + async_transport: Inner transport for async requests; defaults to + ``transport``. Separate inners let `build_transport` set TLS + options, which live on distinct sync/async httpx transports. """ - def __init__(self, transport) -> None: + def __init__(self, transport, async_transport=None) -> None: self._transport = transport + self._async_transport = async_transport if async_transport is not None else transport def handle_request(self, request: httpx.Request) -> httpx.Response: try: @@ -65,36 +120,35 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: except httpx.HTTPError as exc: raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc if response.status_code >= 400: - response.read() - _raise_for_response(response) + _raise_for_response(response, _read_error_body(response)) return response async def handle_async_request(self, request: httpx.Request) -> httpx.Response: try: - response = await self._transport.handle_async_request(request) + response = await self._async_transport.handle_async_request(request) except httpx.TimeoutException as exc: raise APITimeoutError(str(exc)) from exc except httpx.HTTPError as exc: raise APIConnectionError(f"{type(exc).__name__}: {exc}") from exc if response.status_code >= 400: - await response.aread() - _raise_for_response(response) + _raise_for_response(response, await _aread_error_body(response)) return response def close(self) -> None: self._transport.close() async def aclose(self) -> None: - await self._transport.aclose() + await self._async_transport.aclose() def build_transport( max_retries: int = DEFAULT_MAX_RETRIES, retryable_status_codes: frozenset[int] = RETRYABLE_STATUS_CODES, + verify: ssl.SSLContext | str | bool = True, ) -> ErrorRaisingTransport: """Build the default transport stack for `IonQClient`. - Creates a ``RetryTransport`` (from httpx-retries) with exponential + Creates ``RetryTransport``s (from httpx-retries) with exponential backoff, wrapped by `ErrorRaisingTransport` for structured error handling. Args: @@ -102,20 +156,25 @@ def build_transport( `DEFAULT_MAX_RETRIES` (2). retryable_status_codes: HTTP status codes that trigger a retry. Defaults to `RETRYABLE_STATUS_CODES`. + verify: TLS verification (``True``/``False``, a CA bundle path, or an + ``ssl.SSLContext``) applied to the underlying transports; httpx + ignores client-level ``verify`` when a custom transport is + supplied, so it must be configured here to take effect. Returns: A configured `ErrorRaisingTransport` ready to be passed to an - httpx client. + httpx client (sync or async). """ + retry = Retry( + total=max_retries, + backoff_factor=0.5, + backoff_jitter=0.5, + max_backoff_wait=60.0, + status_forcelist=retryable_status_codes, + # POST is deliberately not retryable: without idempotency keys, a replay + # after an ambiguous 5xx could duplicate billable jobs. + ) return ErrorRaisingTransport( - RetryTransport( - retry=Retry( - total=max_retries, - backoff_factor=0.5, - backoff_jitter=0.5, - max_backoff_wait=60.0, - status_forcelist=retryable_status_codes, - allowed_methods=Retry.RETRYABLE_METHODS | {"POST"}, - ) - ) + RetryTransport(transport=httpx.HTTPTransport(verify=verify), retry=retry), + RetryTransport(transport=httpx.AsyncHTTPTransport(verify=verify), retry=retry), ) diff --git a/ionq_core/exceptions.py b/ionq_core/exceptions.py index 41a76ab..5136971 100644 --- a/ionq_core/exceptions.py +++ b/ionq_core/exceptions.py @@ -137,7 +137,11 @@ class RateLimitError(APIError): Attributes: retry_after: Seconds to wait before retrying, or ``None`` if the - server did not include a ``Retry-After`` header. + server did not include a usable ``Retry-After`` header. The + default transport validates the header and caps the value at + 300 seconds (non-finite values are treated as absent), so a + hostile or buggy server cannot steer callers that sleep on this + attribute into an unbounded wait. """ def __init__( diff --git a/ionq_core/ionq_client.py b/ionq_core/ionq_client.py index 52d2286..d57a795 100644 --- a/ionq_core/ionq_client.py +++ b/ionq_core/ionq_client.py @@ -65,7 +65,10 @@ def IonQClient( extension: A `ClientExtension` bundle provided by a downstream SDK. Allows injecting hooks, custom headers, transport wrappers, and error mappers. - **kwargs: Passed through to `AuthenticatedClient`. + **kwargs: Passed through to `AuthenticatedClient`. ``verify_ssl`` + (``True``/``False``, a CA bundle path, or an ``ssl.SSLContext``) + is also applied to the underlying httpx transports on both the + sync and async paths. Returns: An `AuthenticatedClient` configured with retry transport and @@ -137,9 +140,12 @@ def IonQClient( headers = {**ext.default_headers, "User-Agent": user_agent} + # httpx ignores client-level `verify` when a custom transport is supplied, + # so the caller's verify_ssl must be plumbed into the transports here. sync_transport = async_transport = build_transport( effective_retries, ext.retryable_status_codes or RETRYABLE_STATUS_CODES, + verify=kwargs.get("verify_ssl", True), ) if ext.event_hooks or ext.error_mapper: @@ -172,18 +178,16 @@ def IonQClient( **kwargs, ) # `set_async_httpx_client` bypasses `AuthenticatedClient`'s lazy auth-header - # injection (see generated `client.py::get_async_httpx_client`), so we merge - # `Authorization` in manually here. The `_verify_ssl` / `_follow_redirects` - # fields are private on the generated `AuthenticatedClient` but are the only - # way to mirror the caller's choices onto the async transport; do not add a - # public accessor in the hand-written layer — they belong to generated code. + # injection, so `Authorization` is merged in manually. TLS is carried by + # `async_transport`. `_follow_redirects` is private on the generated client + # but is the only way to mirror the caller's choice here; do not add a + # public accessor in the hand-written layer. client.set_async_httpx_client( httpx.AsyncClient( base_url=base_url, headers={**headers, _AUTH_HEADER: f"{_AUTH_PREFIX} {key}"}, timeout=effective_timeout, transport=async_transport, - verify=client._verify_ssl, follow_redirects=client._follow_redirects, ) ) diff --git a/ionq_core/pagination.py b/ionq_core/pagination.py index 5469d7b..6433ba7 100644 --- a/ionq_core/pagination.py +++ b/ionq_core/pagination.py @@ -37,8 +37,17 @@ logger = logging.getLogger("ionq_core") +def _check_cursor(cursor: str, seen: set[str], label: str) -> None: + # The server-controlled cursor is the loop's only exit condition; an empty + # or repeating cursor must abort rather than iterate forever. + if not cursor or cursor in seen: + raise IonQError(f"Pagination cursor for {label} did not advance (next={cursor!r}); aborting") + seen.add(cursor) + + def _paginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) -> Iterator[Job]: kwargs["next_"] = UNSET + seen_cursors: set[str] = set() while True: response = fetch(*args, **kwargs) if response is None: @@ -46,12 +55,14 @@ def _paginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) yield from response.jobs if response.next_ is None: return + _check_cursor(response.next_, seen_cursors, label) kwargs["next_"] = response.next_ logger.debug("Fetching next page of %s (cursor=%s)", label, response.next_) async def _apaginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs: Any) -> AsyncIterator[Job]: kwargs["next_"] = UNSET + seen_cursors: set[str] = set() while True: response = await fetch(*args, **kwargs) if response is None: @@ -60,6 +71,7 @@ async def _apaginate(fetch: Callable[..., Any], label: str, *args: Any, **kwargs yield job if response.next_ is None: return + _check_cursor(response.next_, seen_cursors, label) kwargs["next_"] = response.next_ logger.debug("Fetching next page of %s (cursor=%s)", label, response.next_) @@ -88,7 +100,9 @@ def iter_jobs( Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, or if the + pagination cursor is empty or fails to advance (which would + otherwise loop forever). """ return _paginate( get_jobs.sync, @@ -125,7 +139,9 @@ def aiter_jobs( Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, or if the + pagination cursor is empty or fails to advance (which would + otherwise loop forever). """ return _apaginate( get_jobs.asyncio, @@ -164,7 +180,9 @@ def iter_session_jobs( Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, or if the + pagination cursor is empty or fails to advance (which would + otherwise loop forever). """ return _paginate( get_session_jobs.sync, @@ -201,7 +219,9 @@ def aiter_session_jobs( Individual job objects across all pages. Raises: - IonQError: If the API returns a ``None`` response. + IonQError: If the API returns a ``None`` response, or if the + pagination cursor is empty or fails to advance (which would + otherwise loop forever). """ return _apaginate( get_session_jobs.asyncio, diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index e071165..b90d518 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -9,7 +9,8 @@ import pytest from ionq_core import extensions, polling -from ionq_core._transport import DEFAULT_MAX_RETRIES +from ionq_core._transport import DEFAULT_MAX_RETRIES, MAX_RETRY_AFTER +from ionq_core.exceptions import RateLimitError from ionq_core.ionq_client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT from ionq_core.polling import _BACKOFF_FACTOR, _MAX_INTERVAL from ionq_core.polling import _DEFAULT_TIMEOUT as _POLL_DEFAULT_TIMEOUT @@ -67,6 +68,11 @@ def test_polling_docstring_pins(fn, needle): assert needle in (fn.__doc__ or ""), f"{needle!r} missing from {fn.__name__}" +def test_rate_limit_cap_docstring_pin(): + """The Retry-After cap documented on RateLimitError tracks MAX_RETRY_AFTER.""" + assert f"{int(MAX_RETRY_AFTER)} seconds" in (RateLimitError.__doc__ or "") + + def test_pyproject_floor_matches_ci_matrix(): assert _python_floor() == min(_ci_python_versions()) diff --git a/tests/test_ionq_client.py b/tests/test_ionq_client.py index 9190679..98b4174 100644 --- a/tests/test_ionq_client.py +++ b/tests/test_ionq_client.py @@ -1,9 +1,10 @@ +import ssl import warnings import httpx import pytest -from ionq_core import IonQClient, __version__ +from ionq_core import AuthenticatedClient, IonQClient, __version__ from ionq_core._transport import ErrorRaisingTransport @@ -76,6 +77,25 @@ def test_version_exposed(self): def test_token_not_in_repr(self): c = IonQClient(api_key="super-secret-key") assert "super-secret-key" not in repr(c) + # the credential must also stay out of repr-visible state after the + # httpx clients (and their auth headers) have been built + c.get_httpx_client() + c.get_async_httpx_client() + assert "super-secret-key" not in repr(c) + + def test_caller_headers_dict_not_mutated(self): + # A headers dict passed by the caller is caller-owned; injecting the + # Authorization value into it would leak the key to any other client + # sharing that dict (and into repr). + shared = {"X-Custom": "1"} + c = AuthenticatedClient(base_url="https://api.invalid", token="secret-token", prefix="apiKey", headers=shared) + c.get_httpx_client() + c.get_async_httpx_client() + assert shared == {"X-Custom": "1"} + assert "secret-token" not in repr(c) + assert c.get_httpx_client().headers["Authorization"] == "apiKey secret-token" + assert c.get_async_httpx_client().headers["Authorization"] == "apiKey secret-token" + assert c.get_httpx_client().headers["X-Custom"] == "1" def test_http_base_url_warns(self): with pytest.warns(UserWarning, match="does not use HTTPS"): @@ -97,3 +117,33 @@ def test_async_client_inherits_follow_redirects(self): def test_async_client_default_no_follow_redirects(self): ac = IonQClient(api_key="key").get_async_httpx_client() assert ac.follow_redirects is False + + +class TestIonQClientTls: + """verify_ssl must reach the connection-terminating transports (CWE-295). + + httpx ignores client-level ``verify`` whenever a custom transport is + supplied, so these tests assert on the SSL context of the innermost + httpx transports actually used by IonQClient, on both paths. + """ + + @staticmethod + def _ssl_contexts(c): + sync_ctx = c.get_httpx_client()._transport._transport._sync_transport._pool._ssl_context + async_ctx = c.get_async_httpx_client()._transport._async_transport._async_transport._pool._ssl_context + return sync_ctx, async_ctx + + def test_default_verifies_certificates(self): + for ctx in self._ssl_contexts(IonQClient(api_key="key")): + assert ctx.verify_mode == ssl.CERT_REQUIRED + + def test_custom_ssl_context_reaches_both_transports(self): + pinned = ssl.create_default_context() + for ctx in self._ssl_contexts(IonQClient(api_key="key", verify_ssl=pinned)): + assert ctx is pinned + + def test_verify_ssl_false_disables_verification(self): + with pytest.warns(UserWarning, match="verify_ssl=False"): + c = IonQClient(api_key="key", verify_ssl=False) + for ctx in self._ssl_contexts(c): + assert ctx.verify_mode == ssl.CERT_NONE diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 6234389..2fc13ea 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -83,6 +83,29 @@ def test_multiple_pages(self, httpx_mock, auth_client): assert [j.id for j in jobs] == ["j1", "j2"] +class TestCursorGuard: + """The next cursor is server-controlled and the loop's only exit; a cursor + that repeats or is empty must abort instead of iterating forever (CWE-835).""" + + def test_sync_repeated_cursor_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + httpx_mock.add_response(json=_jobs_page(["j2"], next_cursor="c1")) + with pytest.raises(IonQError, match="did not advance"): + list(iter_jobs(auth_client)) + + async def test_async_repeated_cursor_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="c1")) + httpx_mock.add_response(json=_jobs_page(["j2"], next_cursor="c1")) + with pytest.raises(IonQError, match="did not advance"): + async for _ in aiter_jobs(auth_client): + pass + + def test_sync_empty_cursor_raises(self, httpx_mock, auth_client): + httpx_mock.add_response(json=_jobs_page(["j1"], next_cursor="")) + with pytest.raises(IonQError, match="did not advance"): + list(iter_jobs(auth_client)) + + class TestAiterSessionJobs: async def test_single_page(self, httpx_mock, auth_client): httpx_mock.add_response(json=_jobs_page(["j1"])) diff --git a/tests/test_transport.py b/tests/test_transport.py index c3aa02f..f31a6ca 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,7 +1,14 @@ +import ssl + import httpx import pytest -from ionq_core._transport import ErrorRaisingTransport, build_transport +from ionq_core._transport import ( + MAX_ERROR_BODY_BYTES, + MAX_RETRY_AFTER, + ErrorRaisingTransport, + build_transport, +) from ionq_core.exceptions import ( APIConnectionError, APITimeoutError, @@ -52,10 +59,39 @@ class TestBuildTransport: def test_returns_error_raising(self): assert isinstance(build_transport(), ErrorRaisingTransport) - def test_retries_post_requests(self): + def test_does_not_retry_post_requests(self): + # POSTs submit billable jobs/sessions and the API has no idempotency + # keys, so a retry after an ambiguous 5xx could duplicate work. transport = build_transport() retry = transport._transport.retry - assert "POST" in retry.allowed_methods + assert "POST" not in retry.allowed_methods + assert "GET" in retry.allowed_methods + + def test_sync_and_async_share_retry_config(self): + transport = build_transport(max_retries=5) + assert transport._transport.retry.total == 5 + assert transport._async_transport.retry.total == 5 + + +class TestBuildTransportTls: + @staticmethod + def _ssl_contexts(transport): + sync_ctx = transport._transport._sync_transport._pool._ssl_context + async_ctx = transport._async_transport._async_transport._pool._ssl_context + return sync_ctx, async_ctx + + def test_default_verifies_certificates(self): + for ctx in self._ssl_contexts(build_transport()): + assert ctx.verify_mode == ssl.CERT_REQUIRED + + def test_verify_false_disables_verification(self): + for ctx in self._ssl_contexts(build_transport(verify=False)): + assert ctx.verify_mode == ssl.CERT_NONE + + def test_custom_ssl_context_used_verbatim(self): + pinned = ssl.create_default_context() + for ctx in self._ssl_contexts(build_transport(verify=pinned)): + assert ctx is pinned class TestErrorRaisingTransportSync: @@ -185,3 +221,87 @@ def test_unparseable_retry_after(self): with pytest.raises(RateLimitError) as exc_info: transport.handle_request(_req()) assert exc_info.value.retry_after is None + + @pytest.mark.parametrize( + ("header", "expected"), + [ + ("9000000000", MAX_RETRY_AFTER), # absurdly large finite values are capped + (str(MAX_RETRY_AFTER + 1), MAX_RETRY_AFTER), + ("-3", 0.0), # negative values are floored + ("inf", None), # non-finite values are garbage, not advice + ("1e309", None), # overflows float() to +inf + ("nan", None), + ], + ) + def test_retry_after_bounded(self, header, expected): + # Callers are documented to sleep on retry_after, so a forged header + # must never produce an unbounded or non-finite wait (CWE-1284). + transport, _ = _wrap([_resp(429, headers={"retry-after": header})]) + with pytest.raises(RateLimitError) as exc_info: + transport.handle_request(_req()) + assert exc_info.value.retry_after == expected + + +class _CountingStream(httpx.SyncByteStream): + """A large streamed body that records how many chunks were consumed.""" + + def __init__(self, chunk_size=16384, chunks=1000): + self.chunk = b"x" * chunk_size + self.chunks = chunks + self.consumed = 0 + + def __iter__(self): + for _ in range(self.chunks): + self.consumed += 1 + yield self.chunk + + +class _CountingAsyncStream(httpx.AsyncByteStream): + def __init__(self, chunk_size=16384, chunks=1000): + self.chunk = b"x" * chunk_size + self.chunks = chunks + self.consumed = 0 + + async def __aiter__(self): + for _ in range(self.chunks): + self.consumed += 1 + yield self.chunk + + +class TestErrorBodyCap: + """Error bodies are server-controlled; only a bounded prefix may be read (CWE-409).""" + + # chunks needed to reach the cap, +1 for iteration slack + _MAX_CHUNKS = MAX_ERROR_BODY_BYTES // 16384 + 1 + + def test_sync_read_stops_at_cap(self): + stream = _CountingStream() + transport, _ = _wrap([httpx.Response(400, stream=stream)]) + with pytest.raises(BadRequestError) as exc_info: + transport.handle_request(_req()) + assert stream.consumed <= self._MAX_CHUNKS + assert len(exc_info.value.body) <= 500 + + async def test_async_read_stops_at_cap(self): + stream = _CountingAsyncStream() + transport, _ = _wrap([httpx.Response(400, stream=stream)]) + with pytest.raises(BadRequestError) as exc_info: + await transport.handle_async_request(_req()) + assert stream.consumed <= self._MAX_CHUNKS + assert len(exc_info.value.body) <= 500 + + def test_plain_text_body_truncated_to_500(self): + transport, _ = _wrap([httpx.Response(400, content=b"e" * 600)]) + with pytest.raises(BadRequestError) as exc_info: + transport.handle_request(_req()) + assert exc_info.value.body == "e" * 500 + + def test_json_body_exceeding_cap_degrades_to_text(self): + # Truncation invalidates the JSON, so the capped prefix is surfaced as + # text instead of being parsed into a second full-size structure. + big = b'{"message": "' + b"a" * (MAX_ERROR_BODY_BYTES + 1000) + b'"}' + transport, _ = _wrap([httpx.Response(400, content=big)]) + with pytest.raises(BadRequestError) as exc_info: + transport.handle_request(_req()) + assert isinstance(exc_info.value.body, str) + assert len(exc_info.value.body) <= 500 From 0b189432f7b01a3400f453aa42c8e9356ff69251 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:52:01 -0400 Subject: [PATCH 3/3] Pin the spec-drift check to a constant upstream URL The workflow derived its fetch target from servers[0].url inside the very openapi.json it exists to check, so a tampered vendored spec could point the drift comparison at a mirror serving an identical copy and suppress its own detection; the jq-derived value was also written unsanitized to GITHUB_ENV. The URL is now a workflow-defined constant (matching CONTRIBUTING.md) and nothing is written to GITHUB_ENV. (CWE-807) Also documents all fixes in CHANGELOG and updates CONTRIBUTING's description of the post-generation hooks. --- .github/workflows/spec-drift.yml | 11 ++++++----- CHANGELOG.md | 12 ++++++++++++ CONTRIBUTING.md | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/spec-drift.yml b/.github/workflows/spec-drift.yml index 6552b76..fe76f0e 100644 --- a/.github/workflows/spec-drift.yml +++ b/.github/workflows/spec-drift.yml @@ -13,15 +13,16 @@ jobs: check: runs-on: ubuntu-latest timeout-minutes: 5 + env: + # Pinned (keep in sync with CONTRIBUTING.md); never derived from openapi.json, + # so a tampered vendored spec cannot point the check at a mirror that hides it. + SPEC_URL: https://api.ionq.co/v0.4/api-docs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Fetch latest spec - run: | - BASE_URL=$(jq -r '.servers[0].url' openapi.json) - echo "BASE_URL=${BASE_URL}" >> "$GITHUB_ENV" - curl -sf "${BASE_URL}/api-docs" -o /tmp/latest-spec.json + run: curl -sf "$SPEC_URL" -o /tmp/latest-spec.json - name: Check for drift id: drift run: | @@ -35,7 +36,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | { - echo "The spec at ${BASE_URL}/api-docs has diverged from the vendored openapi.json. Fetch the new spec and regenerate the client." + echo "The spec at ${SPEC_URL} has diverged from the vendored openapi.json. Fetch the new spec and regenerate the client." printf '\n
Diff (sorted, pretty-printed JSON)\n\n```diff\n' head -c 60000 /tmp/spec.diff [[ $(wc -c < /tmp/spec.diff) -gt 60000 ]] && printf '\n... (truncated)\n' diff --git a/CHANGELOG.md b/CHANGELOG.md index 706ed79..6a185b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Security + +- Generated endpoints now reject the path-parameter values `""`, `"."`, and `".."` (raising `ValueError`) before any request is built. `urllib.parse.quote` never encodes dots, so an attacker-supplied identifier like `".."` previously survived into the URL and deleted a fixed path segment under RFC 3986 normalization (e.g. `/sessions/../jobs` -> `/jobs`), redirecting session-scoped reads to account-wide ones. +- `QctrlQaoaJobCreationPayloadExternalSettings.api_credentials` (a Q-CTRL API key) is now excluded from the attrs-generated `repr`, so logging or echoing a job payload can no longer disclose it. `to_dict()` and the wire format are unchanged. +- `AuthenticatedClient` no longer writes the `Authorization` value into its repr-visible, caller-owned headers dict when the httpx clients are built; the credential now lives only on the httpx clients themselves. `repr(client)` stays token-free after use, and a headers dict shared with other clients is no longer contaminated with the key. +- `RateLimitError.retry_after` is now validated by the default transport: values are clamped to at most 300 seconds and non-finite values (`inf`, `nan`, overflowing forms like `1e309`) are treated as absent, so a forged `Retry-After` header cannot drive callers that sleep on it into an unbounded wait or an `OverflowError`. +- The default transport now reads at most 64 KiB (decoded) of an error-response body instead of materializing the whole, transparently decompressed body, preventing memory exhaustion from compression-bomb error responses. +- `verify_ssl` passed to `IonQClient` is now applied to the underlying sync and async httpx transports. Previously the value was silently ignored (httpx disregards client-level `verify` when a custom transport is supplied), so custom CA bundles and pinned `ssl.SSLContext` objects had no effect and `verify_ssl=False` did not actually disable verification. +- The pagination helpers (`iter_jobs`, `aiter_jobs`, `iter_session_jobs`, `aiter_session_jobs`) now raise `IonQError` when the server-supplied `next` cursor is empty or repeats a previously seen cursor, instead of issuing authenticated requests in an unbounded loop. +- The weekly spec-drift workflow fetches the upstream spec from a URL pinned in the workflow instead of one derived from the vendored `openapi.json`, so a tampered spec can no longer point the drift check at a mirror that hides the tampering. + ### Added - `QctrlQaoaJobCreationPayload` and `QctrlQaoaJobInput` for submitting Q-CTRL QAOA maxcut combinatorial-optimization jobs via `create_job`. The `create_job` body union now also accepts `QctrlQaoaJobCreationPayload`. @@ -17,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- POST requests are no longer retried automatically by the default transport. The API has no idempotency-key mechanism, so replaying `create_job` / `create_session` / `end_session` after an ambiguous gateway 5xx could duplicate billable work; idempotent methods retry as before. Callers that want POST retries must supply their own transport and handle deduplication. - `NativeCircuitInput.qubits` and `JsonMultiCircuitInput.qubits` are now `int | Unset` (previously `float | Unset`), matching upstream's tightening to `format: int32, minimum: 1`. `QisCircuitInput.qubits` already had this type locally via the OpenAPI overlay; that overlay action has been removed now that upstream is correct natively. - Regenerated with `openapi-python-client` 0.29.0. Generated models now parse timestamps with the standard library (`datetime.fromisoformat`) instead of `dateutil.parser.isoparse`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a175603..fbd9201 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ uv run openapi-python-client generate \ --overwrite ``` -Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` from `repr`, and run `ruff` fix-and-format. +Keep this command in sync with the [`generated`](.github/workflows/generated.yml) workflow, which runs the same invocation on every PR. Post-generation hooks (in `openapi-python-client-config.yaml`) inject SPDX/`@generated` headers, hide `AuthenticatedClient.token` and the Q-CTRL `api_credentials` field from `repr`, keep the `Authorization` header out of repr-visible client state, route path parameters through `ionq_core._url.quote_path_param`, and run `ruff` fix-and-format. Commit the regenerated files alongside the spec or template change that caused them. Spec drift is checked weekly by [`spec-drift.yml`](.github/workflows/spec-drift.yml), which opens an issue if `openapi.json` falls behind upstream.