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
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Instead, use [GitHub's private vulnerability reporting](https://github.com/cache
| ------- | --------- |
| 0.x | Yes |

## Cache-Key Path Encoding (CWE-22)

The CachekitIO backend transmits cache keys as a single URL path segment (`/v1/cache/{key}`, `…/{key}/ttl`, `…/{key}/lock`). Keys are percent-encoded with `encodeURIComponent`; a key that is exactly `.`, `..`, `health`, `ttl` or `lock` is rejected with a `ConfigurationError` before any request is built ([protocol `spec/saas-api.md` § Cache-Key Path Encoding](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#cache-key-path-encoding), rule 2): the WHATWG URL parser behind `fetch` removes literal and percent-encoded (`%2E`) dot segments before the request reaches the wire, and the other three words are live route tokens at that path level. Every other key — including `a:..` and every canonical `ns:…` key — is sent percent-encoded as a single path segment and decodes once server-side to the original key.

## Scope

This policy covers the `@cachekit-io/cachekit` and `@cachekit-io/cachekit-core-ts` packages. For issues with the CacheKit SaaS platform (api.cachekit.io), contact security@cachekit.io.
3 changes: 3 additions & 0 deletions packages/cachekit/src/backends/cachekitio-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class CachekitIO implements LockableBackend, TTLBackend {
validateTtl(ttl: number) {
this.lockable.validateTtl(ttl);
}
validateKey(key: string) {
this.lockable.validateKey(key);
}
delete(key: string) {
return this.lockable.delete(key);
}
Expand Down
11 changes: 8 additions & 3 deletions packages/cachekit/src/backends/cachekitio-lockable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { LockableBackend } from './types.js';
import { CachekitIOCore } from './cachekitio.js';
import { CachekitIOCore, encodeKey } from './cachekitio.js';
import { BackendError, TimeoutError } from '../errors.js';
import { classifyHttpError, classifyNetworkError } from './error-classifier.js';

Expand Down Expand Up @@ -33,6 +33,9 @@ export class LockableCachekitIO implements LockableBackend {
validateTtl(ttl: number) {
this.inner.validateTtl(ttl);
}
validateKey(key: string) {
this.inner.validateKey(key);
}
delete(key: string) {
return this.inner.delete(key);
}
Expand All @@ -44,8 +47,9 @@ export class LockableCachekitIO implements LockableBackend {
}

async acquireLock(key: string, timeoutMs = 5000): Promise<string | null> {
// Before the try — the catch below would wrap encodeKey's ConfigurationError as a BackendError.
const url = `${this.inner['apiUrl']}/v1/cache/${encodeKey(key)}/lock`;
try {
const url = `${this.inner['apiUrl']}/v1/cache/${encodeURIComponent(key)}/lock`;
const response = await this.inner.requestJson('POST', url, { timeout_ms: timeoutMs });
// Contested lock: the protocol spec (saas-api.md) answers 409 Conflict;
// the deployed SaaS currently answers 200 {lock_id: null}. Both mean
Expand Down Expand Up @@ -75,8 +79,9 @@ export class LockableCachekitIO implements LockableBackend {
}

async releaseLock(key: string, lockId: string): Promise<boolean> {
// Before the try — the catch below would wrap encodeKey's ConfigurationError as a BackendError.
const url = `${this.inner['apiUrl']}/v1/cache/${encodeKey(key)}/lock`;
try {
const url = `${this.inner['apiUrl']}/v1/cache/${encodeURIComponent(key)}/lock`;
const response = await this.inner.requestJson('DELETE', url, undefined, {
[LOCK_ID_HEADER]: lockId,
});
Expand Down
12 changes: 8 additions & 4 deletions packages/cachekit/src/backends/cachekitio-ttl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TTLBackend } from './types.js';
import { CachekitIOCore, validateTtl } from './cachekitio.js';
import { CachekitIOCore, validateTtl, encodeKey } from './cachekitio.js';
import { BackendError, TimeoutError } from '../errors.js';
import { classifyHttpError, classifyNetworkError } from './error-classifier.js';

Expand All @@ -25,6 +25,9 @@ export class TTLCachekitIO implements TTLBackend {
validateTtl(ttl: number) {
this.inner.validateTtl(ttl);
}
validateKey(key: string) {
this.inner.validateKey(key);
}
delete(key: string) {
return this.inner.delete(key);
}
Expand All @@ -36,8 +39,9 @@ export class TTLCachekitIO implements TTLBackend {
}

async getTTL(key: string): Promise<number | null> {
// Before the try — the catch below would wrap encodeKey's ConfigurationError as a BackendError.
const url = `${this.inner['apiUrl']}/v1/cache/${encodeKey(key)}/ttl`;
try {
const url = `${this.inner['apiUrl']}/v1/cache/${encodeURIComponent(key)}/ttl`;
const response = await this.inner.requestJson('GET', url);
if (response.status === 404) return null;
if (!response.ok)
Expand All @@ -64,10 +68,10 @@ export class TTLCachekitIO implements TTLBackend {

async refreshTTL(key: string, ttl: number): Promise<boolean> {
// Same normative rules as X-CacheKit-TTL (spec: PATCH body follows them).
// Before the try — the catch below would wrap it as a BackendError.
// Both before the try — the catch below would wrap their ConfigurationError as a BackendError.
const validTtl = validateTtl(ttl);
const url = `${this.inner['apiUrl']}/v1/cache/${encodeKey(key)}/ttl`;
try {
const url = `${this.inner['apiUrl']}/v1/cache/${encodeURIComponent(key)}/ttl`;
const response = await this.inner.requestJson('PATCH', url, { ttl: validTtl });
if (response.status === 404) return false;
if (!response.ok)
Expand Down
68 changes: 63 additions & 5 deletions packages/cachekit/src/backends/cachekitio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,52 @@ import { buildMetricsHeaders } from './metrics-headers.js';
import { classifyHttpError, classifyNetworkError } from './error-classifier.js';
import { validateCachekitUrl } from './url-validator.js';

/**
* Path segments a key may never encode to (protocol spec/saas-api.md
* § Cache-Key Path Encoding, rule 2). `.` / `..` are dot segments that every
* WHATWG parser — fetch's and the SaaS worker's own — removes before routing,
* `%2E` forms included, so no encoding keeps them inside /v1/cache/ (CWE-22).
* `health` / `ttl` / `lock` are live route tokens at that level. The SaaS
* router matches all five exactly and case-sensitively, so only these
* lowercase words are reserved: `a:..`, `..a`, `HEALTH`, `ttls` transmit.
*/
const RESERVED_SEGMENTS = new Set(['.', '..', 'health', 'ttl', 'lock']);

/**
* Percent-encode a cache key as a single URL path segment, or throw
* `ConfigurationError` if it is a reserved segment (RESERVED_SEGMENTS) or
* malformed UTF-16. Every other key is exactly `encodeURIComponent(key)`:
* decode-equivalent to cachekit-py and cachekit-rs, which additionally
* encode `! * ' ( )` (spec rule 4; fixture `encoded_alternates`).
*/
export function encodeKey(key: string): string {
// An empty key encodes to an empty segment, so `/v1/cache/${''}` collapses to
// the `/v1/cache/` collection path — the same CWE-22 escape as a dot segment,
// reached without ever hitting RESERVED_SEGMENTS. Reject it up front.
if (key === '') {
throw new ConfigurationError(
'Cache key must not be empty: an empty key addresses the /v1/cache/ collection path, ' +
'not a keyed resource (CWE-22). Use a non-empty, namespaced key.'
);
}
let encoded: string;
try {
encoded = encodeURIComponent(key);
} catch (error) {
// Lone surrogate: encodeURIComponent throws a raw URIError. Every SDK error is a CachekitError.
throw new ConfigurationError('Cache key is not well-formed UTF-16 (lone surrogate)', {
cause: error,
});
}
if (RESERVED_SEGMENTS.has(encoded)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw new ConfigurationError(
`Cache key "${key}" is a reserved path segment (one of ${[...RESERVED_SEGMENTS].join(' ')}) ` +
`and cannot be addressed at /v1/cache/{key} (CWE-22). Use a namespaced key instead.`
);
}
return encoded;
}

const DEFAULT_API_URL = 'https://api.cachekit.io';
const DEFAULT_TIMEOUT_MS = 30_000;

Expand Down Expand Up @@ -76,9 +122,10 @@ export class CachekitIOCore implements Backend {

async get(key: string): Promise<Uint8Array | null> {
this.ensureNotClosed();
const url = this.cacheUrl(key);

try {
const response = await this.request('GET', this.cacheUrl(key));
const response = await this.request('GET', url);

if (response.status === 404) {
return null;
Expand All @@ -101,14 +148,21 @@ export class CachekitIOCore implements Backend {
validateTtl(ttl);
}

/** Backend.validateKey capability — rejects a reserved path segment
* synchronously, before the reliability executor can swallow it. */
validateKey(key: string): void {
encodeKey(key);
}

async set(key: string, value: Uint8Array, ttl?: number): Promise<void> {
this.ensureNotClosed();

const effectiveTtl = validateTtl(ttl ?? this.defaultTtl);
const headers: Record<string, string> = { 'X-CacheKit-TTL': String(effectiveTtl) };
const url = this.cacheUrl(key);

try {
const response = await this.request('PUT', this.cacheUrl(key), {
const response = await this.request('PUT', url, {
body: value,
headers,
});
Expand All @@ -124,9 +178,10 @@ export class CachekitIOCore implements Backend {

async delete(key: string): Promise<boolean> {
this.ensureNotClosed();
const url = this.cacheUrl(key);

try {
const response = await this.request('DELETE', this.cacheUrl(key));
const response = await this.request('DELETE', url);

if (response.status === 404) {
return false;
Expand All @@ -145,9 +200,10 @@ export class CachekitIOCore implements Backend {

async exists(key: string): Promise<boolean> {
this.ensureNotClosed();
const url = this.cacheUrl(key);

try {
const response = await this.request('HEAD', this.cacheUrl(key));
const response = await this.request('HEAD', url);

if (response.status === 404) {
return false;
Expand Down Expand Up @@ -198,8 +254,10 @@ export class CachekitIOCore implements Backend {

// ── Internal ──────────────────────────────────────────────

/** Call before the network `try`: encodeKey's ConfigurationError must reach
* the caller as-is, not wrapped by the catch as a BackendError. */
private cacheUrl(key: string): string {
return `${this.apiUrl}/v1/cache/${encodeURIComponent(key)}`;
return `${this.apiUrl}/v1/cache/${encodeKey(key)}`;
Comment thread
27Bslash6 marked this conversation as resolved.
}

private async request(
Expand Down
14 changes: 14 additions & 0 deletions packages/cachekit/src/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ export interface Backend {
*/
validateTtl?(ttl: number): void;

/**
* Reject a key this backend cannot address, synchronously and before the
* reliability executor runs — CachekitIO refuses the reserved path
* segments `.` `..` `health` `ttl` `lock` (protocol spec/saas-api.md
* § Cache-Key Path Encoding). Same contract as validateTtl: inside `run`,
* degradation would swallow the deterministic caller error and
* retry/circuit-breaker would count it as backend failures.
*
* Delegating wrappers MUST forward the inner backend's implementation.
*
* @throws {ConfigurationError} when the key is rejected by this backend
*/
validateKey?(key: string): void;

/**
* Delete a key from the cache.
*
Expand Down
9 changes: 8 additions & 1 deletion packages/cachekit/src/cache-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,9 @@ export class CacheImpl implements SecureCache {
}
}

// Reserved-key pre-flight — see Backend.validateKey.
this.backend.validateKey?.(key);

// Fetch from L2 (backend)
return this.run('get', null, async (): Promise<T | null> => {
// When L1 will be re-populated, prefer the TTL-carrying read (same
Expand Down Expand Up @@ -754,11 +757,13 @@ export class CacheImpl implements SecureCache {
}

// A backend with hard TTL bounds (CachekitIO: reject 0 / > 30 days per
// protocol) rejects here, synchronously — for the same reason as the
// protocol) or a key it cannot address (CachekitIO's reserved path
// segments) rejects here, synchronously — for the same reason as the
// interop rejection below: inside `run`, degradation would swallow the
// deterministic caller error (set() would silently never store) and
// retry/circuit-breaker would count it as backend failures.
this.backend.validateTtl?.(ttl);
this.backend.validateKey?.(key);

const namespace = options?.namespace ?? extractNamespace(key);
const useEnvelope = this.useEnvelope(interop);
Expand Down Expand Up @@ -844,6 +849,7 @@ export class CacheImpl implements SecureCache {

async delete(key: string): Promise<boolean> {
this.ensureNotClosed();
this.backend.validateKey?.(key); // see Backend.validateKey

return this.run('delete', false, async (): Promise<boolean> => {
// Delete from backend
Expand All @@ -861,6 +867,7 @@ export class CacheImpl implements SecureCache {

async exists(key: string): Promise<boolean> {
this.ensureNotClosed();
this.backend.validateKey?.(key); // see Backend.validateKey

// Check L1 first. Presence alone is not an answer for a secure cache: L1
// holds ciphertext, and after a key rotation every resident entry is
Expand Down
47 changes: 47 additions & 0 deletions packages/cachekit/src/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,53 @@ describe('Cache Integration', () => {
expect(setCalls).toBe(1);
await boundedCache.close();
});

// LAB-2877 regression: a backend's key rejection (Backend.validateKey —
// CachekitIO's reserved path segments) is the same kind of deterministic
// caller error: inside the executor it would be retried, counted by the
// circuit breaker, and swallowed by degradation into a silent miss or a
// set() that never stores.
it('surfaces a backend key rejection despite default-on degradation', async () => {
const inner = new InMemoryBackend();
const calls: string[] = [];
const guardedBackend: Backend = {
async get(key) {
calls.push('get');
return inner.get(key);
},
async set(key, value, ttl) {
calls.push('set');
return inner.set(key, value, ttl!);
},
async delete(key) {
calls.push('delete');
return inner.delete(key);
},
async exists(key) {
calls.push('exists');
return inner.exists(key);
},
close: () => inner.close(),
validateKey(key) {
if (key === '..') throw new ConfigurationError('reserved path segment');
},
};

const guardedCache = createCache({
backend: guardedBackend,
compression: false,
l1: { enabled: false },
});
await expect(guardedCache.get('..')).rejects.toThrow(ConfigurationError);
await expect(guardedCache.set('..', 'value')).rejects.toThrow(ConfigurationError);
await expect(guardedCache.delete('..')).rejects.toThrow(ConfigurationError);
await expect(guardedCache.exists('..')).rejects.toThrow(ConfigurationError);
expect(calls).toEqual([]); // rejected before the reliability executor ran

await guardedCache.set('test:key-ok', 'value');
expect(calls).toEqual(['set']);
await guardedCache.close();
});
});

describe('Compression (ByteStorage)', () => {
Expand Down
Loading
Loading