From 1a1ffc2ef608bef149890c8855778401558a1903 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 11 Jun 2026 21:29:53 +1000 Subject: [PATCH] security: send lock_id via X-CacheKit-Lock-Id header, not query string (#63) releaseLock sent the lock capability token as ?lock_id= on the unlock DELETE. Query strings leak into access/proxy logs and OpenTelemetry http.url spans (CWE-532), allowing token replay within the lock TTL. Move the token to the X-CacheKit-Lock-Id request header (requestJson gains an optional headers arg) and drop it from the URL. SaaS dual-reads header + legacy ?lock_id= (deployed in saas 0.1.7), so this is wire-compatible with prod. Ratified in protocol spec/saas-api.md. Scope: the lock_id-header item of #63 only. The separate core 0.2.1 NAPI rebuild item remains tracked in #63. --- .../src/backends/cachekitio-lockable.test.ts | 12 ++++++++++++ .../cachekit/src/backends/cachekitio-lockable.ts | 14 ++++++++++++-- packages/cachekit/src/backends/cachekitio.ts | 9 +++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) 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 }, }); }