Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- Fixed a path traversal issue where a resource name or id containing `../` was resolved while the request was prepared, retargeting the call at a different API endpoint under the SDK's own credentials (for example `containers.delete_deployment('../../v1/instances')` issued `DELETE /v1/instances`). This also prevents a name from injecting query parameters, such as overriding the `force` flag of `containers.delete_secret`.

Caller-supplied path values are no longer interpolated into the request path. `HTTPClient.get/post/put/patch/delete` now accept a keyword-only `path_params` mapping whose values are validated as a single path segment before substitution, and all service modules pass names and ids that way. This covers `instances.is_available()` and `clusters.is_available()`, where the affected value was the `instance_type`/`cluster_type`. As a backstop, `HTTPClient` refuses to send a request whose path would escape the API base path.

`InferenceClient` paths are validated too: `path` may still span several segments, but it can no longer walk out of the deployment's base url.

### Added

- `LongTermService` with `get_cluster_periods()` and `get_instance_periods()` methods
Expand All @@ -16,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking:** a resource name or id used in a request path must now match `[A-Za-z0-9._~-]+` (the RFC 3986 unreserved set). Anything else raises `ValueError` instead of being percent-encoded and sent — including `/`, `\`, `%`, spaces, `?`, `#` and non-ASCII characters. Every name the API takes in a path position is a slug, an id or a machine type (`my-deployment`, `1A100.22V`, a UUID), so ordinary calls are unaffected. If you have a name that was already URL-encoded, pass the raw name: `get_deployment_by_name('my%20deployment')` now raises rather than looking up a deployment literally named `my%20deployment`.
- **Breaking:** a relative path segment (`.` or `..`), an empty value, or `None` raises `ValueError`. Encoding is not sufficient for these: `%2E` is decoded back to `.` before the request is sent.
- **Breaking:** a path value that is not a `str`, `int` or `UUID` now raises `ValueError` rather than being coerced with `str()` into a nonsense path segment.
- **Breaking:** `InferenceClient` now requires `endpoint_base_url` to include the deployment path. `InferenceClient(key, 'https://containers.example.com')` previously produced a `base_domain` of `https:/`, sending async status and result requests to a host named `status`/`result` while still carrying the inference key.
- Refactored `Image` model to use `@dataclass` and `@dataclass_json` for consistency with `Instance` and `Volume`
- License changed from MIT to Apache 2.0

Expand Down
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,44 @@ verda/<service>/
- `__init__.py` files do NOT have the Apache 2.0 license header. All other `.py` files do.
- Implementation files are prefixed with `_` (e.g., `_instances.py`, `_volumes.py`).

## Making API requests

Service modules call the shared `HTTPClient` (`verda/http_client/`), which exposes `get`, `post`, `put`, `patch`, and `delete`.

**Never interpolate a caller-supplied value into the request path.** Resource names and IDs arrive from application input. A value containing `../` is resolved while the request is prepared and retargets the call at a different API endpoint under the SDK's own credentials; a value containing `?` injects query parameters.

Pass such values as `path_params`. The client validates each one as exactly one path segment before substituting it:

```python
# correct
response = self.client.get(
CONTAINER_DEPLOYMENTS_ENDPOINT + '/{deployment_name}/status',
path_params={'deployment_name': deployment_name},
)

# wrong -- the name can escape its path segment
response = self.client.get(f'{CONTAINER_DEPLOYMENTS_ENDPOINT}/{deployment_name}/status')
```

- The url is a template: a trusted endpoint constant concatenated with a literal containing `{name}` placeholders. Keep it a plain string, never an f-string, so a value cannot be interpolated by accident.
- Name each placeholder after the parameter it carries (`{deployment_name}`, `{id}`, `{job_name}`).
- `path_params` goes last in the call, after any positional `json` body or `params` query dict.
- Paths with no caller input need no `path_params` (e.g. `self.client.get(INSTANCES_ENDPOINT)`).

Endpoint paths belong in a module-level `<NAME>_ENDPOINT` constant, never an inline string literal — a literal hides the call site from the greps and audits used to check this rule.

A path value must match `[A-Za-z0-9._~-]+` (the RFC 3986 unreserved set). Anything else raises `ValueError`, as do `.`, `..`, empty/`None`, a non-`str`/`int`/`UUID` type, and any template/`path_params` mismatch. `int` and `UUID` are coerced with `str()`.

Reject, do not encode: `requests` decodes `%2E` back to `.` before sending, and an intermediary that unescapes `%2F` before normalising the path restores a traversal. Do not encode a rejected name at the call site — pass the raw name.

`tests/unit_tests/test_path_traversal.py` enforces its own completeness: it reads the source for methods passing `path_params` and fails if any is absent from its `_call_sites` table.

All five verbs delegate to a single private `_request`, which is where the url is built and validated. Add new verbs by delegating to it, never by calling `requests` directly.

The check is `_encode_path_segment` in `verda/http_client/_http_client.py`; `path_params` is the only supported way to put a caller-supplied value into a path. `_add_base_url` re-asserts the same allowlist on the finished path, as a backstop for a call site that skips `path_params`.

`verda.helpers.has_relative_path_segment` strips the query string and decodes escapes and encoded separators. It is for `InferenceClient` only, whose `path` spans several segments and may carry a query string. Do not use it in the http client, and do not re-implement it.

## Code style

### Formatting and linting
Expand Down Expand Up @@ -143,6 +181,7 @@ Ensure two blank lines between the header and the first top-level `class`/`def`
- **API error tests:** use `pytest.raises(APIException)` and verify `.code` and `.message`
- **Request matching:** use `responses.add()` with `matchers.json_params_matcher()` to verify request payloads
- **Test data:** define constants and mock payloads as module-level variables at top of test file
- **Path traversal regression:** `tests/unit_tests/test_path_traversal.py` drives every method that takes a resource name or id against hostile values. When adding such a method, add it to the `_call_sites` table there.

## Git and branching

Expand Down
Loading
Loading