Skip to content

fix(cachekitio): reject reserved cache-key segments in request path (LAB-2878) - #76

Merged
27Bslash6 merged 3 commits into
mainfrom
lab-2878-cachekitio-all-dot-guard
Sep 4, 2026
Merged

fix(cachekitio): reject reserved cache-key segments in request path (LAB-2878)#76
27Bslash6 merged 3 commits into
mainfrom
lab-2878-cachekitio-all-dot-guard

Conversation

@27Bslash6

@27Bslash6 27Bslash6 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

LAB-2878 — security(cachekitio): reject all-dot cache-key segment (. / ..) in the request path (CWE-22)

Closes LAB-2878.

The finding: the prescribed Python-parity fix does not work in Rust

LAB-2878 asked to mirror cachekit-py's f000ba3 fix — rewrite an all-dot key ./.. to %2E/%2E%2E so it survives to the wire and the SaaS rejects the decoded ... AC-0 ("repro first") required verifying this against reqwest. It does not hold.

reqwest parses the URL string with the url crate (rust-url 2.5.8), which implements the WHATWG URL Standard. WHATWG treats %2e/%2e%2e (case-insensitive) as dot-segments and removes them — so %2E%2E collapses just like a raw ... Verified at the reqwest layer:

key "."   → encode "%2E"    → reqwest wire path  /v1/cache/    (single-dot removed)
key ".."  → encode "%2E%2E"  → reqwest wire path  /v1/          (double-dot removed — escapes /v1/cache/)

Python's fix works only because httpx/requests apply RFC-3986 remove_dot_segments, which does not decode %2e. rust-url is stricter. This is exactly the "verify reqwest" question the Python commit flagged, and it matches the protocol resolution in LAB-2879 (WHATWG stacks — fetch/undici/Workers/rust-url — cannot carry an encoded all-dot key intact).

Since every representation that decodeURIComponents once back to ./.. is a WHATWG dot-segment, no encoding can both reach the wire intact and round-trip. The only safe behaviour is to refuse to build the request.

The fix: reject, don't encode (aligns with cachekit-ts twin)

One shared fallible chokepoint in crates/cachekit/src/backend/mod.rs:

pub(crate) fn encode_key(key: &str) -> Result<Cow<'_, str>, BackendError> {
    let encoded = urlencoding::encode(key);
    if matches!(encoded.as_ref(), "." | "..") {
        return Err(BackendError::permanent(/* CWE-22: all-dot key stripped by the URL parser */));
    }
    Ok(encoded)
}

Result is threaded through the URL builders url / ttl_url / lock_url (native cachekitio.rs and wasm workers.rs) and their ~8 request callers, so every CachekitIO path — base, /ttl, /lock, native and wasm — is type-forced through the one guard; a ./.. key yields a permanent BackendError and no authenticated request is ever emitted. Every other key encodes byte-identically to before.

