diff --git a/SECURITY.md b/SECURITY.md index 241a74e..7298562 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/packages/cachekit/src/backends/cachekitio-factory.ts b/packages/cachekit/src/backends/cachekitio-factory.ts index 6daa143..0b66a3b 100644 --- a/packages/cachekit/src/backends/cachekitio-factory.ts +++ b/packages/cachekit/src/backends/cachekitio-factory.ts @@ -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); } diff --git a/packages/cachekit/src/backends/cachekitio-lockable.ts b/packages/cachekit/src/backends/cachekitio-lockable.ts index 4c02adc..b1262ad 100644 --- a/packages/cachekit/src/backends/cachekitio-lockable.ts +++ b/packages/cachekit/src/backends/cachekitio-lockable.ts @@ -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'; @@ -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); } @@ -44,8 +47,9 @@ export class LockableCachekitIO implements LockableBackend { } async acquireLock(key: string, timeoutMs = 5000): Promise { + // 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 @@ -75,8 +79,9 @@ export class LockableCachekitIO implements LockableBackend { } async releaseLock(key: string, lockId: string): Promise { + // 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, }); diff --git a/packages/cachekit/src/backends/cachekitio-ttl.ts b/packages/cachekit/src/backends/cachekitio-ttl.ts index 494b76f..7304536 100644 --- a/packages/cachekit/src/backends/cachekitio-ttl.ts +++ b/packages/cachekit/src/backends/cachekitio-ttl.ts @@ -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'; @@ -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); } @@ -36,8 +39,9 @@ export class TTLCachekitIO implements TTLBackend { } async getTTL(key: string): Promise { + // 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) @@ -64,10 +68,10 @@ export class TTLCachekitIO implements TTLBackend { async refreshTTL(key: string, ttl: number): Promise { // 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) diff --git a/packages/cachekit/src/backends/cachekitio.ts b/packages/cachekit/src/backends/cachekitio.ts index 2d97553..eabca05 100644 --- a/packages/cachekit/src/backends/cachekitio.ts +++ b/packages/cachekit/src/backends/cachekitio.ts @@ -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)) { + 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; @@ -76,9 +122,10 @@ export class CachekitIOCore implements Backend { async get(key: string): Promise { 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; @@ -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 { this.ensureNotClosed(); const effectiveTtl = validateTtl(ttl ?? this.defaultTtl); const headers: Record = { '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, }); @@ -124,9 +178,10 @@ export class CachekitIOCore implements Backend { async delete(key: string): Promise { 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; @@ -145,9 +200,10 @@ export class CachekitIOCore implements Backend { async exists(key: string): Promise { 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; @@ -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)}`; } private async request( diff --git a/packages/cachekit/src/backends/types.ts b/packages/cachekit/src/backends/types.ts index c82d183..a624bef 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -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. * diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 81f7200..67fd916 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -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 => { // When L1 will be re-populated, prefer the TTL-carrying read (same @@ -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); @@ -844,6 +849,7 @@ export class CacheImpl implements SecureCache { async delete(key: string): Promise { this.ensureNotClosed(); + this.backend.validateKey?.(key); // see Backend.validateKey return this.run('delete', false, async (): Promise => { // Delete from backend @@ -861,6 +867,7 @@ export class CacheImpl implements SecureCache { async exists(key: string): Promise { 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 diff --git a/packages/cachekit/src/cache.test.ts b/packages/cachekit/src/cache.test.ts index 5941353..84081ee 100644 --- a/packages/cachekit/src/cache.test.ts +++ b/packages/cachekit/src/cache.test.ts @@ -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)', () => { diff --git a/packages/cachekit/test/protocol/fixtures/path-encoding.json b/packages/cachekit/test/protocol/fixtures/path-encoding.json new file mode 100644 index 0000000..3837a80 --- /dev/null +++ b/packages/cachekit/test/protocol/fixtures/path-encoding.json @@ -0,0 +1,105 @@ +{ + "version": "1.0.0", + "generator": "urllib.parse.quote(key, safe=\"\") — cachekit-py v0.18.0 CachekitIOBackend._encode_key (f000ba3) minus its `.`/`..` → %2E rewrite, which the server's WHATWG URL parse collapses; `.` and `..` are reject rows", + "spec": "spec/saas-api.md § Cache-Key Path Encoding", + "ci_verification": "tools/path-encoding-verify.py (stdlib only; runs in this repo's verify.yml; mutation self-test first)", + "contract": "`encoded` is the single `{key}` path segment in the reference form (spec rule 1); `decoded` is the key the server sees after its single percent-decode and equals `key` in every transmittable row (spec rules 3-4). Rows with `reject: true` are the reserved segments of spec rule 2 (`.`, `..`, `health`, `ttl`, `lock`): a conformant client raises before building the URL, so `encoded` and `decoded` are null. `encoded_alternates` lists the other conformant wire form where `encodeURIComponent` differs (`! * ' ( )` raw); assert `encoded in [encoded] + encoded_alternates`.", + "vectors": [ + { + "key": "ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s", + "encoded": "ns%3Atest%3Afunc%3A__main__.get_user%3Aargs%3A3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153%3A1s", + "decoded": "ns:test:func:__main__.get_user:args:3870b2ea5735ae639ded9450ef117768db676f037bec636503796c5b81095153:1s", + "note": "Canonical 7-segment auto-mode key (test-vectors/cache-keys.json `single_integer`). Only `:` is encoded; identical bytes from all three reference encoders." + }, + { + "key": "default:../../admin", + "encoded": "default%3A..%2F..%2Fadmin", + "decoded": "default:../../admin", + "note": "Embedded traversal: every `/` is `%2F`, so no `../` boundary exists for a URL parser to collapse. Server decodes once, then rejects (`/` outside charset; `..` substring)." + }, + { + "key": "x/../../health", + "encoded": "x%2F..%2F..%2Fhealth", + "decoded": "x/../../health", + "note": "Traversal aimed at the `/v1/cache/health` route token; inert once `/` is `%2F`. Server rejects the decoded key (charset)." + }, + { + "key": "k?x=1#f", + "encoded": "k%3Fx%3D1%23f", + "decoded": "k?x=1#f", + "note": "Query/fragment injection: `?` and `#` MUST be encoded or the client's URL parser truncates the key and emits a query string. Server rejects the decoded key (charset)." + }, + { + "key": "a b", + "encoded": "a%20b", + "decoded": "a b", + "note": "Space is `%20` in a path segment, never `+` (form-encoding, which the server does not decode). Server rejects the decoded key (charset)." + }, + { + "key": "100%", + "encoded": "100%25", + "decoded": "100%", + "note": "A literal `%` is encoded exactly once; the server decodes once and sees `100%`, then rejects it (charset). `%2525` would decode to `100%25`, a different key." + }, + { + "key": ".", + "encoded": null, + "decoded": null, + "reject": true, + "note": "Dot segment: the plain encoding `.` is removed by every URL parser, and `%2E` is collapsed by the server's WHATWG parse (`/v1/cache/%2E` → `/v1/cache/`). No wire form reaches the validator." + }, + { + "key": "..", + "encoded": null, + "decoded": null, + "reject": true, + "note": "Dot segment: unencoded, `/v1/cache/..` collapses to `/v1/` and `/v1/cache/../ttl` to `/v1/ttl`; `%2E%2E` is collapsed the same way by the server (`GET /v1/cache/%2E%2E/health` returns the `/v1/health` response). No wire form reaches the validator." + }, + { + "key": "health", + "encoded": null, + "decoded": null, + "reject": true, + "note": "Route token: `/v1/cache/health` is the health endpoint, so a GET for this key would return the health payload as a cache hit." + }, + { + "key": "ttl", + "encoded": null, + "decoded": null, + "reject": true, + "note": "Route token: a final `ttl` segment selects the TTL sub-resource, so `/v1/cache/ttl` is read as an empty key plus `/ttl`." + }, + { + "key": "lock", + "encoded": null, + "decoded": null, + "reject": true, + "note": "Route token: a final `lock` segment selects the lock sub-resource, so `/v1/cache/lock` is read as an empty key plus `/lock`." + }, + { + "key": "a:..", + "encoded": "a%3A..", + "decoded": "a:..", + "note": "Trailing dots but NOT an all-dot segment: not a dot segment under either parsing model, so the dots stay raw. Server rejects the decoded `..` substring." + }, + { + "key": "..a", + "encoded": "..a", + "decoded": "..a", + "note": "Leading dots, not an all-dot segment: sent verbatim (all characters unreserved). Server rejects the decoded `..` substring." + }, + { + "key": "ns:key", + "encoded": "ns%3Akey", + "decoded": "ns:key", + "note": "`:` → `%3A`, decoded once server-side. The server then rejects it: an `ns:` key must be `ns:{namespace}:{rest}` (spec rule 3), and this one has no `{rest}`." + }, + { + "key": "f(x)!*'", + "encoded": "f%28x%29%21%2A%27", + "decoded": "f(x)!*'", + "encoded_alternates": ["f(x)!*'"], + "note": "Encoder-variance row (spec rule 4): `quote(safe=\"\")` and `urlencoding::encode` produce `encoded`; `encodeURIComponent` produces the alternate. Both decode to `key`. Server rejects the decoded key (charset)." + } + ] +} diff --git a/packages/cachekit/test/protocol/path-encoding.protocol.test.ts b/packages/cachekit/test/protocol/path-encoding.protocol.test.ts new file mode 100644 index 0000000..6ac4ba1 --- /dev/null +++ b/packages/cachekit/test/protocol/path-encoding.protocol.test.ts @@ -0,0 +1,199 @@ +/** + * Cache-Key Path Encoding Protocol Tests (CWE-22) + * + * Verifies `encodeKey` and every CachekitIO request builder against + * protocol/test-vectors/path-encoding.json (vendored in ./fixtures/ — re-copy + * on spec change). Spec: protocol/spec/saas-api.md § Cache-Key Path Encoding. + * + * Every wire assertion reads the URL the real class handed to `fetch` and + * parses it with `new URL()` — the post-WHATWG-normalisation path, which is + * what leaves the process. A template-string comparison would pass while the + * traversal ships (spec rule 2). + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { CachekitIOCore, encodeKey } from '../../src/backends/cachekitio.js'; +import { TTLCachekitIO } from '../../src/backends/cachekitio-ttl.js'; +import { LockableCachekitIO } from '../../src/backends/cachekitio-lockable.js'; +import { ConfigurationError } from '../../src/errors.js'; + +interface Vector { + key: string; + encoded: string | null; + decoded: string | null; + reject?: boolean; + encoded_alternates?: string[]; + note: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const { vectors } = JSON.parse( + readFileSync(join(here, 'fixtures', 'path-encoding.json'), 'utf8') +) as { vectors: Vector[] }; +const reserved = vectors.filter((v) => v.reject); +const transmittable = vectors.filter((v) => !v.reject); + +const BASE = 'https://api.cachekit.io'; +const PREFIX = '/v1/cache/'; + +/** Real backend classes over a fetch spy that answers every call 200 with a JSON body. */ +function harness() { + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify({ ttl: 60, lock_id: 'lock-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + const core = new CachekitIOCore({ apiKey: 'ck_test_fake-not-a-secret', apiUrl: BASE }); // pragma: allowlist secret + return { fetchSpy, core, ttl: new TTLCachekitIO(core), lock: new LockableCachekitIO(core) }; +} +type Harness = ReturnType; + +/** Every operation that places `{key}` in the request path, with its route suffix. */ +const OPERATIONS: Record< + string, + { run: (h: Harness, key: string) => Promise; suffix: string } +> = { + get: { run: (h, k) => h.core.get(k), suffix: '' }, + set: { run: (h, k) => h.core.set(k, new Uint8Array([1])), suffix: '' }, + delete: { run: (h, k) => h.core.delete(k), suffix: '' }, + exists: { run: (h, k) => h.core.exists(k), suffix: '' }, + getTTL: { run: (h, k) => h.ttl.getTTL(k), suffix: '/ttl' }, + refreshTTL: { run: (h, k) => h.ttl.refreshTTL(k, 60), suffix: '/ttl' }, + acquireLock: { run: (h, k) => h.lock.acquireLock(k, 1000), suffix: '/lock' }, + releaseLock: { run: (h, k) => h.lock.releaseLock(k, 'lock-1'), suffix: '/lock' }, +}; + +/** The path `fetch` was handed, as the WHATWG parser resolves it. */ +function sentPathname(h: Harness): string { + expect(h.fetchSpy).toHaveBeenCalledTimes(1); + const [url] = h.fetchSpy.mock.calls[0] as unknown as [string]; + return new URL(url).pathname; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// Pins the platform premise the reject-not-encode design rests on. If a runtime +// ever stops collapsing these, the decision needs revisiting — not silently. +describe('AC-0 repro — raw encodeURIComponent lets a dot-segment key escape /v1/cache/', () => { + it('literal dots collapse client-side', () => { + expect(new URL(`${BASE}${PREFIX}${encodeURIComponent('.')}`).pathname).toBe(PREFIX); + expect(new URL(`${BASE}${PREFIX}${encodeURIComponent('..')}`).pathname).toBe('/v1/'); + expect(new URL(`${BASE}${PREFIX}${encodeURIComponent('..')}/ttl`).pathname).toBe('/v1/ttl'); + expect(new URL(`${BASE}${PREFIX}${encodeURIComponent('..')}/lock`).pathname).toBe('/v1/lock'); + }); + + it('%2E does not help — WHATWG treats it as a dot segment (URL Standard §4.1)', () => { + expect(new URL(`${BASE}${PREFIX}%2E`).pathname).toBe(PREFIX); + expect(new URL(`${BASE}${PREFIX}%2E%2E`).pathname).toBe('/v1/'); + expect(new URL(`${BASE}${PREFIX}%2E%2E/ttl`).pathname).toBe('/v1/ttl'); + expect(new URL(`${BASE}${PREFIX}%2e%2e/lock`).pathname).toBe('/v1/lock'); + }); +}); + +describe('rule 2 — reserved segments are rejected before the URL is built', () => { + it('the vendored fixture reserves exactly the five spec tokens', () => { + expect(reserved.map((v) => v.key).sort()).toEqual(['.', '..', 'health', 'lock', 'ttl']); + }); + + it.each(reserved)('encodeKey($key) throws ConfigurationError', ({ key }) => { + expect(() => encodeKey(key)).toThrow(ConfigurationError); + }); + + // Case-sensitive, exact match — mirrors the SaaS router (`=== 'health'`, `=== 'ttl' || 'lock'`). + it.each(['...', 'HEALTH', 'ttls', 'unlock'])( + 'near-miss %j is transmittable and unchanged', + (key) => { + expect(encodeKey(key)).toBe(encodeURIComponent(key)); + } + ); + + it('a lone surrogate is a ConfigurationError, not a raw URIError', async () => { + expect(() => encodeKey('a\uD800')).toThrow(ConfigurationError); + const h = harness(); + await expect(h.core.get('a\uD800')).rejects.toBeInstanceOf(ConfigurationError); + expect(h.fetchSpy).not.toHaveBeenCalled(); + }); + + // Backend.validateKey lets CacheImpl reject synchronously, before the + // reliability executor could retry, count, and swallow the rejection. + it.each(reserved)('validateKey($key) throws on the core and both wrappers', ({ key }) => { + const h = harness(); + for (const backend of [h.core, h.ttl, h.lock]) { + expect(() => backend.validateKey(key)).toThrow(ConfigurationError); + } + }); + + describe.each(Object.entries(OPERATIONS))('%s', (_name, op) => { + it.each(reserved)( + 'rejects $key with ConfigurationError and never calls fetch', + async ({ key }) => { + const h = harness(); + await expect(op.run(h, key)).rejects.toBeInstanceOf(ConfigurationError); + expect(h.fetchSpy).not.toHaveBeenCalled(); + } + ); + }); +}); + +describe('empty-key precondition — rejected before the URL is built (no shared fixture vector yet)', () => { + // The empty key is the same CWE-22 escape class as the `.`/`..` reject rows — + // `/v1/cache/${''}` collapses to the `/v1/cache/` collection path — but it is + // not yet a row in the vendored cross-SDK fixture, so it is enforced here as a + // local precondition guard rather than claimed as a spec rule-2 vector. Adding + // the empty-key reject row to protocol/test-vectors/path-encoding.json (and + // re-vendoring) is tracked as cross-SDK parity follow-up. + it('the platform premise: an empty segment collapses to the collection path', () => { + expect(new URL(`${BASE}${PREFIX}${encodeURIComponent('')}`).pathname).toBe(PREFIX); + }); + + it('encodeKey("") throws ConfigurationError', () => { + expect(() => encodeKey('')).toThrow(ConfigurationError); + }); + + it('validateKey("") throws on the core and both wrappers', () => { + const h = harness(); + for (const backend of [h.core, h.ttl, h.lock]) { + expect(() => backend.validateKey('')).toThrow(ConfigurationError); + } + }); + + describe.each(Object.entries(OPERATIONS))('%s', (_name, op) => { + it('rejects "" with ConfigurationError and never calls fetch', async () => { + const h = harness(); + await expect(op.run(h, '')).rejects.toBeInstanceOf(ConfigurationError); + expect(h.fetchSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('rules 1, 3, 4 — transmittable keys travel as one segment and decode once to the key', () => { + it.each(transmittable)( + 'encodeKey($key) is a conformant wire form', + ({ key, encoded, encoded_alternates }) => { + expect([encoded, ...(encoded_alternates ?? [])]).toContain(encodeKey(key)); + expect(encodeKey(key)).toBe(encodeURIComponent(key)); + } + ); + + describe.each(Object.entries(OPERATIONS))('%s', (_name, op) => { + it.each(transmittable)( + 'sends $key inside /v1/cache/ as one segment', + async ({ key, decoded }) => { + const h = harness(); + await op.run(h, key); + const pathname = sentPathname(h); + expect(pathname).toBe(`${PREFIX}${encodeKey(key)}${op.suffix}`); + const segment = pathname.slice(PREFIX.length, pathname.length - op.suffix.length); + expect(decodeURIComponent(segment)).toBe(decoded); + } + ); + }); +});