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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ let ssn: String = secure.get("user:42:ssn").await?.unwrap();
| **AAD Binding** | Cache key bound to ciphertext (prevents substitution attacks) |
| **Memory Safety** | [zeroize](https://crates.io/crates/zeroize) on drop for all key material |
| **L1 Guarantee** | L1 stores ciphertext, never plaintext |
| **Cache-key path encoding (CWE-22)** | Keys are percent-encoded into the CachekitIO request path; a key encoding to a reserved segment (`.`, `..`, `health`, `ttl`, `lock`) is **rejected** rather than sent |

**Cache-key path encoding (CWE-22):** CachekitIO keys are percent-encoded (`urlencoding::encode`) so a key can only ever address `/v1/cache/{key}`. A key whose encoded form is one of the five reserved path segments — `.`, `..`, `health`, `ttl`, `lock` — is **rejected** with a permanent error rather than sent (protocol `spec/saas-api.md` § Cache-Key Path Encoding, rule 2). `.`/`..` are dot segments that `reqwest`'s WHATWG URL parser (rust-url) strips *before the request leaves the process* (`/v1/cache/..` → `/v1/`); `health`/`ttl`/`lock` are live route tokens (`/v1/cache/health` is the health endpoint, a trailing `ttl`/`lock` selects a sub-resource). Encoding can't neutralise either — WHATWG collapses `%2E%2E` too — so the SDK refuses rather than emit a request whose path was rewritten. This matches the cachekit-ts twin and is stricter than cachekit-py's older `%2E` rewrite; none of the five is ever a canonical CacheKit key (those contain `:`), so nothing legitimate is affected and every other key encodes byte-identically across the SDKs.

**AAD v0x03 wire format:**

Expand Down
152 changes: 142 additions & 10 deletions crates/cachekit/src/backend/cachekitio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::time::Duration;
use async_trait::async_trait;
use zeroize::Zeroizing;

use crate::backend::{Backend, HealthStatus, LockableBackend};
use crate::backend::{encode_key, Backend, HealthStatus, LockableBackend};
use crate::error::{BackendError, BackendErrorKind};
use crate::metrics::{metrics_headers, MetricsProvider};
use crate::session::session_headers;
Expand Down Expand Up @@ -59,11 +59,32 @@ impl CachekitIO {

/// Build the full URL for a cache key path segment.
///
/// Keys are percent-encoded so that slashes or special characters in the
/// cache key do not break the URL structure.
fn url(&self, key: &str) -> String {
let encoded = urlencoding::encode(key);
format!("{}/v1/cache/{}", self.api_url, encoded)
/// Keys are percent-encoded via [`encode_key`](crate::backend::encode_key) so
/// slashes or special characters do not break the URL structure. A key whose
/// encoded form is a reserved segment (`.`, `..`, `health`, `ttl`, `lock`) is
/// **rejected** (fallible return) rather than sent: the dot segments are
/// stripped by `reqwest`'s WHATWG URL parser and the route tokens collide
/// with the health/sub-resource routes, both escaping `/v1/cache/{key}`
/// (CWE-22, spec rule 2) — see [`encode_key`](crate::backend::encode_key).
fn url(&self, key: &str) -> Result<String, BackendError> {
Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
}

/// Build the TTL URL for a cache key (`…/ttl`). Composes on [`Self::url`], so
/// it inherits the same [`encode_key`](crate::backend::encode_key) guard and
/// the `/v1/cache/` prefix lives in one place (matching the wasm `workers`
/// backend). `pub(crate)` so the [`TtlInspectable`](super::TtlInspectable)
/// impl in the sibling `cachekitio_ttl` module builds its path through it.
pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
Ok(format!("{}/ttl", self.url(key)?))
}

/// Build the lock URL for a cache key (`…/lock`). Composes on [`Self::url`]
/// (same guard, same single prefix). `pub(crate)` so the
/// [`LockableBackend`](super::LockableBackend) impl in the sibling
/// `cachekitio_lock` module builds its path through it.
pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
Ok(format!("{}/lock", self.url(key)?))
}

/// Build the health-check URL.
Expand Down Expand Up @@ -125,7 +146,7 @@ impl Backend for CachekitIO {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
let req = self.with_standard_headers(
self.client
.get(self.url(key))
.get(self.url(key)?)
.bearer_auth(self.api_key.as_str()),
);

Expand Down Expand Up @@ -155,7 +176,7 @@ impl Backend for CachekitIO {
) -> Result<(), BackendError> {
let mut req = self
.client
.put(self.url(key))
.put(self.url(key)?)
.bearer_auth(self.api_key.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(value);
Expand All @@ -182,7 +203,7 @@ impl Backend for CachekitIO {
async fn delete(&self, key: &str) -> Result<bool, BackendError> {
let req = self.with_standard_headers(
self.client
.delete(self.url(key))
.delete(self.url(key)?)
.bearer_auth(self.api_key.as_str()),
);

Expand All @@ -201,7 +222,7 @@ impl Backend for CachekitIO {
async fn exists(&self, key: &str) -> Result<bool, BackendError> {
let req = self.with_standard_headers(
self.client
.head(self.url(key))
.head(self.url(key)?)
.bearer_auth(self.api_key.as_str()),
);

Expand Down Expand Up @@ -337,3 +358,114 @@ impl CachekitIOBuilder {
})
}
}

// ── Cache-key path encoding tests (CWE-22) ────────────────────────────────────

#[cfg(test)]
#[allow(clippy::expect_used)] // test-only: a builder/parse failure on a fixture should panic loudly
mod path_encoding_tests {
use super::CachekitIO;
use url::Url;

const API: &str = "https://api.cachekit.io";

fn backend() -> CachekitIO {
CachekitIO::builder()
.api_url(API)
.api_key("ck_test_key")
.build()
.expect("builder should succeed for the canonical host")
}

/// AC-0 — Repro. Before any guard, a raw-encoded `.`/`..` key collapses in
/// rust-url (the parser `reqwest` uses) *before* the request leaves the
/// process: the segment is stripped and the path escapes `/v1/cache/`.
/// `%2E%2E` collapses identically, which is why the fix rejects rather than
/// re-encodes (WHATWG treats `%2e%2e` as a dot-segment too).
#[test]
fn repro_raw_dot_key_escapes_the_cache_prefix() {
let cases = [
("..", "", "/v1/"),
("..", "/ttl", "/v1/ttl"),
("..", "/lock", "/v1/lock"),
(".", "", "/v1/cache/"),
];
for (key, suffix, escaped) in cases {
let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
let parsed = Url::parse(&raw).expect("parses");
assert_eq!(
parsed.path(),
escaped,
"raw {key:?}{suffix} should collapse to {escaped}"
);
// The %2E form the Python SDK emits collapses just the same in rust-url.
let pct = key.replace('.', "%2E");
let enc = format!("{API}/v1/cache/{pct}{suffix}");
assert_eq!(
Url::parse(&enc).expect("parses").path(),
escaped,
"%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
);
}
}

/// AC-2 / spec rule 2 — all five reserved segments (`.`, `..`, `health`,
/// `ttl`, `lock`) are rejected by every builder (base, ttl, lock): no URL is
/// produced, so no rewritten or mis-routed request can ever be sent.
#[test]
fn reserved_segments_rejected_by_every_builder() {
let b = backend();
for key in [".", "..", "health", "ttl", "lock"] {
assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
Comment thread
27Bslash6 marked this conversation as resolved.
assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
assert!(
b.lock_url(key).is_err(),
"lock_url({key:?}) must be rejected"
);
}
}

/// AC-2 — every non-dot vector builds a URL whose *parsed* path (the real
/// wire path, post-normalisation) stays inside `/v1/cache/`. Asserting on the
/// unparsed `format!` output would pass while still shipping a traversal, so
/// we parse with the same `url` crate `reqwest` uses.
#[test]
fn safe_keys_never_escape_the_cache_prefix() {
let b = backend();
let vectors = [
"a:..",
"default:../../admin",
"k?x=1#f",
"a b",
"healthy", // route-token near-miss: not reserved, must build fine
"x/../../health", // embedded route token, `/`→`%2F` keeps it one segment
"ns:default:func:m.f:args:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:",
];
for key in vectors {
let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");

assert!(
base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
"base path {} escaped prefix for {key:?}",
base.path()
);
assert_eq!(
base.path(),
format!("/v1/cache/{}", urlencoding::encode(key)),
"base wire path mismatch for {key:?}"
);
assert!(
ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
"ttl path {} escaped prefix for {key:?}",
ttl.path()
);
assert!(
lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
"lock path {} escaped prefix for {key:?}",
lock.path()
);
}
}
}
25 changes: 11 additions & 14 deletions crates/cachekit/src/backend/cachekitio_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@ const LOCK_ID_HEADER: &str = "X-CacheKit-Lock-Id";
impl CachekitIO {
/// Build the unlock request. Extracted so tests can assert the lock_id rides the
/// `X-CacheKit-Lock-Id` header and never appears in the URL (CWE-532).
fn release_request(&self, key: &str, lock_id: &str) -> reqwest::RequestBuilder {
let url = format!(
"{}/v1/cache/{}/lock",
self.api_url(),
urlencoding::encode(key)
);
self.with_standard_headers(
fn release_request(
&self,
key: &str,
lock_id: &str,
) -> Result<reqwest::RequestBuilder, BackendError> {
let url = self.lock_url(key)?;
Ok(self.with_standard_headers(
self.client()
.delete(&url)
.bearer_auth(self.api_key_str())
.header(LOCK_ID_HEADER, lock_id),
)
))
}
}

Expand All @@ -53,11 +53,7 @@ impl LockableBackend for CachekitIO {
key: &str,
timeout_ms: u64,
) -> Result<Option<String>, BackendError> {
let url = format!(
"{}/v1/cache/{}/lock",
self.api_url(),
urlencoding::encode(key)
);
let url = self.lock_url(key)?;

let body = serde_json::to_vec(&LockAcquireRequest { timeout_ms }).map_err(|e| {
BackendError::permanent(format!("failed to serialize lock request: {e}"))
Expand Down Expand Up @@ -92,7 +88,7 @@ impl LockableBackend for CachekitIO {
// lock_id is a capability token → X-CacheKit-Lock-Id header, not the query string
// (CWE-532). See `release_request`.
let resp = self
.release_request(key, lock_id)
.release_request(key, lock_id)?
.send()
.await
.map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
Expand Down Expand Up @@ -130,6 +126,7 @@ mod tests {

let req = backend
.release_request("my-key", "lock-secret-123")
.expect("release_request should build for a normal key")
.build()
.expect("request should build");

Expand Down
12 changes: 2 additions & 10 deletions crates/cachekit/src/backend/cachekitio_ttl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ struct RefreshTtlRequest {
#[cfg_attr(feature = "unsync", async_trait(?Send))]
impl TtlInspectable for CachekitIO {
async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError> {
let url = format!(
"{}/v1/cache/{}/ttl",
self.api_url(),
urlencoding::encode(key)
);
let url = self.ttl_url(key)?;

let req =
self.with_standard_headers(self.client().get(&url).bearer_auth(self.api_key_str()));
Expand Down Expand Up @@ -59,11 +55,7 @@ impl TtlInspectable for CachekitIO {
));
}

let url = format!(
"{}/v1/cache/{}/ttl",
self.api_url(),
urlencoding::encode(key)
);
let url = self.ttl_url(key)?;

let body = serde_json::to_vec(&RefreshTtlRequest { ttl: secs }).map_err(|e| {
BackendError::permanent(format!("failed to serialize refresh_ttl request: {e}"))
Expand Down
Loading
Loading