This matches the sibling cachekit-ts decision (LAB-2877, cachekit-io/cachekit-ts#118), which rejects ./.. with a ConfigurationError.

Cross-SDK wire position (AC-4)

SDK HTTP client model ./.. behaviour
cachekit-py RFC-3986 (httpx) encodes to %2E/%2E%2E; SaaS rejects decoded ..
cachekit-ts WHATWG (fetch/undici) rejects client-side
cachekit-rs WHATWG (rust-url) rejects client-side (this PR)

For every key except ./.., cachekit-rs is byte-identical to cachekit-py on the wire (urlencoding::encodequote(safe="") on the reserved set). ./.. is never a canonical CacheKit key (those always contain :), so rejection breaks nothing legitimate. Reference: cachekit-py _encode_key (src/cachekit/backends/cachekitio/backend.py:247-250 @ f000ba3); SaaS validator saas/apps/cache/src/cache-key-validator.ts (single decode, charset [a-zA-Z0-9_.:-], rejects any key containing ..).

Tests (AC-0/1/2/3)

  • AC-0 repro_raw_dot_key_escapes_the_cache_prefix — proves raw .. (and the %2E form) collapse in rust-url to /v1/, /v1/ttl, /v1/lock, /v1/cache/.
  • AC-2 dot_keys_are_rejected_by_every_builder + safe_keys_never_escape_the_cache_prefix — assert on the parsed Url::path() (the real post-normalisation wire path) for base/ttl/lock builders across ., .., a:.., default:../../admin, k?x=1#f, a b, canonical ns:….
  • AC-1 safe_keys_are_byte_identical_to_urlencoding — parity with urlencoding::encode for every non-dot vector.
  • AC-3 safe_keys_decode_once_back_to_the_original — SaaS single-decodeURIComponent round-trip.

Docs (AC-5)

  • README.md Security Properties: new "Cache-key path encoding (CWE-22)" row + paragraph stating the reject behaviour and the py divergence.
  • fn url / encode_key doc comments carry the WHATWG/CWE-22 rationale so a later dev doesn't "harmonize" back to %2E and silently reintroduce the gap.

Quality gates

  • cargo clippy --all-targets --features "cachekitio,redis,encryption,l1,macros,memcached,file" -- -D warnings — clean
  • cargo test --features "cachekitio,redis,encryption,l1,macros,memcached,file" — all pass
  • cargo check --target wasm32-unknown-unknown --features workers,encryption --no-default-features — compiles (no new warnings)
  • cargo fmt --check — clean

Expert-panel review (AC-6)

Ran at high stakes (crypto/protocol wire gate). bug-hunter and security-specialist returned NO FINDINGS (security independently re-derived the guard-completeness proof from the urlencoding source). code-craftsman flagged two doc issues (phantom test-name references; native/wasm path-construction asymmetry) — both applied. catchphrase-agent proposed cutting the decode-round-trip test — rebutted, it's mandated by AC-3 and documents the SaaS single-decode contract. Verdict: SHIP.

Summary by CodeRabbit

  • Security

    • Cache keys are now safely percent-encoded when used in request paths.
    • Exact ., .., health, ttl, and lock keys are rejected to prevent path normalisation and route conflicts.
  • Bug Fixes

    • Cache, lock, and TTL operations consistently apply protected key handling across supported backends.
    • Invalid cache-key paths now return clear errors instead of generating unsafe requests.
    • Special-character and traversal-like keys are handled safely.
  • Documentation

    • Added guidance explaining cache-key path protection and encoding behaviour.

A cache key of exactly `.` or `..` escapes the `/v1/cache/` prefix in
cachekit-rs: reqwest parses the URL with rust-url (WHATWG URL Standard),
which strips an all-dot path segment before the request leaves the
process — `/v1/cache/..` -> `/v1/`, `.../../lock` -> `/v1/lock` — carrying
the app bearer token to a route the SaaS cache-key-validator never sees
(CWE-22).

The Python-parity fix this ticket prescribed (rewrite to `%2E`/`%2E%2E`)
does NOT work here: rust-url treats `%2e`/`%2e%2e` (case-insensitive) as
dot-segments too, so the encoded form collapses identically (verified at
the reqwest layer). Since every representation that decodes once back to
`.`/`..` is a WHATWG dot-segment, no encoding survives — the only safe
action is to refuse to build the request.

Add a shared fallible `encode_key` in backend/mod.rs that rejects a key
encoding to exactly `.`/`..` with a permanent BackendError, and thread
Result through the `url`/`ttl_url`/`lock_url` builders (native cachekitio
+ wasm workers) and their callers, so every CachekitIO request path is
type-forced through the one guard. Every other key encodes byte-identically.

Aligns with the cachekit-ts twin (LAB-2877), which also rejects, and
diverges deliberately from cachekit-py (whose RFC-3986 client keeps
`%2E%2E` on the wire). `.`/`..` is never a canonical CacheKit key.

Docs: README Security Properties note + encode_key/url doc comments.
Expert-panel reviewed at high stakes (SHIP).
@kodus-27b

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: 443a3ee3-bcce-44bc-a25a-7aebdf7251fe

📥 Commits

Reviewing files that changed from the base of the PR and between 923b2b2 and 0a4a247.

📒 Files selected for processing (4)
  • README.md
  • crates/cachekit/src/backend/cachekitio.rs
  • crates/cachekit/src/backend/mod.rs
  • crates/cachekit/src/backend/workers.rs

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


Important

Approval pending

CodeRabbit has no unresolved comments, but it has not reviewed the latest commit.

Use the checkbox below to review the latest commit. CodeRabbit will approve the changes if it finds no blocking issues.

  • 🔍 Trigger review

Walkthrough

Cache-key URL construction now percent-encodes keys and rejects exact ., .., health, ttl, and lock keys. CachekitIO and Workers propagate URL errors through cache, lock, and TTL operations. Tests and README documentation cover the new behaviour.

Changes

Cache-key path protection

Layer / File(s) Summary
Shared key encoding contract
crates/cachekit/src/backend/mod.rs, README.md
encode_key encodes cache-path segments and rejects exact dot segments and reserved route tokens. Tests and documentation cover the contract.
CachekitIO URL flow
crates/cachekit/src/backend/cachekitio.rs, crates/cachekit/src/backend/cachekitio_lock.rs, crates/cachekit/src/backend/cachekitio_ttl.rs
CachekitIO centralises guarded cache, lock, and TTL URL construction. Cache operations propagate URL-construction errors. Tests cover normalisation, reserved routes, and safe key encoding.
Workers URL flow
crates/cachekit/src/backend/workers.rs
The Workers backend uses shared key encoding for cache, lock, and TTL URLs. All affected operations propagate construction errors.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 0a4a2

Cache keys are now encoded and five reserved path segments are rejected, preventing route normalization and endpoint collisions while preserving safe-key behavior. No current merge-blocking risk is identified.

Sequence Diagram(s)

sequenceDiagram
  participant CacheOperation
  participant Backend
  participant encode_key
  participant Endpoint
  CacheOperation->>Backend: Build cache, lock, or TTL URL
  Backend->>encode_key: Encode cache key
  encode_key-->>Backend: Encoded key or BackendError
  Backend->>Endpoint: Compose guarded endpoint URL
  Endpoint-->>CacheOperation: Request URL or BackendError
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 36 functions across 5 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting reserved cache-key segments in CachekitIO request paths. The issue reference is relevant.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 36 functions across 5 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lab-2878-cachekitio-all-dot-guard

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Sep 4, 2026
Comment thread crates/cachekit/src/backend/cachekitio.rs

@kodus-27b kodus-27b Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found critical issues please review the requested changes

…9 conformance)

