From ac5aa3fb90aaecdacee2e0e0fc5e43c910b82c39 Mon Sep 17 00:00:00 2001 From: branarakic Date: Sat, 25 Apr 2026 23:35:59 +0200 Subject: [PATCH 1/5] fix(cli): harden context graph ids and keystores Made-with: Cursor --- packages/cli/src/daemon/http-utils.ts | 9 +++- packages/cli/src/keystore.ts | 48 ++++++++++++++++--- .../cli/test/daemon-keystore-extra.test.ts | 6 +-- packages/cli/test/http-utils.test.ts | 28 +++++++++++ packages/cli/test/keystore.test.ts | 2 +- 5 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 packages/cli/test/http-utils.test.ts diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 462c71a57..9e4465823 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -579,7 +579,14 @@ export function isValidContextGraphId(id: string): boolean { if (!id || typeof id !== "string") return false; if (id.length > 256) return false; // Allow URNs, DIDs, simple slug-like identifiers, and URIs - return /^[\w:/.@\-]+$/.test(id); + if (!/^[\w:/.@\-]+$/.test(id)) return false; + if (id.includes('\\')) return false; + try { + const decoded = decodeURIComponent(id); + return !decoded.split('/').some((segment) => segment === '..' || segment === '.'); + } catch { + return false; + } } export function shortId(peerId: string): string { diff --git a/packages/cli/src/keystore.ts b/packages/cli/src/keystore.ts index 2e4b9e121..960b01053 100644 --- a/packages/cli/src/keystore.ts +++ b/packages/cli/src/keystore.ts @@ -38,15 +38,45 @@ let SCRYPT_N = 2 ** 18; const SCRYPT_R = 8; const SCRYPT_P = 1; const DKLEN = 32; +const MIN_SCRYPT_N = 2 ** 15; +const MIN_SCRYPT_R = 8; +const MIN_SCRYPT_P = 1; +const MIN_SALT_BYTES = 16; /** @internal Allow tests to use lighter scrypt params to avoid memory limits */ export function _setScryptN(n: number) { SCRYPT_N = n; } -function deriveKey(passphrase: string, salt: Buffer): Buffer { +function isPowerOfTwo(value: number): boolean { + return Number.isInteger(value) && value > 0 && Number.isInteger(Math.log2(value)); +} + +function assertSafeKdfParams(kdfparams: EncryptedKeystore['crypto']['kdfparams']): void { + if (!isPowerOfTwo(kdfparams.n) || kdfparams.n < MIN_SCRYPT_N) { + throw new Error('KDF parameters below minimum: scrypt N too low'); + } + if (!Number.isInteger(kdfparams.r) || kdfparams.r < MIN_SCRYPT_R) { + throw new Error('KDF parameters below minimum: scrypt r too low'); + } + if (!Number.isInteger(kdfparams.p) || kdfparams.p < MIN_SCRYPT_P) { + throw new Error('KDF parameters below minimum: scrypt p too low'); + } + if (kdfparams.dklen !== DKLEN) { + throw new Error(`Invalid dklen: dklen must be ${DKLEN}`); + } + if (!/^[0-9a-fA-F]+$/.test(kdfparams.salt) || kdfparams.salt.length % 2 !== 0 || kdfparams.salt.length < MIN_SALT_BYTES * 2) { + throw new Error(`KDF parameters below minimum: salt too short (minimum ${MIN_SALT_BYTES} bytes)`); + } +} + +function deriveKey( + passphrase: string, + salt: Buffer, + params: Pick, +): Buffer { return scryptSync(passphrase, salt, DKLEN, { - N: SCRYPT_N, - r: SCRYPT_R, - p: SCRYPT_P, + N: params.n, + r: params.r, + p: params.p, maxmem: 256 * 1024 * 1024, }); } @@ -56,7 +86,12 @@ export async function encryptKeystore( passphrase: string, ): Promise { const salt = randomBytes(32); - const key = deriveKey(passphrase, salt); + const key = deriveKey(passphrase, salt, { + n: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + dklen: DKLEN, + }); const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', key, iv); @@ -96,8 +131,9 @@ export async function decryptKeystore( } const { kdfparams } = keystore.crypto; + assertSafeKdfParams(kdfparams); const salt = Buffer.from(kdfparams.salt, 'hex'); - const key = deriveKey(passphrase, salt); + const key = deriveKey(passphrase, salt, kdfparams); const iv = Buffer.from(keystore.crypto.iv, 'hex'); const tag = Buffer.from(keystore.crypto.tag, 'hex'); diff --git a/packages/cli/test/daemon-keystore-extra.test.ts b/packages/cli/test/daemon-keystore-extra.test.ts index 830325be9..80addffb8 100644 --- a/packages/cli/test/daemon-keystore-extra.test.ts +++ b/packages/cli/test/daemon-keystore-extra.test.ts @@ -91,11 +91,11 @@ describe('CLI-1 — scrypt KDF parameter floor (PROD-BUG: not enforced)', () => // encrypted with. expect(weakKs.crypto.kdfparams.n).toBe(WEAK_N); - // Sanity: the "strong" keystore is rejected if we lie about its N - // (tampered kdfparams → wrong key → GCM auth failure). + // Sanity: the "strong" keystore is rejected if we lie about its N. + // The loader should fail at KDF validation before attempting GCM. await expect( decryptKeystore(withKdfParams(ks, { n: WEAK_N }), PASSPHRASE), - ).rejects.toThrow(/Decryption failed/); + ).rejects.toThrow(/KDF parameters below minimum|scrypt N too low|weak keystore/i); // PROD-BUG: the below call SHOULD throw "KDF parameters below minimum" // (or any rejection tied to the cost floor). Instead it returns the diff --git a/packages/cli/test/http-utils.test.ts b/packages/cli/test/http-utils.test.ts new file mode 100644 index 000000000..38ddaeec9 --- /dev/null +++ b/packages/cli/test/http-utils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { isValidContextGraphId } from '../src/daemon/http-utils.js'; + +describe('isValidContextGraphId', () => { + it('rejects traversal path segments', () => { + for (const id of [ + '../etc/passwd', + '../../root', + './../_private', + 'legit-cg/../../other-cg', + 'legit-cg/%2e%2e/other-cg', + ]) { + expect(isValidContextGraphId(id)).toBe(false); + } + }); + + it('keeps existing slug, DID, URN, and URL-style identifiers valid', () => { + for (const id of [ + 'devnet-test', + 'did:dkg:context-graph:devnet-test', + 'urn:dkg:project:smart-contracts', + 'https://example.org/context-graphs/devnet-test', + 'agent@example.org/context.graph-v1', + ]) { + expect(isValidContextGraphId(id)).toBe(true); + } + }); +}); diff --git a/packages/cli/test/keystore.test.ts b/packages/cli/test/keystore.test.ts index 17c896b91..b3f8105cb 100644 --- a/packages/cli/test/keystore.test.ts +++ b/packages/cli/test/keystore.test.ts @@ -8,7 +8,7 @@ import { } from '../src/keystore.js'; beforeAll(() => { - _setScryptN(2 ** 14); + _setScryptN(2 ** 15); }); const TEST_KEY = 'aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344'; From 4547d89f1dc69bcd8137351dad6cdf6605191355 Mon Sep 17 00:00:00 2001 From: branarakic Date: Sat, 25 Apr 2026 23:43:47 +0200 Subject: [PATCH 2/5] fix(cli): share context graph traversal validation Made-with: Cursor --- packages/cli/src/daemon/http-utils.ts | 13 ++----------- packages/cli/src/daemon/routes/context-graph.ts | 11 +++++++++++ .../cli/test/daemon-http-behavior-extra.test.ts | 12 ++++++++++++ packages/core/src/constants.ts | 9 +++++++++ packages/core/test/constants.test.ts | 6 ++++++ 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 9e4465823..6ef0f9384 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -576,17 +576,8 @@ export function shouldBypassRateLimitForLoopbackTraffic(ip: string, pathname: st } export function isValidContextGraphId(id: string): boolean { - if (!id || typeof id !== "string") return false; - if (id.length > 256) return false; - // Allow URNs, DIDs, simple slug-like identifiers, and URIs - if (!/^[\w:/.@\-]+$/.test(id)) return false; - if (id.includes('\\')) return false; - try { - const decoded = decodeURIComponent(id); - return !decoded.split('/').some((segment) => segment === '..' || segment === '.'); - } catch { - return false; - } + if (typeof id !== "string") return false; + return validateContextGraphId(id).valid; } export function shortId(peerId: string): string { diff --git a/packages/cli/src/daemon/routes/context-graph.ts b/packages/cli/src/daemon/routes/context-graph.ts index 74c77cff7..5c564295c 100644 --- a/packages/cli/src/daemon/routes/context-graph.ts +++ b/packages/cli/src/daemon/routes/context-graph.ts @@ -670,6 +670,7 @@ export async function handleContextGraphRoutes(ctx: RequestContext): Promise { expect(body.error).toMatch(/context graph id|invalid/i); }); } + + it('rejects encoded traversal in context-graph path-param routes', async () => { + const d = daemon!; + const encoded = encodeURIComponent('../etc/passwd'); + const res = await fetch(urlFor(d, `/api/context-graph/${encoded}/participants`), { + method: 'GET', + headers: authHeaders(d), + }); + expect(res.status).toBe(400); + const body = await res.json().catch(() => ({})); + expect(body.error).toMatch(/contextGraphId|context graph id|invalid/i); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 6ce86e3ef..6d8dfeabf 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -122,6 +122,15 @@ export function validateContextGraphId(id: string): { valid: boolean; reason?: s if (!id || id.length === 0) return { valid: false, reason: 'Context graph ID cannot be empty' }; if (id.length > 256) return { valid: false, reason: 'Context graph ID exceeds 256 characters' }; if (!/^[\w:/.@\-]+$/.test(id)) return { valid: false, reason: 'Context graph ID contains disallowed characters (allowed: alphanumeric, _, :, /, ., @, -)' }; + let decoded: string; + try { + decoded = decodeURIComponent(id); + } catch { + return { valid: false, reason: 'Context graph ID contains malformed percent-encoding' }; + } + if (decoded.split('/').some((segment) => segment === '.' || segment === '..')) { + return { valid: false, reason: 'Context graph ID cannot contain path traversal segments' }; + } return { valid: true }; } diff --git a/packages/core/test/constants.test.ts b/packages/core/test/constants.test.ts index 9d2831ac8..1f3ccfa67 100644 --- a/packages/core/test/constants.test.ts +++ b/packages/core/test/constants.test.ts @@ -110,6 +110,12 @@ describe('validateContextGraphId', () => { expect(validateContextGraphId('a'.repeat(257)).valid).toBe(false); expect(validateContextGraphId('a'.repeat(256)).valid).toBe(true); }); + + it('rejects literal and URL-encoded traversal path segments', () => { + expect(validateContextGraphId('../etc/passwd').valid).toBe(false); + expect(validateContextGraphId('legit-cg/../../other-cg').valid).toBe(false); + expect(validateContextGraphId('legit-cg/%2e%2e/other-cg').valid).toBe(false); + }); }); describe('validateAssertionName', () => { From b4f5ff6d97c9d0bd0fb3bacc70632d86d8d4caa4 Mon Sep 17 00:00:00 2001 From: branarakic Date: Sat, 25 Apr 2026 23:48:38 +0200 Subject: [PATCH 3/5] fix(cli): cap keystore KDF parameters Made-with: Cursor --- packages/cli/src/keystore.ts | 12 ++++++++++++ packages/cli/test/daemon-keystore-extra.test.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/cli/src/keystore.ts b/packages/cli/src/keystore.ts index 960b01053..3bd81d7e2 100644 --- a/packages/cli/src/keystore.ts +++ b/packages/cli/src/keystore.ts @@ -39,8 +39,11 @@ const SCRYPT_R = 8; const SCRYPT_P = 1; const DKLEN = 32; const MIN_SCRYPT_N = 2 ** 15; +const MAX_SCRYPT_N = 2 ** 18; const MIN_SCRYPT_R = 8; +const MAX_SCRYPT_R = SCRYPT_R; const MIN_SCRYPT_P = 1; +const MAX_SCRYPT_P = SCRYPT_P; const MIN_SALT_BYTES = 16; /** @internal Allow tests to use lighter scrypt params to avoid memory limits */ @@ -54,12 +57,21 @@ function assertSafeKdfParams(kdfparams: EncryptedKeystore['crypto']['kdfparams'] if (!isPowerOfTwo(kdfparams.n) || kdfparams.n < MIN_SCRYPT_N) { throw new Error('KDF parameters below minimum: scrypt N too low'); } + if (kdfparams.n > MAX_SCRYPT_N) { + throw new Error('Unsupported keystore KDF parameters: scrypt N too high'); + } if (!Number.isInteger(kdfparams.r) || kdfparams.r < MIN_SCRYPT_R) { throw new Error('KDF parameters below minimum: scrypt r too low'); } + if (kdfparams.r > MAX_SCRYPT_R) { + throw new Error('Unsupported keystore KDF parameters: scrypt r too high'); + } if (!Number.isInteger(kdfparams.p) || kdfparams.p < MIN_SCRYPT_P) { throw new Error('KDF parameters below minimum: scrypt p too low'); } + if (kdfparams.p > MAX_SCRYPT_P) { + throw new Error('Unsupported keystore KDF parameters: scrypt p too high'); + } if (kdfparams.dklen !== DKLEN) { throw new Error(`Invalid dklen: dklen must be ${DKLEN}`); } diff --git a/packages/cli/test/daemon-keystore-extra.test.ts b/packages/cli/test/daemon-keystore-extra.test.ts index 80addffb8..513788952 100644 --- a/packages/cli/test/daemon-keystore-extra.test.ts +++ b/packages/cli/test/daemon-keystore-extra.test.ts @@ -145,6 +145,18 @@ describe('CLI-1 — scrypt KDF parameter floor (PROD-BUG: not enforced)', () => ); }); + it('refuses KDF parameters above the supported envelope before calling scrypt', async () => { + _setScryptN(SAFE_N); + const ks = await encryptKeystore(PRIVKEY, PASSPHRASE); + + await expect(decryptKeystore(withKdfParams(ks, { n: 2 ** 30 }), PASSPHRASE)) + .rejects.toThrow(/scrypt N too high|unsupported keystore/i); + await expect(decryptKeystore(withKdfParams(ks, { r: 64 }), PASSPHRASE)) + .rejects.toThrow(/scrypt r too high|unsupported keystore/i); + await expect(decryptKeystore(withKdfParams(ks, { p: 64 }), PASSPHRASE)) + .rejects.toThrow(/scrypt p too high|unsupported keystore/i); + }); + it('refuses to decrypt a keystore with a short salt (<16 bytes)', async () => { _setScryptN(SAFE_N); const ks = await encryptKeystore(PRIVKEY, PASSPHRASE); From ee87bb3a0132eb8c11bdff8e0881f43b42b15987 Mon Sep 17 00:00:00 2001 From: branarakic Date: Sat, 25 Apr 2026 23:55:17 +0200 Subject: [PATCH 4/5] fix(cli): handle malformed context graph paths Made-with: Cursor --- .../cli/src/daemon/routes/context-graph.ts | 33 ++++++++++++------- packages/cli/src/keystore.ts | 21 +++++------- .../test/daemon-http-behavior-extra.test.ts | 11 +++++++ .../cli/test/daemon-keystore-extra.test.ts | 6 ++-- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/daemon/routes/context-graph.ts b/packages/cli/src/daemon/routes/context-graph.ts index 5c564295c..65b01698d 100644 --- a/packages/cli/src/daemon/routes/context-graph.ts +++ b/packages/cli/src/daemon/routes/context-graph.ts @@ -669,7 +669,8 @@ export async function handleContextGraphRoutes(ctx: RequestContext): Promise MAX_SCRYPT_N) { - throw new Error('Unsupported keystore KDF parameters: scrypt N too high'); - } - if (!Number.isInteger(kdfparams.r) || kdfparams.r < MIN_SCRYPT_R) { + if (!Number.isSafeInteger(kdfparams.r) || kdfparams.r < MIN_SCRYPT_R) { throw new Error('KDF parameters below minimum: scrypt r too low'); } - if (kdfparams.r > MAX_SCRYPT_R) { - throw new Error('Unsupported keystore KDF parameters: scrypt r too high'); - } - if (!Number.isInteger(kdfparams.p) || kdfparams.p < MIN_SCRYPT_P) { + if (!Number.isSafeInteger(kdfparams.p) || kdfparams.p < MIN_SCRYPT_P) { throw new Error('KDF parameters below minimum: scrypt p too low'); } + const estimatedMemoryBytes = 128 * kdfparams.n * kdfparams.r; + if (!Number.isSafeInteger(estimatedMemoryBytes) || estimatedMemoryBytes > MAX_SCRYPT_MEMORY_BYTES) { + throw new Error('Unsupported keystore KDF parameters: scrypt memory cost too high'); + } if (kdfparams.p > MAX_SCRYPT_P) { throw new Error('Unsupported keystore KDF parameters: scrypt p too high'); } diff --git a/packages/cli/test/daemon-http-behavior-extra.test.ts b/packages/cli/test/daemon-http-behavior-extra.test.ts index 6eb5d341c..d50af07c7 100644 --- a/packages/cli/test/daemon-http-behavior-extra.test.ts +++ b/packages/cli/test/daemon-http-behavior-extra.test.ts @@ -646,6 +646,17 @@ describe('CLI-16 — Path traversal in context-graph IDs', () => { const body = await res.json().catch(() => ({})); expect(body.error).toMatch(/contextGraphId|context graph id|invalid/i); }); + + it('rejects malformed percent-encoding in context-graph path-param routes', async () => { + const d = daemon!; + const res = await fetch(urlFor(d, '/api/context-graph/%E0%A4%A/participants'), { + method: 'GET', + headers: authHeaders(d), + }); + expect(res.status).toBe(400); + const body = await res.json().catch(() => ({})); + expect(body.error).toMatch(/percent-encoding|malformed/i); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/test/daemon-keystore-extra.test.ts b/packages/cli/test/daemon-keystore-extra.test.ts index 513788952..50a8bc3da 100644 --- a/packages/cli/test/daemon-keystore-extra.test.ts +++ b/packages/cli/test/daemon-keystore-extra.test.ts @@ -150,9 +150,9 @@ describe('CLI-1 — scrypt KDF parameter floor (PROD-BUG: not enforced)', () => const ks = await encryptKeystore(PRIVKEY, PASSPHRASE); await expect(decryptKeystore(withKdfParams(ks, { n: 2 ** 30 }), PASSPHRASE)) - .rejects.toThrow(/scrypt N too high|unsupported keystore/i); - await expect(decryptKeystore(withKdfParams(ks, { r: 64 }), PASSPHRASE)) - .rejects.toThrow(/scrypt r too high|unsupported keystore/i); + .rejects.toThrow(/memory cost too high|unsupported keystore/i); + await expect(decryptKeystore(withKdfParams(ks, { r: 65 }), PASSPHRASE)) + .rejects.toThrow(/memory cost too high|unsupported keystore/i); await expect(decryptKeystore(withKdfParams(ks, { p: 64 }), PASSPHRASE)) .rejects.toThrow(/scrypt p too high|unsupported keystore/i); }); From c887970286bb5efabbf31f74925081a63f67a372 Mon Sep 17 00:00:00 2001 From: branarakic Date: Sun, 26 Apr 2026 00:03:13 +0200 Subject: [PATCH 5/5] fix(cli): keep keystore writer and reader aligned Made-with: Cursor --- packages/cli/src/keystore.ts | 8 +++++++- .../cli/test/daemon-keystore-extra.test.ts | 20 ++----------------- packages/core/src/constants.ts | 8 +------- packages/core/test/constants.test.ts | 3 +-- 4 files changed, 11 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/keystore.ts b/packages/cli/src/keystore.ts index c8054d557..370cb454c 100644 --- a/packages/cli/src/keystore.ts +++ b/packages/cli/src/keystore.ts @@ -46,7 +46,13 @@ const MAX_SCRYPT_P = 16; const MIN_SALT_BYTES = 16; /** @internal Allow tests to use lighter scrypt params to avoid memory limits */ -export function _setScryptN(n: number) { SCRYPT_N = n; } +export function _setScryptN(n: number) { + const estimatedMemoryBytes = 128 * n * SCRYPT_R; + if (!Number.isSafeInteger(n) || !isPowerOfTwo(n) || n < MIN_SCRYPT_N || estimatedMemoryBytes > MAX_SCRYPT_MEMORY_BYTES) { + throw new Error('Unsupported scrypt N for keystore encryption'); + } + SCRYPT_N = n; +} function isPowerOfTwo(value: number): boolean { return Number.isInteger(value) && value > 0 && Number.isInteger(Math.log2(value)); diff --git a/packages/cli/test/daemon-keystore-extra.test.ts b/packages/cli/test/daemon-keystore-extra.test.ts index 50a8bc3da..ac2f51c65 100644 --- a/packages/cli/test/daemon-keystore-extra.test.ts +++ b/packages/cli/test/daemon-keystore-extra.test.ts @@ -80,16 +80,7 @@ describe('CLI-1 — scrypt KDF parameter floor (PROD-BUG: not enforced)', () => _setScryptN(SAFE_N); const ks = await encryptKeystore(PRIVKEY, PASSPHRASE); - // Re-encrypt using a toy N so we can faithfully construct a forged - // "weak" keystore (same ciphertext+IV+tag would not decrypt if we just - // mutated kdfparams because the derived key would differ). - _setScryptN(WEAK_N); - const weakKs = await encryptKeystore(PRIVKEY, PASSPHRASE); - _setScryptN(SAFE_N); - - // Sanity: this really is a weak keystore — the advertised N is the one we - // encrypted with. - expect(weakKs.crypto.kdfparams.n).toBe(WEAK_N); + await expect(() => _setScryptN(WEAK_N)).toThrow(/Unsupported scrypt N/); // Sanity: the "strong" keystore is rejected if we lie about its N. // The loader should fail at KDF validation before attempting GCM. @@ -97,14 +88,7 @@ describe('CLI-1 — scrypt KDF parameter floor (PROD-BUG: not enforced)', () => decryptKeystore(withKdfParams(ks, { n: WEAK_N }), PASSPHRASE), ).rejects.toThrow(/KDF parameters below minimum|scrypt N too low|weak keystore/i); - // PROD-BUG: the below call SHOULD throw "KDF parameters below minimum" - // (or any rejection tied to the cost floor). Instead it returns the - // plaintext — which means any attacker who can write a keystore file - // can force an O(1)-to-brute-force KDF. See issue #11. - // - // This assertion stays RED until `decryptKeystore` enforces N >= 2**15, - // r >= 8, p >= 1. Leaving red-on-purpose. - await expect(decryptKeystore(weakKs, PASSPHRASE)).rejects.toThrow( + await expect(decryptKeystore(withKdfParams(ks, { n: WEAK_N }), PASSPHRASE)).rejects.toThrow( /KDF parameters below minimum|scrypt cost too low|weak keystore/i, ); }); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 6d8dfeabf..27d2be7ed 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -122,13 +122,7 @@ export function validateContextGraphId(id: string): { valid: boolean; reason?: s if (!id || id.length === 0) return { valid: false, reason: 'Context graph ID cannot be empty' }; if (id.length > 256) return { valid: false, reason: 'Context graph ID exceeds 256 characters' }; if (!/^[\w:/.@\-]+$/.test(id)) return { valid: false, reason: 'Context graph ID contains disallowed characters (allowed: alphanumeric, _, :, /, ., @, -)' }; - let decoded: string; - try { - decoded = decodeURIComponent(id); - } catch { - return { valid: false, reason: 'Context graph ID contains malformed percent-encoding' }; - } - if (decoded.split('/').some((segment) => segment === '.' || segment === '..')) { + if (id.split('/').some((segment) => segment === '.' || segment === '..')) { return { valid: false, reason: 'Context graph ID cannot contain path traversal segments' }; } return { valid: true }; diff --git a/packages/core/test/constants.test.ts b/packages/core/test/constants.test.ts index 1f3ccfa67..256bd21ce 100644 --- a/packages/core/test/constants.test.ts +++ b/packages/core/test/constants.test.ts @@ -111,10 +111,9 @@ describe('validateContextGraphId', () => { expect(validateContextGraphId('a'.repeat(256)).valid).toBe(true); }); - it('rejects literal and URL-encoded traversal path segments', () => { + it('rejects literal traversal path segments', () => { expect(validateContextGraphId('../etc/passwd').valid).toBe(false); expect(validateContextGraphId('legit-cg/../../other-cg').valid).toBe(false); - expect(validateContextGraphId('legit-cg/%2e%2e/other-cg').valid).toBe(false); }); });