From d3165aae01415d3391fba2b69ee6a53af0a4b328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 10:35:26 -0500 Subject: [PATCH 1/8] fix: validate simulator create key grants --- src/simulator/fidelity.test.ts | 63 +++++++++++++++++++++++ src/simulator/index.ts | 94 +++++++++++++++++++++++++++++----- 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index cb257cb4..57ee88f8 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -613,6 +613,69 @@ describe('B2Simulator strictAuth: capability enforcement', () => { expect(expiredBody.code).toBe('expired_auth_token') }) + it('rejects unknown create-key capabilities', async () => { + const { client, sim } = makeClient({ sim: { strictAuth: true } }) + await client.authorize() + const resp = await sim.transport().send({ + method: 'POST', + url: 'http://localhost:0/b2api/v4/b2_create_key', + headers: { + Authorization: client.accountInfo.getAuthToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + accountId: 'sim_account_0001', + capabilities: [Capability.ListBuckets, 'notARealCapability'], + keyName: 'unknown-capability-key', + }), + }) + + expect(resp.status).toBe(400) + await expect(resp.json()).resolves.toMatchObject({ + code: 'bad_request', + message: expect.stringContaining('unknown capability'), + }) + }) + + it('rejects create-key capabilities outside the creator grant', async () => { + const { client, sim } = makeClient({ sim: { strictAuth: true } }) + await client.authorize() + const creatorKey = await client.createKey({ + capabilities: [Capability.WriteKeys, Capability.ListBuckets], + keyName: 'limited-key-admin', + }) + const creatorClient = await authorizeWithKey(sim, creatorKey) + + await expect( + creatorClient.createKey({ + capabilities: [Capability.ListBuckets, Capability.ReadFiles], + keyName: 'too-wide-child', + }), + ).rejects.toThrow(/exceed creator grant/) + await expect( + creatorClient.createKey({ + capabilities: [Capability.ListBuckets], + keyName: 'allowed-child', + }), + ).resolves.toMatchObject({ keyName: 'allowed-child' }) + }) + + it('enforces create-key keyName length bounds', async () => { + const { client } = makeClient({ sim: { strictAuth: true } }) + await client.authorize() + const maxLengthName = 'a'.repeat(100) + + await expect( + client.createKey({ capabilities: [Capability.ListBuckets], keyName: '' }), + ).rejects.toThrow(/keyName/) + await expect( + client.createKey({ capabilities: [Capability.ListBuckets], keyName: `${maxLengthName}a` }), + ).rejects.toThrow(/keyName/) + await expect( + client.createKey({ capabilities: [Capability.ListBuckets], keyName: maxLengthName }), + ).resolves.toMatchObject({ keyName: maxLengthName }) + }) + it('enforces single-bucket application key scope from the bucketId alias', async () => { const { client, sim } = makeClient({ sim: { strictAuth: true } }) await client.authorize() diff --git a/src/simulator/index.ts b/src/simulator/index.ts index 63f1e40b..a6ca9ad6 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -36,6 +36,10 @@ import { utf8Decoder, utf8Encoder } from '../util/text-codec.ts' import { toError } from '../util/to-error.ts' const UPLOAD_TOKEN_SIGNING_KEY = 'b2-sdk-typescript-simulator-upload-token-v1' +const ALL_CAPABILITIES = Object.freeze(Object.values(Capability) as Capability[]) +const ALL_CAPABILITY_VALUES: ReadonlySet = new Set(ALL_CAPABILITIES) +const KEY_NAME_MIN_LENGTH = 1 +const KEY_NAME_MAX_LENGTH = 100 function base64UrlEncode(bytes: Uint8Array): string { let binary = '' @@ -305,7 +309,7 @@ interface LargeFileInProgress { interface StoredKey { readonly applicationKeyId: string readonly keyName: string - readonly capabilities: readonly string[] + readonly capabilities: readonly Capability[] readonly accountId: string readonly applicationKey: string readonly bucketIds: readonly string[] | null @@ -429,7 +433,39 @@ function storedNotificationRulePrefixes( ) } -function hasKeyManagementCapability(capabilities: readonly string[]): boolean { +function isCapability(value: string): value is Capability { + return ALL_CAPABILITY_VALUES.has(value) +} + +function validateKeyName(keyName: unknown): ValidationError | null { + if ( + typeof keyName !== 'string' || + keyName.length < KEY_NAME_MIN_LENGTH || + keyName.length > KEY_NAME_MAX_LENGTH + ) { + return { + code: 'bad_request', + message: `keyName must be ${KEY_NAME_MIN_LENGTH}..${KEY_NAME_MAX_LENGTH} characters`, + } + } + return null +} + +function validateKeyCapabilities(capabilities: unknown): ValidationError | null { + if (!Array.isArray(capabilities)) { + return { code: 'bad_request', message: 'capabilities must be an array' } + } + for (const capability of capabilities) { + if (typeof capability === 'string' && isCapability(capability)) continue + return { + code: 'bad_request', + message: `unknown capability: ${String(capability)}`, + } + } + return null +} + +function hasKeyManagementCapability(capabilities: readonly Capability[]): boolean { return capabilities.some( (capability) => capability === Capability.ListKeys || @@ -2001,14 +2037,15 @@ export class B2Simulator { return this.createKey( body as { accountId: string - capabilities: string[] - keyName: string + capabilities: unknown + keyName: unknown validDurationInSeconds?: number bucketIds?: readonly string[] | null bucketId?: string namePrefix?: string }, apiVersion, + headers['authorization'], ) case 'b2_list_keys': return this.listKeys( @@ -3493,18 +3530,54 @@ export class B2Simulator { // --- Keys --- + private validateCreateKeyCapabilityGrant( + capabilities: readonly Capability[], + authToken: string | undefined, + ): SimulatorJsonResponse | null { + if (!this.strictAuth) return null + + const creator = authToken === undefined ? undefined : this.issuedTokens.get(authToken) + if (creator === undefined) { + return this.error(401, 'bad_auth_token', 'unknown auth token') + } + + // The implicit account credential is the simulator's root key; only + // application keys minted via b2_create_key are constrained here. + const grant = creator.applicationKeyId === null ? ALL_CAPABILITIES : creator.capabilities + const grantSet = new Set(grant) + const missing = capabilities.filter((capability) => !grantSet.has(capability)) + if (missing.length === 0) return null + + return this.error( + 400, + 'bad_request', + `requested capabilities exceed creator grant: ${missing.join(', ')}`, + ) + } + private createKey( req: { accountId: string - capabilities: string[] - keyName: string + capabilities: unknown + keyName: unknown validDurationInSeconds?: number bucketIds?: readonly string[] | null bucketId?: string namePrefix?: string }, apiVersion: string, + authToken?: string, ): SimulatorJsonResponse { + const keyNameError = validateKeyName(req.keyName) + if (keyNameError) return this.error(400, keyNameError.code, keyNameError.message) + const capabilitiesError = validateKeyCapabilities(req.capabilities) + if (capabilitiesError) { + return this.error(400, capabilitiesError.code, capabilitiesError.message) + } + const keyName = req.keyName as string + const capabilities = Object.freeze([...(req.capabilities as Capability[])]) + const capabilityGrantError = this.validateCreateKeyCapabilityGrant(capabilities, authToken) + if (capabilityGrantError !== null) return capabilityGrantError if (apiVersion === 'v4' && hasOwnField(req, 'bucketId')) { return this.error( 400, @@ -3520,10 +3593,7 @@ export class B2Simulator { ? Object.freeze([req.bucketId]) : normalizeKeyBucketIds(req) const namePrefix = req.namePrefix === undefined || req.namePrefix === '' ? null : req.namePrefix - if ( - hasKeyManagementCapability(req.capabilities) && - (bucketIds !== null || namePrefix !== null) - ) { + if (hasKeyManagementCapability(capabilities) && (bucketIds !== null || namePrefix !== null)) { return this.error( 400, 'bad_request', @@ -3538,8 +3608,8 @@ export class B2Simulator { : null const stored: StoredKey = { applicationKeyId: kid, - keyName: req.keyName, - capabilities: req.capabilities, + keyName, + capabilities, accountId: req.accountId, applicationKey: appKey, bucketIds, From 131ef7b6987986400f4a5824273ca0697ce2ce03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 10:49:02 -0500 Subject: [PATCH 2/8] fix: tighten simulator create key validation --- src/simulator/fidelity.test.ts | 116 +++++++++++++++++++------------ src/simulator/index.ts | 67 +++++------------- src/simulator/validation.test.ts | 34 +++++++++ src/simulator/validation.ts | 72 +++++++++++++++++++ 4 files changed, 195 insertions(+), 94 deletions(-) diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index 57ee88f8..6b6376b8 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -15,6 +15,7 @@ import { type B2Simulator, DOWNLOAD_AUTH_DURATION_MAX_SECONDS, DOWNLOAD_AUTH_DURATION_MIN_SECONDS, + KEY_NAME_MAX, } from './index.ts' /** @@ -531,7 +532,7 @@ describe('B2Simulator hooks: onWebhookDeliver', () => { // Strict-auth mode // --------------------------------------------------------------------------- -describe('B2Simulator strictAuth: capability enforcement', () => { +describe('B2Simulator capability enforcement', () => { async function authorizeWithKey( sim: B2Simulator, key: { applicationKeyId: string; applicationKey: string }, @@ -558,6 +559,22 @@ describe('B2Simulator strictAuth: capability enforcement', () => { ).downloadAuthorizationTokens.size } + async function sendCreateKey( + sim: B2Simulator, + authToken: string, + body: Record, + ): Promise { + return await sim.transport().send({ + method: 'POST', + url: 'http://localhost:0/b2api/v4/b2_create_key', + headers: { + Authorization: authToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ accountId: 'sim_account_0001', ...body }), + }) + } + it('grants the master credential the documented capability set by default', async () => { const { client } = makeClient({ sim: { strictAuth: true } }) await client.authorize() @@ -613,67 +630,76 @@ describe('B2Simulator strictAuth: capability enforcement', () => { expect(expiredBody.code).toBe('expired_auth_token') }) - it('rejects unknown create-key capabilities', async () => { - const { client, sim } = makeClient({ sim: { strictAuth: true } }) + it('rejects malformed create-key inputs in default mode', async () => { + const { client, sim } = makeClient() await client.authorize() - const resp = await sim.transport().send({ - method: 'POST', - url: 'http://localhost:0/b2api/v4/b2_create_key', - headers: { - Authorization: client.accountInfo.getAuthToken(), - 'Content-Type': 'application/json', + const authToken = client.accountInfo.getAuthToken() + const maxLengthName = 'a'.repeat(KEY_NAME_MAX) + + for (const { body, message } of [ + { + body: { + capabilities: [Capability.ListBuckets, 'notARealCapability'], + keyName: 'unknown-capability-key', + }, + message: 'unknown capability', }, - body: JSON.stringify({ - accountId: 'sim_account_0001', - capabilities: [Capability.ListBuckets, 'notARealCapability'], - keyName: 'unknown-capability-key', - }), - }) + { + body: { capabilities: [], keyName: 'empty-capability-key' }, + message: 'non-empty array', + }, + { + body: { capabilities: [Capability.ListBuckets], keyName: 'bad\nname' }, + message: 'letters, digits, and hyphens', + }, + { + body: { capabilities: [Capability.ListBuckets], keyName: '' }, + message: 'keyName', + }, + { + body: { capabilities: [Capability.ListBuckets], keyName: `${maxLengthName}a` }, + message: 'keyName', + }, + ]) { + const resp = await sendCreateKey(sim, authToken, body) + expect(resp.status).toBe(400) + await expect(resp.json()).resolves.toMatchObject({ + code: 'bad_request', + message: expect.stringContaining(message), + }) + } - expect(resp.status).toBe(400) - await expect(resp.json()).resolves.toMatchObject({ - code: 'bad_request', - message: expect.stringContaining('unknown capability'), - }) + await expect( + client.createKey({ capabilities: [Capability.ListBuckets], keyName: maxLengthName }), + ).resolves.toMatchObject({ keyName: maxLengthName }) }) - it('rejects create-key capabilities outside the creator grant', async () => { - const { client, sim } = makeClient({ sim: { strictAuth: true } }) + it.each([ + false, + true, + ])('rejects create-key capabilities outside the creator grant (strictAuth=%s)', async (strictAuth) => { + const { client, sim } = strictAuth ? makeClient({ sim: { strictAuth: true } }) : makeClient() await client.authorize() const creatorKey = await client.createKey({ - capabilities: [Capability.WriteKeys, Capability.ListBuckets], - keyName: 'limited-key-admin', + capabilities: [Capability.WriteKeys], + keyName: strictAuth ? 'limited-key-admin-strict' : 'limited-key-admin-default', }) const creatorClient = await authorizeWithKey(sim, creatorKey) await expect( creatorClient.createKey({ - capabilities: [Capability.ListBuckets, Capability.ReadFiles], - keyName: 'too-wide-child', + capabilities: [Capability.ReadFiles], + keyName: strictAuth ? 'too-wide-child-strict' : 'too-wide-child-default', }), ).rejects.toThrow(/exceed creator grant/) await expect( creatorClient.createKey({ - capabilities: [Capability.ListBuckets], - keyName: 'allowed-child', + capabilities: [Capability.WriteKeys], + keyName: strictAuth ? 'allowed-child-strict' : 'allowed-child-default', }), - ).resolves.toMatchObject({ keyName: 'allowed-child' }) - }) - - it('enforces create-key keyName length bounds', async () => { - const { client } = makeClient({ sim: { strictAuth: true } }) - await client.authorize() - const maxLengthName = 'a'.repeat(100) - - await expect( - client.createKey({ capabilities: [Capability.ListBuckets], keyName: '' }), - ).rejects.toThrow(/keyName/) - await expect( - client.createKey({ capabilities: [Capability.ListBuckets], keyName: `${maxLengthName}a` }), - ).rejects.toThrow(/keyName/) - await expect( - client.createKey({ capabilities: [Capability.ListBuckets], keyName: maxLengthName }), - ).resolves.toMatchObject({ keyName: maxLengthName }) + ).resolves.toMatchObject({ + keyName: strictAuth ? 'allowed-child-strict' : 'allowed-child-default', + }) }) it('enforces single-bucket application key scope from the bucketId alias', async () => { diff --git a/src/simulator/index.ts b/src/simulator/index.ts index a6ca9ad6..486169d5 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -36,10 +36,7 @@ import { utf8Decoder, utf8Encoder } from '../util/text-codec.ts' import { toError } from '../util/to-error.ts' const UPLOAD_TOKEN_SIGNING_KEY = 'b2-sdk-typescript-simulator-upload-token-v1' -const ALL_CAPABILITIES = Object.freeze(Object.values(Capability) as Capability[]) -const ALL_CAPABILITY_VALUES: ReadonlySet = new Set(ALL_CAPABILITIES) -const KEY_NAME_MIN_LENGTH = 1 -const KEY_NAME_MAX_LENGTH = 100 +const MASTER_CAPABILITY_GRANT = Object.freeze(Object.values(Capability) as Capability[]) function base64UrlEncode(bytes: Uint8Array): string { let binary = '' @@ -189,6 +186,8 @@ import { validateDownloadAuthorizationPrefix, validateFileInfo, validateFileName, + validateKeyCapabilities, + validateKeyName, validateMaxCount, validateNotificationRules, } from './validation.ts' @@ -206,6 +205,8 @@ export { FILE_INFO_TOTAL_MAX, FILE_INFO_VALUE_MAX, FILE_NAME_MAX_BYTES, + KEY_NAME_MAX, + KEY_NAME_MIN, LIST_ENDPOINT_CAPS, } from './validation.ts' @@ -433,38 +434,6 @@ function storedNotificationRulePrefixes( ) } -function isCapability(value: string): value is Capability { - return ALL_CAPABILITY_VALUES.has(value) -} - -function validateKeyName(keyName: unknown): ValidationError | null { - if ( - typeof keyName !== 'string' || - keyName.length < KEY_NAME_MIN_LENGTH || - keyName.length > KEY_NAME_MAX_LENGTH - ) { - return { - code: 'bad_request', - message: `keyName must be ${KEY_NAME_MIN_LENGTH}..${KEY_NAME_MAX_LENGTH} characters`, - } - } - return null -} - -function validateKeyCapabilities(capabilities: unknown): ValidationError | null { - if (!Array.isArray(capabilities)) { - return { code: 'bad_request', message: 'capabilities must be an array' } - } - for (const capability of capabilities) { - if (typeof capability === 'string' && isCapability(capability)) continue - return { - code: 'bad_request', - message: `unknown capability: ${String(capability)}`, - } - } - return null -} - function hasKeyManagementCapability(capabilities: readonly Capability[]): boolean { return capabilities.some( (capability) => @@ -3533,26 +3502,24 @@ export class B2Simulator { private validateCreateKeyCapabilityGrant( capabilities: readonly Capability[], authToken: string | undefined, - ): SimulatorJsonResponse | null { - if (!this.strictAuth) return null - + ): ValidationError | null { const creator = authToken === undefined ? undefined : this.issuedTokens.get(authToken) - if (creator === undefined) { - return this.error(401, 'bad_auth_token', 'unknown auth token') - } + // In strictAuth mode, handleRequest already validates this token + // before dispatch. In default mode, a missing or unknown token stays + // permissive and cannot supply a narrower creator grant. + if (creator === undefined) return null // The implicit account credential is the simulator's root key; only // application keys minted via b2_create_key are constrained here. - const grant = creator.applicationKeyId === null ? ALL_CAPABILITIES : creator.capabilities + const grant = creator.applicationKeyId === null ? MASTER_CAPABILITY_GRANT : creator.capabilities const grantSet = new Set(grant) const missing = capabilities.filter((capability) => !grantSet.has(capability)) if (missing.length === 0) return null - return this.error( - 400, - 'bad_request', - `requested capabilities exceed creator grant: ${missing.join(', ')}`, - ) + return { + code: 'bad_request', + message: `requested capabilities exceed creator grant: ${missing.join(', ')}`, + } } private createKey( @@ -3577,7 +3544,9 @@ export class B2Simulator { const keyName = req.keyName as string const capabilities = Object.freeze([...(req.capabilities as Capability[])]) const capabilityGrantError = this.validateCreateKeyCapabilityGrant(capabilities, authToken) - if (capabilityGrantError !== null) return capabilityGrantError + if (capabilityGrantError !== null) { + return this.error(400, capabilityGrantError.code, capabilityGrantError.message) + } if (apiVersion === 'v4' && hasOwnField(req, 'bucketId')) { return this.error( 400, diff --git a/src/simulator/validation.test.ts b/src/simulator/validation.test.ts index 8594cc97..16f99f7a 100644 --- a/src/simulator/validation.test.ts +++ b/src/simulator/validation.test.ts @@ -10,6 +10,7 @@ import { FILE_INFO_TOTAL_MAX, FILE_INFO_VALUE_MAX, FILE_NAME_MAX_BYTES, + KEY_NAME_MAX, LIST_ENDPOINT_CAPS, validateBucketInfo, validateBucketName, @@ -17,6 +18,8 @@ import { validateDownloadAuthorizationPrefix, validateFileInfo, validateFileName, + validateKeyCapabilities, + validateKeyName, validateMaxCount, validateNotificationRules, } from './validation.ts' @@ -32,6 +35,37 @@ import { * these tests. */ +describe('validateKeyName', () => { + it('returns null for a valid key name at the documented max length', () => { + expect(validateKeyName('valid-key-1')).toBeNull() + expect(validateKeyName('a'.repeat(KEY_NAME_MAX))).toBeNull() + }) + + it('rejects empty and oversized key names', () => { + expect(validateKeyName('')?.code).toBe('bad_request') + expect(validateKeyName('a'.repeat(KEY_NAME_MAX + 1))?.code).toBe('bad_request') + }) + + it('rejects key names outside the documented character set', () => { + expect(validateKeyName('bad_name')?.code).toBe('bad_request') + expect(validateKeyName('bad\nname')?.code).toBe('bad_request') + }) +}) + +describe('validateKeyCapabilities', () => { + it('returns null for a non-empty list of known capabilities', () => { + expect(validateKeyCapabilities([Capability.ListBuckets])).toBeNull() + }) + + it('rejects an empty capability list', () => { + expect(validateKeyCapabilities([])?.code).toBe('bad_request') + }) + + it('rejects unknown capabilities', () => { + expect(validateKeyCapabilities(['notARealCapability'])?.code).toBe('bad_request') + }) +}) + describe('validateBucketName', () => { it('returns null for a valid name', () => { expect(validateBucketName('valid-bucket')).toBeNull() diff --git a/src/simulator/validation.ts b/src/simulator/validation.ts index 646473c8..6b0f5465 100644 --- a/src/simulator/validation.ts +++ b/src/simulator/validation.ts @@ -24,6 +24,7 @@ import { hasValidB2BucketNameShape, isB2BucketNameIpv4Address, } from '../internal/b2-naming.ts' +import { Capability } from '../types/auth.ts' import { EventType } from '../types/notifications.ts' import { utf8Encoder } from '../util/text-codec.ts' @@ -33,6 +34,77 @@ export interface ValidationError { readonly message: string } +// --------------------------------------------------------------------------- +// Application keys (`b2_create_key`) +// --------------------------------------------------------------------------- + +/** Minimum `b2_create_key.keyName` length. */ +export const KEY_NAME_MIN = 1 +/** Maximum `b2_create_key.keyName` length. */ +export const KEY_NAME_MAX = 100 +const KEY_NAME_REGEX = /^[A-Za-z0-9-]+$/ +const KNOWN_CAPABILITY_SET: ReadonlySet = new Set(Object.values(Capability)) + +function isCapability(value: string): value is Capability { + return KNOWN_CAPABILITY_SET.has(value) +} + +/** + * Validates an application key name against B2's documented rules. + * + * @param keyName - Caller-supplied key name. + * + * @returns A `{ code, message }` pair on failure, or `null` when valid. + * + * @see https://www.backblaze.com/apidocs/b2-create-key + */ +export function validateKeyName(keyName: unknown): ValidationError | null { + if ( + typeof keyName !== 'string' || + keyName.length < KEY_NAME_MIN || + keyName.length > KEY_NAME_MAX + ) { + return { + code: 'bad_request', + message: `keyName must be ${KEY_NAME_MIN}..${KEY_NAME_MAX} characters`, + } + } + if (!KEY_NAME_REGEX.test(keyName)) { + return { + code: 'bad_request', + message: 'keyName must contain only letters, digits, and hyphens', + } + } + return null +} + +/** + * Validates an application key capability list against the supported + * B2 capability enum. + * + * @param capabilities - Caller-supplied capability list. + * + * @returns A `{ code, message }` pair on failure, or `null` when valid. + * + * @see https://www.backblaze.com/apidocs/b2-create-key + */ +export function validateKeyCapabilities(capabilities: unknown): ValidationError | null { + if (!Array.isArray(capabilities)) { + return { code: 'bad_request', message: 'capabilities must be an array' } + } + if (capabilities.length === 0) { + return { code: 'bad_request', message: 'capabilities must be a non-empty array' } + } + for (const capability of capabilities) { + if (typeof capability === 'string' && isCapability(capability)) continue + return { + code: 'bad_request', + message: `unknown capability: ${String(capability)}`, + } + } + return null +} + // --------------------------------------------------------------------------- // Bucket name (`b2_create_bucket`, `b2_update_bucket`) // --------------------------------------------------------------------------- From 317046079b069c44ce5bcf46dfcc79e19e20e8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 11:10:19 -0500 Subject: [PATCH 3/8] fix: constrain simulator create key scope --- CHANGELOG.md | 5 + src/client.test.ts | 6 ++ src/simulator/fidelity.test.ts | 147 +++++++++++++++++++++++++++-- src/simulator/index.ts | 153 ++++++++++++++++++------------- src/simulator/validation.test.ts | 4 +- src/simulator/validation.ts | 45 +++++++-- 6 files changed, 283 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0493acb0..d87b79ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **B2Simulator create-key validation now matches more B2 constraints.** `b2_create_key` + rejects unknown capabilities, key names outside B2's 1..100 + letters/digits/hyphen grammar, child keys that exceed the creator token's + capabilities, bucket scope, or name prefix, and mismatched request `accountId` + values instead of storing over-broad simulator keys. - **Real-B2 integration evidence is explicit and diagnosable.** Same-repo integration workflow runs now fail when required B2 secrets are missing instead of silently accepting an all-skipped suite, integration setup logs per-step diff --git a/src/client.test.ts b/src/client.test.ts index 1e07e535..2950d5ff 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1493,6 +1493,12 @@ describe('B2Client.hasCapabilities', () => { }) it('returns ok: false with the missing list when capabilities are absent', () => { + const auth = client.accountInfo.getAuth() + if (auth === null) throw new Error('expected authorized client') + ;(auth.apiInfo.storageApi.allowed as { capabilities: readonly Capability[] }).capabilities = [ + Capability.ListBuckets, + ] + const result = client.hasCapabilities([Capability.ListBuckets, Capability.BypassGovernance]) expect(result.ok).toBe(false) expect(result.missing).toEqual([Capability.BypassGovernance]) diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index 6b6376b8..ccdaf3ad 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -559,6 +559,49 @@ describe('B2Simulator capability enforcement', () => { ).downloadAuthorizationTokens.size } + let seededKeyCounter = 0 + + function seedApplicationKey( + sim: B2Simulator, + options: { + capabilities: readonly Capability[] + bucketIds?: readonly string[] | null + namePrefix?: string | null + }, + ): { applicationKeyId: string; applicationKey: string } { + seededKeyCounter += 1 + const applicationKeyId = `seed-key-${seededKeyCounter}` + const applicationKey = `seed-secret-${seededKeyCounter}` + const keys = ( + sim as unknown as { + readonly keys: Map< + string, + { + readonly applicationKeyId: string + readonly keyName: string + readonly capabilities: readonly Capability[] + readonly accountId: string + readonly applicationKey: string + readonly bucketIds: readonly string[] | null + readonly namePrefix: string | null + readonly expirationTimestamp: number | null + } + > + } + ).keys + keys.set(applicationKeyId, { + applicationKeyId, + keyName: `seed-key-${seededKeyCounter}`, + capabilities: options.capabilities, + accountId: 'sim_account_0001', + applicationKey, + bucketIds: options.bucketIds ?? null, + namePrefix: options.namePrefix ?? null, + expirationTimestamp: null, + }) + return { applicationKeyId, applicationKey } + } + async function sendCreateKey( sim: B2Simulator, authToken: string, @@ -583,9 +626,8 @@ describe('B2Simulator capability enforcement', () => { expect(allowed?.capabilities).toContain(Capability.WriteFiles) expect(allowed?.capabilities).toContain(Capability.ListBuckets) expect(allowed?.buckets).toBeNull() - // Master does NOT have BypassGovernance — tests that need it must - // mint a key with that cap explicitly. - expect(allowed?.capabilities).not.toContain(Capability.BypassGovernance) + expect(allowed?.capabilities).toContain(Capability.BypassGovernance) + expect(allowed?.capabilities).toContain(Capability.WriteFileRetentions) }) it('rejects with 401 when the auth token is unknown', async () => { @@ -644,10 +686,6 @@ describe('B2Simulator capability enforcement', () => { }, message: 'unknown capability', }, - { - body: { capabilities: [], keyName: 'empty-capability-key' }, - message: 'non-empty array', - }, { body: { capabilities: [Capability.ListBuckets], keyName: 'bad\nname' }, message: 'letters, digits, and hyphens', @@ -674,6 +712,42 @@ describe('B2Simulator capability enforcement', () => { ).resolves.toMatchObject({ keyName: maxLengthName }) }) + it('uses the advertised master capabilities as the create-key grant ceiling', async () => { + const { client } = makeClient({ sim: { strictAuth: true } }) + await client.authorize() + const allowed = client.accountInfo.getAuth()?.apiInfo.storageApi.allowed.capabilities + + expect(allowed).toContain(Capability.BypassGovernance) + expect(allowed).toContain(Capability.WriteFileRetentions) + await expect( + client.createKey({ + capabilities: [Capability.BypassGovernance, Capability.WriteFileRetentions], + keyName: 'master-lock-grant', + }), + ).resolves.toMatchObject({ + capabilities: expect.arrayContaining([ + Capability.BypassGovernance, + Capability.WriteFileRetentions, + ]), + }) + }) + + it('rejects create-key accountId values outside the authenticated account', async () => { + const { client, sim } = makeClient() + await client.authorize() + const resp = await sendCreateKey(sim, client.accountInfo.getAuthToken(), { + accountId: 'sim_account_9999', + capabilities: [Capability.ListBuckets], + keyName: 'wrong-account-key', + }) + + expect(resp.status).toBe(400) + await expect(resp.json()).resolves.toMatchObject({ + code: 'bad_request', + message: expect.stringContaining('accountId'), + }) + }) + it.each([ false, true, @@ -702,6 +776,65 @@ describe('B2Simulator capability enforcement', () => { }) }) + it.each([ + false, + true, + ])('rejects create-key scope outside the creator grant (strictAuth=%s)', async (strictAuth) => { + const { client, sim } = strictAuth ? makeClient({ sim: { strictAuth: true } }) : makeClient() + await client.authorize() + const allowedBucket = await client.createBucket({ + bucketName: strictAuth ? 'scope-allowed-strict' : 'scope-allowed-default', + bucketType: BucketType.AllPrivate, + }) + const blockedBucket = await client.createBucket({ + bucketName: strictAuth ? 'scope-blocked-strict' : 'scope-blocked-default', + bucketType: BucketType.AllPrivate, + }) + const creatorKey = seedApplicationKey(sim, { + capabilities: [Capability.WriteKeys, Capability.ReadFiles], + bucketIds: [allowedBucket.id], + namePrefix: 'tenant/', + }) + const creatorClient = await authorizeWithKey(sim, creatorKey) + const keyNamePrefix = strictAuth ? 'strict' : 'default' + + await expect( + creatorClient.createKey({ + capabilities: [Capability.ReadFiles], + keyName: `${keyNamePrefix}-all-buckets-child`, + bucketIds: null, + namePrefix: 'tenant/', + }), + ).rejects.toThrow(/bucket scope/) + await expect( + creatorClient.createKey({ + capabilities: [Capability.ReadFiles], + keyName: `${keyNamePrefix}-blocked-bucket-child`, + bucketIds: [blockedBucket.id], + namePrefix: 'tenant/', + }), + ).rejects.toThrow(/bucket scope/) + await expect( + creatorClient.createKey({ + capabilities: [Capability.ReadFiles], + keyName: `${keyNamePrefix}-wide-prefix-child`, + bucketIds: [allowedBucket.id], + namePrefix: 'other/', + }), + ).rejects.toThrow(/namePrefix/) + await expect( + creatorClient.createKey({ + capabilities: [Capability.ReadFiles], + keyName: `${keyNamePrefix}-narrow-prefix-child`, + bucketIds: [allowedBucket.id], + namePrefix: 'tenant/narrow/', + }), + ).resolves.toMatchObject({ + bucketIds: [allowedBucket.id], + namePrefix: 'tenant/narrow/', + }) + }) + it('enforces single-bucket application key scope from the bucketId alias', async () => { const { client, sim } = makeClient({ sim: { strictAuth: true } }) await client.authorize() diff --git a/src/simulator/index.ts b/src/simulator/index.ts index 486169d5..852e5564 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -36,7 +36,7 @@ import { utf8Decoder, utf8Encoder } from '../util/text-codec.ts' import { toError } from '../util/to-error.ts' const UPLOAD_TOKEN_SIGNING_KEY = 'b2-sdk-typescript-simulator-upload-token-v1' -const MASTER_CAPABILITY_GRANT = Object.freeze(Object.values(Capability) as Capability[]) +const MASTER_CAPABILITIES = Object.freeze(Object.values(Capability) as Capability[]) function base64UrlEncode(bytes: Uint8Array): string { let binary = '' @@ -179,6 +179,8 @@ function compareB2FileNames(a: string, b: string): number { import { missingCapabilitiesFor } from './capabilities.ts' import { + parseKeyCapabilities, + parseKeyName, type ValidationError, validateBucketInfo, validateBucketName, @@ -186,8 +188,6 @@ import { validateDownloadAuthorizationPrefix, validateFileInfo, validateFileName, - validateKeyCapabilities, - validateKeyName, validateMaxCount, validateNotificationRules, } from './validation.ts' @@ -320,6 +320,7 @@ interface StoredKey { interface IssuedToken { readonly capabilities: readonly Capability[] + readonly accountId: string readonly bucketIds: readonly string[] | null readonly namePrefix: string | null readonly expiresAt: number @@ -1735,6 +1736,7 @@ export class B2Simulator { */ private findKeyForAuthHeader(authzHeader: string | undefined): { capabilities: readonly Capability[] + accountId: string bucketIds: readonly string[] | null namePrefix: string | null applicationKeyId: string @@ -1757,7 +1759,8 @@ export class B2Simulator { const stored = this.keys.get(applicationKeyId) if (!stored || stored.applicationKey !== applicationKey) return null return { - capabilities: stored.capabilities as readonly Capability[], + capabilities: stored.capabilities, + accountId: stored.accountId, bucketIds: stored.bucketIds, namePrefix: stored.namePrefix, applicationKeyId, @@ -2005,7 +2008,7 @@ export class B2Simulator { case 'b2_create_key': return this.createKey( body as { - accountId: string + accountId: unknown capabilities: unknown keyName: unknown validDurationInSeconds?: number @@ -2561,49 +2564,22 @@ export class B2Simulator { authzHeader?: string, origin = 'http://localhost:0', ): { status: number; body: AuthorizeAccountResponse } { - // Master capabilities granted to the implicit "test" credential. - // Real B2 derives the capability list from the application key the - // caller authorized with; in permissive mode every auth call gets - // the full set so existing tests don't have to construct keys - // first. Strict-mode tests that need a restricted scope authorize - // with a specific app-key first via b2_create_key, then call - // authorize-with-that-key (today's simulator returns this full - // set regardless — strict-mode test seam is in `authorizeRequest` - // which consults the issued-token map, not the response body). - // Note: object-lock-related capabilities (BypassGovernance, - // WriteFileLegalHolds, WriteFileRetentions) are intentionally - // omitted from the master grant. Real B2 doesn't auto-grant these - // either — they're opt-in scopes set via b2_create_key. Tests that - // need them explicit-issue a key via the simulator's createKey - // handler and reauth with that key. - const capabilities: readonly Capability[] = [ - Capability.ListBuckets, - Capability.ReadBuckets, - Capability.WriteBuckets, - Capability.DeleteBuckets, - Capability.ListFiles, - Capability.ReadFiles, - Capability.WriteFiles, - Capability.DeleteFiles, - Capability.ListKeys, - Capability.WriteKeys, - Capability.DeleteKeys, - Capability.ShareFiles, - Capability.ReadBucketNotifications, - Capability.WriteBucketNotifications, - ] // Token validity: real B2 = 24h; configurable via `authTokenTtlMs`. - // If a key was previously authorized via `authorizeAsKey` (test - // seam, see `authorizeAsKey` below), the auth header identifies - // it and the issued token inherits that key's scope. + // If the auth header identifies a key minted via b2_create_key, the + // issued token inherits that key's capability and scope grant. const keyForAuth = this.findKeyForAuthHeader(authzHeader) + // Master capabilities granted to the implicit "test" credential. + // Keep this shared with create-key grant enforcement so the token's + // advertised capabilities are the only ceiling for child keys. + const capabilities: readonly Capability[] = keyForAuth?.capabilities ?? MASTER_CAPABILITIES const allowedBuckets = this.allowedBuckets(keyForAuth?.bucketIds) const legacyBucketId = singleBucketId(keyForAuth?.bucketIds) const legacyBucketName = legacyBucketId === null ? null : (this.buckets.get(legacyBucketId)?.info.bucketName ?? null) const tokenStr = `sim_auth_token_${this.nextId++}` this.issuedTokens.set(tokenStr, { - capabilities: keyForAuth?.capabilities ?? capabilities, + capabilities, + accountId: keyForAuth?.accountId ?? this.accountId, bucketIds: keyForAuth?.bucketIds ?? null, namePrefix: keyForAuth?.namePrefix ?? null, expiresAt: this.now() + this.authTokenTtlMs, @@ -3500,19 +3476,12 @@ export class B2Simulator { // --- Keys --- private validateCreateKeyCapabilityGrant( + creator: IssuedToken | undefined, capabilities: readonly Capability[], - authToken: string | undefined, ): ValidationError | null { - const creator = authToken === undefined ? undefined : this.issuedTokens.get(authToken) - // In strictAuth mode, handleRequest already validates this token - // before dispatch. In default mode, a missing or unknown token stays - // permissive and cannot supply a narrower creator grant. if (creator === undefined) return null - // The implicit account credential is the simulator's root key; only - // application keys minted via b2_create_key are constrained here. - const grant = creator.applicationKeyId === null ? MASTER_CAPABILITY_GRANT : creator.capabilities - const grantSet = new Set(grant) + const grantSet = new Set(creator.capabilities) const missing = capabilities.filter((capability) => !grantSet.has(capability)) if (missing.length === 0) return null @@ -3522,9 +3491,59 @@ export class B2Simulator { } } + private validateCreateKeyScopeGrant( + creator: IssuedToken | undefined, + bucketIds: readonly string[] | null, + namePrefix: string | null, + ): ValidationError | null { + if (creator === undefined) return null + + if (creator.bucketIds !== null) { + if (bucketIds === null) { + return { + code: 'bad_request', + message: 'requested bucketIds exceed creator bucket scope', + } + } + const creatorBucketIds = new Set(creator.bucketIds) + const outOfScopeBucketIds = bucketIds.filter((bucketId) => !creatorBucketIds.has(bucketId)) + if (outOfScopeBucketIds.length > 0) { + return { + code: 'bad_request', + message: `requested bucketIds exceed creator bucket scope: ${outOfScopeBucketIds.join(', ')}`, + } + } + } + + if (creator.namePrefix !== null) { + if (namePrefix === null || !namePrefix.startsWith(creator.namePrefix)) { + return { + code: 'bad_request', + message: `requested namePrefix must start with creator prefix "${creator.namePrefix}"`, + } + } + } + + return null + } + + private createKeyAccountId( + requestedAccountId: unknown, + creator: IssuedToken | undefined, + ): string | ValidationError { + const accountId = creator?.accountId ?? this.accountId + if (requestedAccountId !== accountId) { + return { + code: 'bad_request', + message: 'accountId must match the authenticated account', + } + } + return accountId + } + private createKey( req: { - accountId: string + accountId: unknown capabilities: unknown keyName: unknown validDurationInSeconds?: number @@ -3535,17 +3554,13 @@ export class B2Simulator { apiVersion: string, authToken?: string, ): SimulatorJsonResponse { - const keyNameError = validateKeyName(req.keyName) - if (keyNameError) return this.error(400, keyNameError.code, keyNameError.message) - const capabilitiesError = validateKeyCapabilities(req.capabilities) - if (capabilitiesError) { - return this.error(400, capabilitiesError.code, capabilitiesError.message) - } - const keyName = req.keyName as string - const capabilities = Object.freeze([...(req.capabilities as Capability[])]) - const capabilityGrantError = this.validateCreateKeyCapabilityGrant(capabilities, authToken) - if (capabilityGrantError !== null) { - return this.error(400, capabilityGrantError.code, capabilityGrantError.message) + const keyName = parseKeyName(req.keyName) + if (typeof keyName !== 'string') { + return this.error(400, keyName.code, keyName.message) + } + const capabilities = parseKeyCapabilities(req.capabilities) + if ('code' in capabilities) { + return this.error(400, capabilities.code, capabilities.message) } if (apiVersion === 'v4' && hasOwnField(req, 'bucketId')) { return this.error( @@ -3569,6 +3584,20 @@ export class B2Simulator { 'key-management capabilities are account-level and cannot be bucket or name-prefix scoped', ) } + // In strictAuth mode, handleRequest already validates this token + // before dispatch. In default mode, a missing or unknown token stays + // permissive and cannot supply a narrower creator grant. + const creator = authToken === undefined ? undefined : this.issuedTokens.get(authToken) + const accountId = this.createKeyAccountId(req.accountId, creator) + if (typeof accountId !== 'string') { + return this.error(400, accountId.code, accountId.message) + } + for (const grantError of [ + this.validateCreateKeyCapabilityGrant(creator, capabilities), + this.validateCreateKeyScopeGrant(creator, bucketIds, namePrefix), + ]) { + if (grantError) return this.error(400, grantError.code, grantError.message) + } const kid = this.genId('sim_key') const appKey = this.genId('sim_secret') const expiration = @@ -3579,7 +3608,7 @@ export class B2Simulator { applicationKeyId: kid, keyName, capabilities, - accountId: req.accountId, + accountId, applicationKey: appKey, bucketIds, namePrefix, diff --git a/src/simulator/validation.test.ts b/src/simulator/validation.test.ts index 16f99f7a..5d3ad40f 100644 --- a/src/simulator/validation.test.ts +++ b/src/simulator/validation.test.ts @@ -57,8 +57,8 @@ describe('validateKeyCapabilities', () => { expect(validateKeyCapabilities([Capability.ListBuckets])).toBeNull() }) - it('rejects an empty capability list', () => { - expect(validateKeyCapabilities([])?.code).toBe('bad_request') + it('accepts an empty capability list', () => { + expect(validateKeyCapabilities([])).toBeNull() }) it('rejects unknown capabilities', () => { diff --git a/src/simulator/validation.ts b/src/simulator/validation.ts index 6b0f5465..6eb72e71 100644 --- a/src/simulator/validation.ts +++ b/src/simulator/validation.ts @@ -59,6 +59,21 @@ function isCapability(value: string): value is Capability { * @see https://www.backblaze.com/apidocs/b2-create-key */ export function validateKeyName(keyName: unknown): ValidationError | null { + const parsed = parseKeyName(keyName) + return typeof parsed === 'string' ? null : parsed +} + +/** + * Parses and validates an application key name against B2's documented + * `^[A-Za-z0-9-]+$` grammar. + * + * @param keyName - Caller-supplied key name. + * + * @returns The validated key name, or a `{ code, message }` pair on failure. + * + * @see https://www.backblaze.com/apidocs/b2-create-key + */ +export function parseKeyName(keyName: unknown): string | ValidationError { if ( typeof keyName !== 'string' || keyName.length < KEY_NAME_MIN || @@ -75,7 +90,7 @@ export function validateKeyName(keyName: unknown): ValidationError | null { message: 'keyName must contain only letters, digits, and hyphens', } } - return null + return keyName } /** @@ -89,20 +104,38 @@ export function validateKeyName(keyName: unknown): ValidationError | null { * @see https://www.backblaze.com/apidocs/b2-create-key */ export function validateKeyCapabilities(capabilities: unknown): ValidationError | null { + const parsed = parseKeyCapabilities(capabilities) + return 'code' in parsed ? parsed : null +} + +/** + * Parses and validates an application key capability list against the + * supported B2 capability enum. + * + * @param capabilities - Caller-supplied capability list. + * + * @returns A frozen `Capability[]`, or a `{ code, message }` pair on failure. + * + * @see https://www.backblaze.com/apidocs/b2-create-key + */ +export function parseKeyCapabilities( + capabilities: unknown, +): readonly Capability[] | ValidationError { if (!Array.isArray(capabilities)) { return { code: 'bad_request', message: 'capabilities must be an array' } } - if (capabilities.length === 0) { - return { code: 'bad_request', message: 'capabilities must be a non-empty array' } - } + const parsed: Capability[] = [] for (const capability of capabilities) { - if (typeof capability === 'string' && isCapability(capability)) continue + if (typeof capability === 'string' && isCapability(capability)) { + parsed.push(capability) + continue + } return { code: 'bad_request', message: `unknown capability: ${String(capability)}`, } } - return null + return Object.freeze(parsed) } // --------------------------------------------------------------------------- From e4e244e90be9f456161b05bf60f2f7d960fd8889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 11:17:43 -0500 Subject: [PATCH 4/8] fix: reject empty create key capabilities --- CHANGELOG.md | 2 +- src/simulator/fidelity.test.ts | 4 ++++ src/simulator/validation.test.ts | 4 ++-- src/simulator/validation.ts | 3 +++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d87b79ab..46934b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **B2Simulator create-key validation now matches more B2 constraints.** `b2_create_key` - rejects unknown capabilities, key names outside B2's 1..100 + rejects unknown or empty capability lists, key names outside B2's 1..100 letters/digits/hyphen grammar, child keys that exceed the creator token's capabilities, bucket scope, or name prefix, and mismatched request `accountId` values instead of storing over-broad simulator keys. diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index ccdaf3ad..ef7b7114 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -690,6 +690,10 @@ describe('B2Simulator capability enforcement', () => { body: { capabilities: [Capability.ListBuckets], keyName: 'bad\nname' }, message: 'letters, digits, and hyphens', }, + { + body: { capabilities: [], keyName: 'empty-capability-key' }, + message: 'non-empty array', + }, { body: { capabilities: [Capability.ListBuckets], keyName: '' }, message: 'keyName', diff --git a/src/simulator/validation.test.ts b/src/simulator/validation.test.ts index 5d3ad40f..16f99f7a 100644 --- a/src/simulator/validation.test.ts +++ b/src/simulator/validation.test.ts @@ -57,8 +57,8 @@ describe('validateKeyCapabilities', () => { expect(validateKeyCapabilities([Capability.ListBuckets])).toBeNull() }) - it('accepts an empty capability list', () => { - expect(validateKeyCapabilities([])).toBeNull() + it('rejects an empty capability list', () => { + expect(validateKeyCapabilities([])?.code).toBe('bad_request') }) it('rejects unknown capabilities', () => { diff --git a/src/simulator/validation.ts b/src/simulator/validation.ts index 6eb72e71..2139ed24 100644 --- a/src/simulator/validation.ts +++ b/src/simulator/validation.ts @@ -124,6 +124,9 @@ export function parseKeyCapabilities( if (!Array.isArray(capabilities)) { return { code: 'bad_request', message: 'capabilities must be an array' } } + if (capabilities.length === 0) { + return { code: 'bad_request', message: 'capabilities must be a non-empty array' } + } const parsed: Capability[] = [] for (const capability of capabilities) { if (typeof capability === 'string' && isCapability(capability)) { From f43ce394f4a68cebf9ede4d152854adabd358b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 11:26:03 -0500 Subject: [PATCH 5/8] fix: validate create key scope fields --- src/simulator/fidelity.test.ts | 12 +++++++ src/simulator/index.ts | 63 ++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index ef7b7114..19dc20a4 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -694,6 +694,18 @@ describe('B2Simulator capability enforcement', () => { body: { capabilities: [], keyName: 'empty-capability-key' }, message: 'non-empty array', }, + { + body: { + capabilities: [Capability.ReadFiles], + keyName: 'bad-bucketids-key', + bucketIds: 'x', + }, + message: 'bucketIds', + }, + { + body: { capabilities: [Capability.ReadFiles], keyName: 'bad-prefix-key', namePrefix: 1 }, + message: 'namePrefix', + }, { body: { capabilities: [Capability.ListBuckets], keyName: '' }, message: 'keyName', diff --git a/src/simulator/index.ts b/src/simulator/index.ts index 852e5564..bffab384 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -375,12 +375,33 @@ interface RequestScope { readonly requiresBucketScope: boolean } +function isValidationError(value: unknown): value is ValidationError { + return typeof value === 'object' && value !== null && 'code' in value && 'message' in value +} + function normalizeKeyBucketIds(req: { - bucketIds?: readonly string[] | null -}): readonly string[] | null { - return req.bucketIds === undefined || req.bucketIds === null - ? null - : Object.freeze([...req.bucketIds]) + bucketIds?: unknown +}): readonly string[] | null | ValidationError { + if (req.bucketIds === undefined || req.bucketIds === null) return null + if (!Array.isArray(req.bucketIds)) { + return { code: 'bad_request', message: 'bucketIds must be an array or null' } + } + const bucketIds: string[] = [] + for (const bucketId of req.bucketIds) { + if (typeof bucketId !== 'string') { + return { code: 'bad_request', message: 'bucketIds must contain only strings' } + } + bucketIds.push(bucketId) + } + return Object.freeze(bucketIds) +} + +function normalizeKeyNamePrefix(namePrefix: unknown): string | null | ValidationError { + if (namePrefix === undefined || namePrefix === '') return null + if (typeof namePrefix !== 'string') { + return { code: 'bad_request', message: 'namePrefix must be a string' } + } + return namePrefix } function singleBucketId(bucketIds: readonly string[] | null | undefined): string | null { @@ -3547,9 +3568,9 @@ export class B2Simulator { capabilities: unknown keyName: unknown validDurationInSeconds?: number - bucketIds?: readonly string[] | null - bucketId?: string - namePrefix?: string + bucketIds?: unknown + bucketId?: unknown + namePrefix?: unknown }, apiVersion: string, authToken?: string, @@ -3569,14 +3590,28 @@ export class B2Simulator { 'bucketId is not accepted by v4 b2_create_key; use bucketIds', ) } - if (apiVersion !== 'v4' && req.bucketId !== undefined && req.bucketIds !== undefined) { + const bucketId = req.bucketId + if (apiVersion !== 'v4' && bucketId !== undefined && req.bucketIds !== undefined) { return this.error(400, 'bad_request', 'b2_create_key accepts either bucketIds or bucketId') } - const bucketIds = - apiVersion !== 'v4' && req.bucketId !== undefined - ? Object.freeze([req.bucketId]) - : normalizeKeyBucketIds(req) - const namePrefix = req.namePrefix === undefined || req.namePrefix === '' ? null : req.namePrefix + let bucketIdsResult: readonly string[] | null | ValidationError + if (apiVersion !== 'v4' && bucketId !== undefined) { + if (typeof bucketId !== 'string') { + return this.error(400, 'bad_request', 'bucketId must be a string') + } + bucketIdsResult = Object.freeze([bucketId]) + } else { + bucketIdsResult = normalizeKeyBucketIds(req) + } + if (isValidationError(bucketIdsResult)) { + return this.error(400, bucketIdsResult.code, bucketIdsResult.message) + } + const namePrefixResult = normalizeKeyNamePrefix(req.namePrefix) + if (isValidationError(namePrefixResult)) { + return this.error(400, namePrefixResult.code, namePrefixResult.message) + } + const bucketIds = bucketIdsResult + const namePrefix = namePrefixResult if (hasKeyManagementCapability(capabilities) && (bucketIds !== null || namePrefix !== null)) { return this.error( 400, From f7b9117d692d69d86b8f2b18729f399cee3dde5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 11:32:39 -0500 Subject: [PATCH 6/8] fix: use simulator clock for key expiry --- src/simulator/fidelity.test.ts | 18 ++++++++++++++++++ src/simulator/index.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index 19dc20a4..6d3a77c1 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -764,6 +764,24 @@ describe('B2Simulator capability enforcement', () => { }) }) + it('uses the simulator clock for create-key expiration timestamps', async () => { + const { client, sim } = makeClient() + await client.authorize() + const beforeAdvance = Date.now() + sim.advanceTime(60_000) + + const key = await client.createKey({ + capabilities: [Capability.ListBuckets], + keyName: 'clock-expiring-key', + validDurationInSeconds: 60, + }) + + if (key.expirationTimestamp === null) { + throw new Error('expected an expiring application key') + } + expect(key.expirationTimestamp).toBeGreaterThanOrEqual(beforeAdvance + 120_000) + }) + it.each([ false, true, diff --git a/src/simulator/index.ts b/src/simulator/index.ts index bffab384..023a25ae 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -3637,7 +3637,7 @@ export class B2Simulator { const appKey = this.genId('sim_secret') const expiration = req.validDurationInSeconds !== undefined - ? Date.now() + req.validDurationInSeconds * 1000 + ? this.now() + req.validDurationInSeconds * 1000 : null const stored: StoredKey = { applicationKeyId: kid, From e2a604e0117085572dcff288467a2801799a5e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 11:37:52 -0500 Subject: [PATCH 7/8] fix: document master create key grant --- CHANGELOG.md | 4 +++- src/simulator/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46934b97..3e6db9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rejects unknown or empty capability lists, key names outside B2's 1..100 letters/digits/hyphen grammar, child keys that exceed the creator token's capabilities, bucket scope, or name prefix, and mismatched request `accountId` - values instead of storing over-broad simulator keys. + values instead of storing over-broad simulator keys. The implicit master + credential now advertises the full `Capability` enum so create-key grant + checks use the same ceiling clients see in `allowed.capabilities`. - **Real-B2 integration evidence is explicit and diagnosable.** Same-repo integration workflow runs now fail when required B2 secrets are missing instead of silently accepting an all-skipped suite, integration setup logs per-step diff --git a/src/simulator/index.ts b/src/simulator/index.ts index 023a25ae..d4bd8aae 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -2033,9 +2033,9 @@ export class B2Simulator { capabilities: unknown keyName: unknown validDurationInSeconds?: number - bucketIds?: readonly string[] | null - bucketId?: string - namePrefix?: string + bucketIds?: unknown + bucketId?: unknown + namePrefix?: unknown }, apiVersion, headers['authorization'], From a6e6e0246cb40afd3017488ccdc1b8648f802513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 16 Jul 2026 11:43:32 -0500 Subject: [PATCH 8/8] fix: validate create key duration --- src/simulator/fidelity.test.ts | 8 ++++++++ src/simulator/index.ts | 27 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/simulator/fidelity.test.ts b/src/simulator/fidelity.test.ts index 6d3a77c1..6343baf5 100644 --- a/src/simulator/fidelity.test.ts +++ b/src/simulator/fidelity.test.ts @@ -706,6 +706,14 @@ describe('B2Simulator capability enforcement', () => { body: { capabilities: [Capability.ReadFiles], keyName: 'bad-prefix-key', namePrefix: 1 }, message: 'namePrefix', }, + { + body: { + capabilities: [Capability.ListBuckets], + keyName: 'bad-duration-key', + validDurationInSeconds: '60', + }, + message: 'validDurationInSeconds', + }, { body: { capabilities: [Capability.ListBuckets], keyName: '' }, message: 'keyName', diff --git a/src/simulator/index.ts b/src/simulator/index.ts index d4bd8aae..408f37c6 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -404,6 +404,21 @@ function normalizeKeyNamePrefix(namePrefix: unknown): string | null | Validation return namePrefix } +function normalizeKeyValidDurationInSeconds( + validDurationInSeconds: unknown, +): number | null | ValidationError { + if (validDurationInSeconds === undefined) return null + if ( + typeof validDurationInSeconds !== 'number' || + !Number.isFinite(validDurationInSeconds) || + !Number.isInteger(validDurationInSeconds) || + validDurationInSeconds <= 0 + ) { + return { code: 'bad_request', message: 'validDurationInSeconds must be a positive integer' } + } + return validDurationInSeconds +} + function singleBucketId(bucketIds: readonly string[] | null | undefined): string | null { return bucketIds?.length === 1 ? (bucketIds[0] ?? null) : null } @@ -2032,7 +2047,7 @@ export class B2Simulator { accountId: unknown capabilities: unknown keyName: unknown - validDurationInSeconds?: number + validDurationInSeconds?: unknown bucketIds?: unknown bucketId?: unknown namePrefix?: unknown @@ -3567,7 +3582,7 @@ export class B2Simulator { accountId: unknown capabilities: unknown keyName: unknown - validDurationInSeconds?: number + validDurationInSeconds?: unknown bucketIds?: unknown bucketId?: unknown namePrefix?: unknown @@ -3610,6 +3625,10 @@ export class B2Simulator { if (isValidationError(namePrefixResult)) { return this.error(400, namePrefixResult.code, namePrefixResult.message) } + const validDurationInSeconds = normalizeKeyValidDurationInSeconds(req.validDurationInSeconds) + if (isValidationError(validDurationInSeconds)) { + return this.error(400, validDurationInSeconds.code, validDurationInSeconds.message) + } const bucketIds = bucketIdsResult const namePrefix = namePrefixResult if (hasKeyManagementCapability(capabilities) && (bucketIds !== null || namePrefix !== null)) { @@ -3636,9 +3655,7 @@ export class B2Simulator { const kid = this.genId('sim_key') const appKey = this.genId('sim_secret') const expiration = - req.validDurationInSeconds !== undefined - ? this.now() + req.validDurationInSeconds * 1000 - : null + validDurationInSeconds === null ? null : this.now() + validDurationInSeconds * 1000 const stored: StoredKey = { applicationKeyId: kid, keyName,