diff --git a/packages/cachekit/src/backends/cachekitio-lockable.test.ts b/packages/cachekit/src/backends/cachekitio-lockable.test.ts index ec79408..5bc810a 100644 --- a/packages/cachekit/src/backends/cachekitio-lockable.test.ts +++ b/packages/cachekit/src/backends/cachekitio-lockable.test.ts @@ -47,6 +47,18 @@ describe('LockableCachekitIO', () => { expect(await lockable.releaseLock('key', 'lock-id')).toBe(true); }); + it('releaseLock sends lock_id in the X-CacheKit-Lock-Id header, not the URL (CWE-532)', async () => { + fetchSpy.mockResolvedValueOnce(mockResponse(200, { success: true })); + expect(await lockable.releaseLock('key', 'lock-secret')).toBe(true); + const [url, opts] = fetchSpy.mock.calls[0]; + expect(opts.method).toBe('DELETE'); + // Capability token rides the header under the exact wire name... + expect(opts.headers['X-CacheKit-Lock-Id']).toBe('lock-secret'); + // ...and never appears in the URL (no query smuggling / log leak). + expect(url).not.toContain('lock_id'); + expect(url).not.toContain('lock-secret'); + }); + it('delegates get to inner backend', async () => { fetchSpy.mockResolvedValueOnce(new Response(null, { status: 404 })); expect(await lockable.get('missing')).toBeNull(); diff --git a/packages/cachekit/src/backends/cachekitio-lockable.ts b/packages/cachekit/src/backends/cachekitio-lockable.ts index 5d74e09..c6aae1f 100644 --- a/packages/cachekit/src/backends/cachekitio-lockable.ts +++ b/packages/cachekit/src/backends/cachekitio-lockable.ts @@ -3,6 +3,14 @@ import { CachekitIOCore } from './cachekitio.js'; import { BackendError, TimeoutError } from '../errors.js'; import { classifyHttpError, classifyNetworkError } from './error-classifier.js'; +/** + * Lock capability token travels in this request header, never the query string + * (CWE-532): a `?lock_id=` query leaks the token into access/proxy logs and + * OpenTelemetry `http.url` spans. SaaS dual-reads header + legacy query during + * rollout, preferring the header. See protocol spec/saas-api.md. + */ +const LOCK_ID_HEADER = 'X-CacheKit-Lock-Id'; + export class LockableCachekitIO implements LockableBackend { constructor(private readonly inner: CachekitIOCore) {} @@ -50,8 +58,10 @@ export class LockableCachekitIO implements LockableBackend { async releaseLock(key: string, lockId: string): Promise { try { - const url = `${this.inner['apiUrl']}/v1/cache/${encodeURIComponent(key)}/lock?lock_id=${encodeURIComponent(lockId)}`; - const response = await this.inner.requestJson('DELETE', url); + const url = `${this.inner['apiUrl']}/v1/cache/${encodeURIComponent(key)}/lock`; + const response = await this.inner.requestJson('DELETE', url, undefined, { + [LOCK_ID_HEADER]: lockId, + }); if (!response.ok) throw new BackendError( `Lock release failed (HTTP ${response.status})`, diff --git a/packages/cachekit/src/backends/cachekitio.ts b/packages/cachekit/src/backends/cachekitio.ts index 36f18aa..5a0e20f 100644 --- a/packages/cachekit/src/backends/cachekitio.ts +++ b/packages/cachekit/src/backends/cachekitio.ts @@ -157,10 +157,15 @@ export class CachekitIOCore implements Backend { } /** Package-internal: JSON request for lock/TTL decorators. Not part of public API. */ - async requestJson(method: string, url: string, body?: unknown): Promise { + async requestJson( + method: string, + url: string, + body?: unknown, + headers?: Record + ): Promise { return this.request(method, url, { body: body ? new TextEncoder().encode(JSON.stringify(body)) : undefined, - headers: body ? { 'Content-Type': 'application/json' } : {}, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers }, }); }