Widen the client-side reject set from `.`/`..` to the full five reserved
segments the finalized protocol spec mandates — `.`, `..`, `health`, `ttl`,
`lock` (spec/saas-api.md § Cache-Key Path Encoding rule 2, protocol#61).

The dot segments collapse in rust-url before send; the route tokens collide
with real routes: `/v1/cache/health` IS the health endpoint, and a trailing
`ttl`/`lock` segment selects a sub-resource — so a key of exactly `health`,
`ttl` or `lock` is routed off the `/v1/cache/{key}` path carrying the bearer
token (CWE-22), the same class of escape as the dot segments.

`encode_key` now rejects a key whose encoded form is any of the five; tests
and vectors mirror protocol/test-vectors/path-encoding.json (five reject rows,
the transmittable rows byte-identical to urlencoding). Route-token near-misses
(`healthy`, `HEALTH`, `ttls`, `unlock`, embedded `x/../../health`) transmit
unchanged. Docs (README + doc comments) updated to the five-segment rule.
@27Bslash6 27Bslash6 changed the title security(cachekitio): reject all-dot cache-key segment (LAB-2878) fix(cachekitio): reject reserved cache-key segments in request path (LAB-2878) Sep 4, 2026
@kodus-27b

This comment has been minimized.

Comment thread crates/cachekit/src/backend/mod.rs

@kodus-27b kodus-27b Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found critical issues please review the requested changes

coderabbitai[bot]
coderabbitai Bot previously approved these changes Sep 4, 2026
…ist)

Pragmatism review of the five-segment widening flagged two ceremony tests:

- `route_token_keys_would_collide_with_reserved_routes` was a tautology —
  it asserted `Url::parse(".../v1/cache/health").path() == "/v1/cache/health"`,
  i.e. that the url crate leaves a non-dot path unchanged. It passes even if
  the guard is deleted, so it caught nothing. The route-token rationale lives
  in the `encode_key` doc comment; rejection is asserted by
  `reserved_segments_rejected_by_every_builder`.
- the inline near-miss `is_ok()` loop in `reserved_segments_are_rejected` was
  triple coverage — those keys are in `SAFE_VECTORS` and already asserted
  `is_ok()` by `safe_keys_are_byte_identical_to_urlencoding` and
  `safe_keys_decode_once_back_to_the_original` (a contains/case-insensitive
  regression panics on their `.expect`).

No coverage lost.
@kodus-27b

kodus-27b Bot commented Sep 4, 2026

Copy link
Copy Markdown

Kody Review Complete

Great news! 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@27Bslash6
27Bslash6 merged commit 0ed7e1d into main Sep 4, 2026
9 of 10 checks passed
@27Bslash6
27Bslash6 deleted the lab-2878-cachekitio-all-dot-guard branch September 4, 2026 12:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant