diff --git a/CHANGELOG.md b/CHANGELOG.md index 0493acb0..3e6db9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ 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 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. 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/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 cb257cb4..6343baf5 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,65 @@ describe('B2Simulator strictAuth: 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, + 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() @@ -566,9 +626,8 @@ describe('B2Simulator strictAuth: 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 () => { @@ -613,6 +672,211 @@ describe('B2Simulator strictAuth: capability enforcement', () => { expect(expiredBody.code).toBe('expired_auth_token') }) + it('rejects malformed create-key inputs in default mode', async () => { + const { client, sim } = makeClient() + await client.authorize() + 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: { capabilities: [Capability.ListBuckets], keyName: 'bad\nname' }, + message: 'letters, digits, and hyphens', + }, + { + 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: 'bad-duration-key', + validDurationInSeconds: '60', + }, + message: 'validDurationInSeconds', + }, + { + 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), + }) + } + + await expect( + client.createKey({ capabilities: [Capability.ListBuckets], keyName: maxLengthName }), + ).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('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, + ])('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], + keyName: strictAuth ? 'limited-key-admin-strict' : 'limited-key-admin-default', + }) + const creatorClient = await authorizeWithKey(sim, creatorKey) + + await expect( + creatorClient.createKey({ + capabilities: [Capability.ReadFiles], + keyName: strictAuth ? 'too-wide-child-strict' : 'too-wide-child-default', + }), + ).rejects.toThrow(/exceed creator grant/) + await expect( + creatorClient.createKey({ + capabilities: [Capability.WriteKeys], + keyName: strictAuth ? 'allowed-child-strict' : 'allowed-child-default', + }), + ).resolves.toMatchObject({ + keyName: strictAuth ? 'allowed-child-strict' : 'allowed-child-default', + }) + }) + + 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 63f1e40b..408f37c6 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -36,6 +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_CAPABILITIES = Object.freeze(Object.values(Capability) as Capability[]) function base64UrlEncode(bytes: Uint8Array): string { let binary = '' @@ -178,6 +179,8 @@ function compareB2FileNames(a: string, b: string): number { import { missingCapabilitiesFor } from './capabilities.ts' import { + parseKeyCapabilities, + parseKeyName, type ValidationError, validateBucketInfo, validateBucketName, @@ -202,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' @@ -305,7 +310,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 @@ -315,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 @@ -369,12 +375,48 @@ 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 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 { @@ -429,7 +471,7 @@ function storedNotificationRulePrefixes( ) } -function hasKeyManagementCapability(capabilities: readonly string[]): boolean { +function hasKeyManagementCapability(capabilities: readonly Capability[]): boolean { return capabilities.some( (capability) => capability === Capability.ListKeys || @@ -1730,6 +1772,7 @@ export class B2Simulator { */ private findKeyForAuthHeader(authzHeader: string | undefined): { capabilities: readonly Capability[] + accountId: string bucketIds: readonly string[] | null namePrefix: string | null applicationKeyId: string @@ -1752,7 +1795,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, @@ -2000,15 +2044,16 @@ export class B2Simulator { case 'b2_create_key': return this.createKey( body as { - accountId: string - capabilities: string[] - keyName: string - validDurationInSeconds?: number - bucketIds?: readonly string[] | null - bucketId?: string - namePrefix?: string + accountId: unknown + capabilities: unknown + keyName: unknown + validDurationInSeconds?: unknown + bucketIds?: unknown + bucketId?: unknown + namePrefix?: unknown }, apiVersion, + headers['authorization'], ) case 'b2_list_keys': return this.listKeys( @@ -2555,49 +2600,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, @@ -3493,18 +3511,93 @@ export class B2Simulator { // --- Keys --- + private validateCreateKeyCapabilityGrant( + creator: IssuedToken | undefined, + capabilities: readonly Capability[], + ): ValidationError | null { + if (creator === undefined) return null + + const grantSet = new Set(creator.capabilities) + const missing = capabilities.filter((capability) => !grantSet.has(capability)) + if (missing.length === 0) return null + + return { + code: 'bad_request', + message: `requested capabilities exceed creator grant: ${missing.join(', ')}`, + } + } + + 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 - capabilities: string[] - keyName: string - validDurationInSeconds?: number - bucketIds?: readonly string[] | null - bucketId?: string - namePrefix?: string + accountId: unknown + capabilities: unknown + keyName: unknown + validDurationInSeconds?: unknown + bucketIds?: unknown + bucketId?: unknown + namePrefix?: unknown }, apiVersion: string, + authToken?: string, ): SimulatorJsonResponse { + 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( 400, @@ -3512,35 +3605,62 @@ 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 - if ( - hasKeyManagementCapability(req.capabilities) && - (bucketIds !== null || namePrefix !== null) - ) { + 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 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)) { return this.error( 400, 'bad_request', '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 = - req.validDurationInSeconds !== undefined - ? Date.now() + req.validDurationInSeconds * 1000 - : null + validDurationInSeconds === null ? null : this.now() + validDurationInSeconds * 1000 const stored: StoredKey = { applicationKeyId: kid, - keyName: req.keyName, - capabilities: req.capabilities, - accountId: req.accountId, + keyName, + capabilities, + accountId, applicationKey: appKey, bucketIds, namePrefix, 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..2139ed24 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,113 @@ 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 { + 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 || + 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 keyName +} + +/** + * 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 { + 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)) { + parsed.push(capability) + continue + } + return { + code: 'bad_request', + message: `unknown capability: ${String(capability)}`, + } + } + return Object.freeze(parsed) +} + // --------------------------------------------------------------------------- // Bucket name (`b2_create_bucket`, `b2_update_bucket`) // ---------------------------------------------------------------------------