diff --git a/SECURITY.md b/SECURITY.md index 43233d12..0d2e1532 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -80,7 +80,7 @@ We follow coordinated disclosure: ┌─────────────────────────────────────────────────────────────────┐ │ cachekit Python SDK │ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │ -│ │ @cache │ │ @cache │ │ Redis/CachekitIO │ │ +│ │ @cache │ │ @cache │ │ Redis/CacheKit Cloud │ │ │ │ Decorator │ │ .secure │ │ Backend │ │ │ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │ │ │ │ │ │ @@ -205,11 +205,11 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ ### Cache Key Redaction in Logs (CWE-532) -Cache keys can embed caller-supplied tenant/user identifiers, so **the SDK's own loggers** (`cachekit.*`) never emit them verbatim ([CWE-532][cwe-532]). Every cachekit log path — decorator error handling (structured and backwards-compat), cache-operation logs, and SWR/TTL-refresh logs — replaces the key with a fixed-length blake2b digest (``), keeping log lines correlatable without leaking the key. The SWR refresh WARNINGs name the decorated function by the same digest of its `module.qualname`, because a dynamically created function's `__qualname__` can carry caller data, and SWR background threads have static names, so a `%(threadName)s` log format leaks no function name either. Error paths are covered centrally at the shared error sink (`FeatureOrchestrator.handle_cache_error` / `log_cache_operation`), so new call sites are redacted by construction. Both structured cache-operation sinks (`FeatureOrchestrator.log_cache_operation`, `StructuredLogger.cache_operation`) also sanitise an exception passed as `error=` themselves — pass the exception object, never `str(e)`, which is emitted as-is. `BackendError` redacts the key in its formatted text (`str(e)` carries `key=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). Its free-form `message` is caller-supplied and third-party exception text (a redis `ResponseError` naming the key, a pymemcache illegal-input error echoing it) has unknown provenance — so **no cachekit log line renders `str(e)`**. Every logging call that mentions an exception goes through `redact_error_for_log`, which emits only the exception type plus, for `BackendError`, its `BackendErrorType` classification; the full exception stays on the object (`original_exception`, `.message`) for programmatic access. Operators lose the provider's message text in the log line and keep it on the exception, except for a CachekitIO transport failure or a Redis failure (see below). An architecture test (`tests/unit/test_log_redaction_architecture.py`) walks every logging call in the package — `logger.*()`, `get_logger().*()`, `getattr(logger, level)()` — and fails CI if a key-shaped value reaches one unredacted in the message, `%s` arguments, or `extra=`; if an exception — any name bound by `except ... as`, a conventional name (`e`, `exc`, `err`, `error`, `*_err`), or an attribute of one — reaches one outside `redact_error_for_log`; or if a call emits a traceback (`logger.exception`, `exc_info=`). The guarantee does not depend on the next contributor remembering it. It is flow-insensitive: build log lines inline, not via a pre-formatted variable, and bind exceptions with `except ... as` or a conventional name (an `Exception`-typed parameter called `failure` is invisible to it), or the guard cannot see them. +Cache keys can embed caller-supplied tenant/user identifiers, so **the SDK's own loggers** (`cachekit.*`) never emit them verbatim ([CWE-532][cwe-532]). Every cachekit log path — decorator error handling (structured and backwards-compat), cache-operation logs, and SWR/TTL-refresh logs — replaces the key with a fixed-length blake2b digest (``), keeping log lines correlatable without leaking the key. The SWR refresh WARNINGs name the decorated function by the same digest of its `module.qualname`, because a dynamically created function's `__qualname__` can carry caller data, and SWR background threads have static names, so a `%(threadName)s` log format leaks no function name either. Error paths are covered centrally at the shared error sink (`FeatureOrchestrator.handle_cache_error` / `log_cache_operation`), so new call sites are redacted by construction. Both structured cache-operation sinks (`FeatureOrchestrator.log_cache_operation`, `StructuredLogger.cache_operation`) also sanitise an exception passed as `error=` themselves — pass the exception object, never `str(e)`, which is emitted as-is. `BackendError` redacts the key in its formatted text (`str(e)` carries `key=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). Its free-form `message` is caller-supplied and third-party exception text (a redis `ResponseError` naming the key, a pymemcache illegal-input error echoing it) has unknown provenance — so **no cachekit log line renders `str(e)`**. Every logging call that mentions an exception goes through `redact_error_for_log`, which emits only the exception type plus, for `BackendError`, its `BackendErrorType` classification; the full exception stays on the object (`original_exception`, `.message`) for programmatic access. Operators lose the provider's message text in the log line and keep it on the exception, except for a CacheKit Cloud transport failure or a Redis failure (see below). An architecture test (`tests/unit/test_log_redaction_architecture.py`) walks every logging call in the package — `logger.*()`, `get_logger().*()`, `getattr(logger, level)()` — and fails CI if a key-shaped value reaches one unredacted in the message, `%s` arguments, or `extra=`; if an exception — any name bound by `except ... as`, a conventional name (`e`, `exc`, `err`, `error`, `*_err`), or an attribute of one — reaches one outside `redact_error_for_log`; or if a call emits a traceback (`logger.exception`, `exc_info=`). The guarantee does not depend on the next contributor remembering it. It is flow-insensitive: build log lines inline, not via a pre-formatted variable, and bind exceptions with `except ... as` or a conventional name (an `Exception`-typed parameter called `failure` is invisible to it), or the guard cannot see them. -**Scope — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key. The CachekitIO backend keeps no urllib3 exception. A request that fails in transport raises a `BackendError` whose `original_exception` and `__cause__` are an `HTTPTransportError` naming only urllib3's exception class, with no traceback. It is raised outside the `except` block, so its `__context__` is never urllib3's exception. urllib3's exception is dropped because its traceback runs through urllib3's request frames, whose locals hold the request headers, the `Authorization: Bearer` API key included, and an error tracker that captures frame locals would send them. A request answered with a status other than 2xx keeps an `HTTPStatusError` holding the response, which reaches no request frame either. So no local in a frame on a failed CachekitIO request's `BackendError`, or on any exception its `__cause__`, `__context__` or `original_exception` reaches, holds the API key as an error tracker's frame-locals capture serialises it: strings and bytes by content, dicts, lists, tuples and sets item by item, and any other object by its `repr` (`tests/unit/backends/test_cachekitio_transport_error_frames.py`). An interrupt raised while a sync request is in flight (`KeyboardInterrupt`, the `SystemExit` a worker-timeout signal raises, `gevent.Timeout`) is not a failure cachekit classifies, and it propagates as itself with its full traceback. Before the interrupt leaves the backend, the locals of every finished frame on its traceback are cleared, and so are those on the traceback of each exception it chained during the request. The backend's own frame, still executing, holds no key. So the interrupt reaches no frame holding the API key either, and each frame keeps its file and line (same test file). An async request runs on a worker thread, which signals do not interrupt. The Redis backends keep no redis-py exception either. A failed operation on a `RedisBackend`, or on a backend from `RedisBackendProvider` or env auto-detection, sync or async, and a failed `RedisBackendProvider` init ping, raise a `BackendError` whose `original_exception` and `__cause__` are a `RedisClientError` naming only redis-py's exception class (`exc_type`), with no traceback, raised outside the `except` block, so its `__context__` is never redis-py's exception. redis-py's exception is dropped because its traceback runs through redis-py's frames, whose locals hold the client, its connection pool and the AUTH arguments, and the client's and the pool's reprs list every connection argument, the password included. The cachekit frames that raise the error hold no client, pool, pipeline or redis-py exception in a local, `RedisBackendProvider` holds the URL it is given only as a `SecretStr`, and a `RedisBackendConfig` leaves `redis_url` out of its repr. An exception your own code raises inside `acquire_lock` or `with_timeout` is kept whole as `original_exception`, because it is yours. A lock acquisition cancelled while its attempt is in flight re-raises the cancel once the attempt has finished, and no frame on that `CancelledError` keeps the attempt or its redis-py exception either. So no local in a frame on a failed Redis operation's `BackendError`, or on any exception its `__cause__`, `__context__` or `original_exception` reaches, holds the Redis password as an error tracker's frame-locals capture serialises it (`tests/unit/backends/test_redis_error_frames.py`). An interrupt raised while a Redis command is in flight, in any of those operations, a health check, the invalidation listener's start or an invalidation's announcement (both run inside a cache operation), is handled as on a sync CachekitIO request: it propagates as itself, the locals of every finished frame on its traceback, redis-py's included, and on the traceback of each exception it chained during the operation are cleared, each frame keeping its file and line, and the backend's own frame, still executing, holds no client, pool or pipeline. So the interrupt reaches no frame holding the Redis password either (same test file). An async operation runs redis-py on an executor thread, which signals do not interrupt; an interrupt raised there (a `gevent.Timeout`) can reach the caller while the thread is still inside the executor frame that caught it, which is not cleared, but that frame holds only the work item, whose repr shows no client, and the interrupt. An interrupt your own code raises inside an `acquire_lock` block is cleared the same way. cachekit's own frames on either backend's error still hold the cache key, as below. cachekit itself never renders a provider exception's text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, pass it a freshly constructed exception carrying only `redact_error_for_log(e)`, handed over explicitly (`capture_exception(RuntimeError(redact_error_for_log(e)))`) rather than raised inside the `except` block: raising it there sets its `__context__` to `e` and re-links the whole chain, and `raise … from None` does not undo that — it only sets `__suppress_context__`, which hides the chain from `traceback` but not from an SDK that walks exception attributes. Failing that, clear **all three** references to the provider exception on `e` first — `__cause__`, `__context__`, and `BackendError.original_exception` — **and `e.__traceback__` and `e.key` with them**. The Memcached and File backends raise the classified `BackendError` from inside the `except` block that caught the provider exception, so Python sets `__context__` as well as `__cause__`; clearing `__cause__` alone hides the provider text from `traceback` but leaves it reachable to an SDK that walks exception attributes, and `original_exception` is a third reference. `__traceback__` leaks by a different route than the other three: they carry the provider exception's *text*, whereas the traceback carries the backend *frame* that raised — and that frame's locals still hold the raw key (`MemcachedBackend.get` raises `classify_memcached_error(exc, operation="get", key=key) from exc`), so a tracker that captures frame locals reads the key off the frame even with all three references cleared. `e.key` is the raw caller-supplied key itself (only `str(e)` is redacted), so a tracker that serialises exception attributes reads it directly. On Python 3.11+, clearing `e.__traceback__` also clears what `sys.exc_info()` reports for that exception. On Python 3.10 it does not: `sys.exc_info()` keeps its own reference to the original traceback until the `except` block exits, so a no-argument `capture_exception()` inside the block still reads the backend frame — always pass the exception to the tracker explicitly. +**Scope — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key. The CacheKit Cloud backend keeps no urllib3 exception. A request that fails in transport raises a `BackendError` whose `original_exception` and `__cause__` are an `HTTPTransportError` naming only urllib3's exception class, with no traceback. It is raised outside the `except` block, so its `__context__` is never urllib3's exception. urllib3's exception is dropped because its traceback runs through urllib3's request frames, whose locals hold the request headers, the `Authorization: Bearer` API key included, and an error tracker that captures frame locals would send them. A request answered with a status other than 2xx keeps an `HTTPStatusError` holding the response, which reaches no request frame either. So no local in a frame on a failed CacheKit Cloud request's `BackendError`, or on any exception its `__cause__`, `__context__` or `original_exception` reaches, holds the API key as an error tracker's frame-locals capture serialises it: strings and bytes by content, dicts, lists, tuples and sets item by item, and any other object by its `repr` (`tests/unit/backends/test_cachekitio_transport_error_frames.py`). An interrupt raised while a sync request is in flight (`KeyboardInterrupt`, the `SystemExit` a worker-timeout signal raises, `gevent.Timeout`) is not a failure cachekit classifies, and it propagates as itself with its full traceback. Before the interrupt leaves the backend, the locals of every finished frame on its traceback are cleared, and so are those on the traceback of each exception it chained during the request. The backend's own frame, still executing, holds no key. So the interrupt reaches no frame holding the API key either, and each frame keeps its file and line (same test file). An async request runs on a worker thread, which signals do not interrupt. The Redis backends keep no redis-py exception either. A failed operation on a `RedisBackend`, or on a backend from `RedisBackendProvider` or env auto-detection, sync or async, and a failed `RedisBackendProvider` init ping, raise a `BackendError` whose `original_exception` and `__cause__` are a `RedisClientError` naming only redis-py's exception class (`exc_type`), with no traceback, raised outside the `except` block, so its `__context__` is never redis-py's exception. redis-py's exception is dropped because its traceback runs through redis-py's frames, whose locals hold the client, its connection pool and the AUTH arguments, and the client's and the pool's reprs list every connection argument, the password included. The cachekit frames that raise the error hold no client, pool, pipeline or redis-py exception in a local, `RedisBackendProvider` holds the URL it is given only as a `SecretStr`, and a `RedisBackendConfig` leaves `redis_url` out of its repr. An exception your own code raises inside `acquire_lock` or `with_timeout` is kept whole as `original_exception`, because it is yours. A lock acquisition cancelled while its attempt is in flight re-raises the cancel once the attempt has finished, and no frame on that `CancelledError` keeps the attempt or its redis-py exception either. So no local in a frame on a failed Redis operation's `BackendError`, or on any exception its `__cause__`, `__context__` or `original_exception` reaches, holds the Redis password as an error tracker's frame-locals capture serialises it (`tests/unit/backends/test_redis_error_frames.py`). An interrupt raised while a Redis command is in flight, in any of those operations, a health check, the invalidation listener's start or an invalidation's announcement (both run inside a cache operation), is handled as on a sync CacheKit Cloud request: it propagates as itself, the locals of every finished frame on its traceback, redis-py's included, and on the traceback of each exception it chained during the operation are cleared, each frame keeping its file and line, and the backend's own frame, still executing, holds no client, pool or pipeline. So the interrupt reaches no frame holding the Redis password either (same test file). An async operation runs redis-py on an executor thread, which signals do not interrupt; an interrupt raised there (a `gevent.Timeout`) can reach the caller while the thread is still inside the executor frame that caught it, which is not cleared, but that frame holds only the work item, whose repr shows no client, and the interrupt. An interrupt your own code raises inside an `acquire_lock` block is cleared the same way. cachekit's own frames on either backend's error still hold the cache key, as below. cachekit itself never renders a provider exception's text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, pass it a freshly constructed exception carrying only `redact_error_for_log(e)`, handed over explicitly (`capture_exception(RuntimeError(redact_error_for_log(e)))`) rather than raised inside the `except` block: raising it there sets its `__context__` to `e` and re-links the whole chain, and `raise … from None` does not undo that — it only sets `__suppress_context__`, which hides the chain from `traceback` but not from an SDK that walks exception attributes. Failing that, clear **all three** references to the provider exception on `e` first — `__cause__`, `__context__`, and `BackendError.original_exception` — **and `e.__traceback__` and `e.key` with them**. The Memcached and File backends raise the classified `BackendError` from inside the `except` block that caught the provider exception, so Python sets `__context__` as well as `__cause__`; clearing `__cause__` alone hides the provider text from `traceback` but leaves it reachable to an SDK that walks exception attributes, and `original_exception` is a third reference. `__traceback__` leaks by a different route than the other three: they carry the provider exception's *text*, whereas the traceback carries the backend *frame* that raised — and that frame's locals still hold the raw key (`MemcachedBackend.get` raises `classify_memcached_error(exc, operation="get", key=key) from exc`), so a tracker that captures frame locals reads the key off the frame even with all three references cleared. `e.key` is the raw caller-supplied key itself (only `str(e)` is redacted), so a tracker that serialises exception attributes reads it directly. On Python 3.11+, clearing `e.__traceback__` also clears what `sys.exc_info()` reports for that exception. On Python 3.10 it does not: `sys.exc_info()` keeps its own reference to the original traceback until the `except` block exits, so a no-argument `capture_exception()` inside the block still reads the backend frame — always pass the exception to the tracker explicitly. -**Scope — transport logs are not covered.** The CachekitIO backend addresses entries by key in the request path (`GET /v1/cache/{key}`), and urllib3 logs every request line — method, path, status — at `DEBUG` on its `urllib3.connectionpool` logger. An application that enables `DEBUG` globally (`logging.basicConfig(level=logging.DEBUG)`) will therefore see raw keys in *urllib3's* output on every operation, exactly as it would see any REST resource path. urllib3 logs no headers, so neither the `Authorization` API key nor the `X-CacheKit-Lock-Id` lock token reaches that output (`tests/unit/backends/test_cachekitio_transport_logging.py`), and the line never carries a password: an API URL with credentials (`user:password@`) is rejected at construction. cachekit leaves the `urllib3` logger to you, because that line carries identifiers, not credentials. If your keys carry identifiers, silence or raise the level of that logger in your logging config: +**Scope — transport logs are not covered.** The CacheKit Cloud backend addresses entries by key in the request path (`GET /v1/cache/{key}`), and urllib3 logs every request line — method, path, status — at `DEBUG` on its `urllib3.connectionpool` logger. An application that enables `DEBUG` globally (`logging.basicConfig(level=logging.DEBUG)`) will therefore see raw keys in *urllib3's* output on every operation, exactly as it would see any REST resource path. urllib3 logs no headers, so neither the `Authorization` API key nor the `X-CacheKit-Lock-Id` lock token reaches that output (`tests/unit/backends/test_cachekitio_transport_logging.py`), and the line never carries a password: an API URL with credentials (`user:password@`) is rejected at construction. cachekit leaves the `urllib3` logger to you, because that line carries identifiers, not credentials. If your keys carry identifiers, silence or raise the level of that logger in your logging config: ```python import logging @@ -223,15 +223,15 @@ The same applies to any HTTP-layer capture between the SDK and `api.cachekit.io` ### Lock Token Transport (CWE-532) -The distributed-lock capability token (`lock_id`) is sent in the `X-CacheKit-Lock-Id` request header when releasing a lock (`DELETE /v1/cache/{key}/lock`), **never** in the URL query string. Query strings are routinely captured by access logs, proxy/CDN logs, and OpenTelemetry `http.url` spans ([CWE-532][cwe-532]); a leaked token could be replayed to release a lock within its short TTL. The CacheKit SaaS backend dual-reads the header and the legacy `?lock_id=` query during migration, preferring the header (removed in protocol 2.0). +The distributed-lock capability token (`lock_id`) is sent in the `X-CacheKit-Lock-Id` request header when releasing a lock (`DELETE /v1/cache/{key}/lock`), **never** in the URL query string. Query strings are routinely captured by access logs, proxy/CDN logs, and OpenTelemetry `http.url` spans ([CWE-532][cwe-532]); a leaked token could be replayed to release a lock within its short TTL. CacheKit Cloud dual-reads the header and the legacy `?lock_id=` query during migration, preferring the header (removed in protocol 2.0). ### Cache-Key Path Encoding (CWE-22) -Custom `@cache(key=...)` values are percent-encoded before they reach the CachekitIO request path, so a key can only ever address `/v1/cache/{key}` and never a different `api.cachekit.io` endpoint. Without encoding, `?`/`#` would be split into a query/fragment and a `/`-bearing key would introduce extra path segments, both escaping the cache namespace with the application's bearer token; an HTTP client splits these off client-side *before the request leaves the process* ([CWE-22][cwe-22]), so the SaaS-side key validator never sees them. `quote(key, safe="")` encodes every reserved character (`/` → `%2F`, `?` → `%3F`, `#` → `%23`, `%` → `%25`), collapsing the whole key into one inert path segment. +Custom `@cache(key=...)` values are percent-encoded before they reach the CacheKit Cloud request path, so a key can only ever address `/v1/cache/{key}` and never a different `api.cachekit.io` endpoint. Without encoding, `?`/`#` would be split into a query/fragment and a `/`-bearing key would introduce extra path segments, both escaping the cache namespace with the application's bearer token; an HTTP client splits these off client-side *before the request leaves the process* ([CWE-22][cwe-22]), so CacheKit Cloud's key validator never sees them. `quote(key, safe="")` encodes every reserved character (`/` → `%2F`, `?` → `%3F`, `#` → `%23`, `%` → `%25`), collapsing the whole key into one inert path segment. -Six keys cannot be encoded safely at all, so they are **rejected client-side** with a `PERMANENT` `BackendError` before any request is made: the empty key, `.`, `..`, `health`, `ttl` and `lock` ([protocol § Cache-Key Path Encoding](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#cache-key-path-encoding), rule 2). RFC-3986 marks `.` as *unreserved*, so `quote` leaves a key of exactly `.` or `..` raw, and that is a live dot-segment: `..` → `GET /v1`, `../ttl` → `GET /v1/ttl`, reaching a *different* route with the bearer token. Percent-encoding the dots does not fix it. The client sends `%2E%2E` intact, but the SaaS parses the request URL under the WHATWG URL Standard, which treats `%2e` / `%2e%2e` (any case) as dot segments and collapses them server-side, so the request still never reaches the key validator. `health`, `ttl` and `lock` are route tokens at the same level: `/v1/cache/health` is the health endpoint, and a final `ttl` or `lock` segment selects a sub-resource. The empty key is an empty segment: `/v1/cache/{key}` becomes `/v1/cache/` and `/v1/cache/{key}/ttl` becomes `/v1/cache//ttl`, neither of which addresses a stored entry. Only an exact match is reserved (`a:..`, `..a` and `x..y` are sent as-is), and canonical keys always contain `:`, so they never match. +Six keys cannot be encoded safely at all, so they are **rejected client-side** with a `PERMANENT` `BackendError` before any request is made: the empty key, `.`, `..`, `health`, `ttl` and `lock` ([protocol § Cache-Key Path Encoding](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#cache-key-path-encoding), rule 2). RFC-3986 marks `.` as *unreserved*, so `quote` leaves a key of exactly `.` or `..` raw, and that is a live dot-segment: `..` → `GET /v1`, `../ttl` → `GET /v1/ttl`, reaching a *different* route with the bearer token. Percent-encoding the dots does not fix it. The client sends `%2E%2E` intact, but CacheKit Cloud parses the request URL under the WHATWG URL Standard, which treats `%2e` / `%2e%2e` (any case) as dot segments and collapses them server-side, so the request still never reaches the key validator. `health`, `ttl` and `lock` are route tokens at the same level: `/v1/cache/health` is the health endpoint, and a final `ttl` or `lock` segment selects a sub-resource. The empty key is an empty segment: `/v1/cache/{key}` becomes `/v1/cache/` and `/v1/cache/{key}/ttl` becomes `/v1/cache//ttl`, neither of which addresses a stored entry. Only an exact match is reserved (`a:..`, `..a` and `x..y` are sent as-is), and canonical keys always contain `:`, so they never match. -Encode-once matches the SaaS validator's single decode, so a canonical key round-trips byte-for-byte. Python's `quote(key, safe="")` is byte-identical to cachekit-rs `urlencoding::encode`, and resolves to the same server-side key as cachekit-ts `encodeURIComponent` after that single decode, so cross-SDK cache lookups still coincide. +Encode-once matches the CacheKit Cloud validator's single decode, so a canonical key round-trips byte-for-byte. Python's `quote(key, safe="")` is byte-identical to cachekit-rs `urlencoding::encode`, and resolves to the same server-side key as cachekit-ts `encodeURIComponent` after that single decode, so cross-SDK cache lookups still coincide. ### Invalidation Channel (Redis Pub/Sub) diff --git a/docs/error-codes.md b/docs/error-codes.md index 8ce3533a..6b1aefc5 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -814,7 +814,7 @@ def my_function(): ### Client error (4xx) -**Message**: `Client error: HTTP 400` (or 404, etc.). A 413's message names `HTTP 413` and ends `value exceeds the server's maximum cache value size`. +**Message**: `Client error: HTTP 400` (or 404, etc.). A 413 reads `Value too large for CacheKit Cloud (HTTP 413): value exceeds the server's maximum cache value size`. **Exception**: none — logged as `BackendError` (`BackendErrorType.PERMANENT`) diff --git a/src/cachekit/__init__.py b/src/cachekit/__init__.py index c0a5dab7..e1fd31b2 100644 --- a/src/cachekit/__init__.py +++ b/src/cachekit/__init__.py @@ -5,7 +5,7 @@ AES-256-GCM encryption, on a Rust-powered core. Zero-config L1 in-memory by default; pluggable backends: Redis, Memcached, -File, CachekitIO SaaS, and custom via the BaseBackend protocol. +File, CacheKit Cloud, and custom via the BaseBackend protocol. Key Features: - **@cache decorator** with intent-based presets @@ -22,7 +22,7 @@ - FeatureOrchestrator: Manages enterprise-grade reliability and monitoring features - Flexible configuration interface with intent-based presets - Enhanced error handling with comprehensive safety checks -- Pluggable backend abstraction (Redis, Memcached, File, CachekitIO, custom) +- Pluggable backend abstraction (Redis, Memcached, File, CacheKit Cloud, custom) Born from production debugging of caching failures: - UTF-8 corruption prevention with intelligent binary data handling @@ -53,7 +53,7 @@ def process_payment(amount: Decimal): def get_user_data(user_id: int) -> UserProfile: return db.fetch_user(user_id) - @cache.io() # Managed SaaS backend (cachekit.io, closed beta) + @cache.io() # CacheKit Cloud backend (closed beta) def get_remote_data(key: str): return fetch_remote(key) @@ -76,7 +76,7 @@ def custom_function(): - ``old_secret_key``: a retired master key, kept decrypt-only during key rotation. - ``secret_key_bytes`` and ``old_secret_key_bytes``: the same keys as raw bytes, as ``EncryptionWrapper`` takes them, for example ``secret_key_bytes = bytes.fromhex(secret_key)``. -- ``api_key``: a cachekit.io API key, for example ``api_key = os.environ["CACHEKIT_API_KEY"]``. +- ``api_key``: a CacheKit Cloud API key, for example ``api_key = os.environ["CACHEKIT_API_KEY"]``. """ __version__ = "0.25.1" diff --git a/src/cachekit/backends/base.py b/src/cachekit/backends/base.py index 7bdc2ebe..c346d63c 100644 --- a/src/cachekit/backends/base.py +++ b/src/cachekit/backends/base.py @@ -75,7 +75,7 @@ def delete(self, key: str) -> bool: key: Cache key to delete Returns: - True if key was deleted, False if key didn't exist. CachekitIO returns + True if key was deleted, False if key didn't exist. CacheKit Cloud returns True on every successful delete, whether or not the key existed: the server does not report existence on DELETE. diff --git a/src/cachekit/backends/cachekitio/error_handler.py b/src/cachekit/backends/cachekitio/error_handler.py index db0518c4..5f97d054 100644 --- a/src/cachekit/backends/cachekitio/error_handler.py +++ b/src/cachekit/backends/cachekitio/error_handler.py @@ -120,7 +120,7 @@ def classify_http_error( # value must shrink), so the decorator degrades: runs uncached, once. if status == 413: return BackendError( - "Value too large for cachekit.io backend (HTTP 413): value exceeds the server's maximum cache value size", + "Value too large for CacheKit Cloud (HTTP 413): value exceeds the server's maximum cache value size", error_type=BackendErrorType.PERMANENT, original_exception=exc, operation=operation,