diff --git a/server/src/__tests__/automation-activation-service.test.ts b/server/src/__tests__/automation-activation-service.test.ts index 1e7ad181..e3e7ec89 100644 --- a/server/src/__tests__/automation-activation-service.test.ts +++ b/server/src/__tests__/automation-activation-service.test.ts @@ -120,6 +120,18 @@ describe('AutomationActivationService', () => { ).rejects.toThrow(/preview is stale/i); }); + it('rejects prototype property names before reading or writing automation maps', async () => { + const service = activationService(); + + await expect(service.getVersion('__proto__')).rejects.toThrow(/invalid automation version id/i); + await expect( + service.updateBinding('__proto__', 1, 'paused', 'Invalid target.') + ).rejects.toThrow(/invalid automation binding id/i); + await expect(service.claimRun('__proto__', 'manual-run', now)).rejects.toThrow( + /invalid automation binding id/i + ); + }); + it('claims each due window once and blocks future ownership after pause or budget exhaustion', async () => { const draft = await drafts.save(completeInput()); const service = activationService(); diff --git a/server/src/services/automation-activation-service.ts b/server/src/services/automation-activation-service.ts index 2140fa04..c41b6a7e 100644 --- a/server/src/services/automation-activation-service.ts +++ b/server/src/services/automation-activation-service.ts @@ -41,6 +41,8 @@ import { getStorage } from '../storage/index.js'; const DEFAULT_APPROVAL_TTL_MS = 15 * 60_000; const MAX_CLAIMS = 1_000; const MAX_EVENTS = 200; +const AUTOMATION_VERSION_ID_PATTERN = /^automation_version_[a-f0-9]{24}$/; +const AUTOMATION_BINDING_ID_PATTERN = /^automation_binding_[a-f0-9]{24}$/; export interface AutomationActivationServiceOptions { stateRepository?: SchedulerStateRepository; @@ -412,6 +414,7 @@ export class AutomationActivationService { } async getVersion(versionId: string): Promise { + assertOpaqueId(versionId, AUTOMATION_VERSION_ID_PATTERN, 'automation version'); const version = (await this.stateRepository.read()).automationVersions[versionId]; if (!version) throw new NotFoundError(`Automation version ${versionId} not found.`); return version; @@ -423,6 +426,7 @@ export class AutomationActivationService { status: Extract, reason: string ): Promise { + assertOpaqueId(bindingId, AUTOMATION_BINDING_ID_PATTERN, 'automation binding'); let result: AutomationBinding | undefined; await this.stateRepository.update((state) => { const current = state.automationBindings[bindingId]; @@ -443,7 +447,7 @@ export class AutomationActivationService { revision: current.revision + 1, updatedAt: this.now().toISOString(), }; - state.automationBindings[bindingId] = result; + state.automationBindings[current.id] = result; state.events = [ ...state.events, automationEvent( @@ -464,6 +468,7 @@ export class AutomationActivationService { trigger: 'due-run' | 'manual-run', now = this.now() ): Promise { + assertOpaqueId(bindingId, AUTOMATION_BINDING_ID_PATTERN, 'automation binding'); const snapshot = await this.stateRepository.read(); const snapshotBinding = snapshot.automationBindings[bindingId]; if (!snapshotBinding) throw new NotFoundError(`Automation binding ${bindingId} not found.`); @@ -541,7 +546,7 @@ export class AutomationActivationService { updatedAt: timestamp, }; } - state.automationBindings[bindingId] = binding; + state.automationBindings[binding.id] = binding; state.automationClaims = [...state.automationClaims, claim].slice(-MAX_CLAIMS); result = { claim, version, binding, replayed: false }; return state; @@ -897,6 +902,10 @@ function required(value: T | undefined, path: string): T { return value; } +function assertOpaqueId(value: string, pattern: RegExp, label: string): void { + if (!pattern.test(value)) throw new ValidationError(`Invalid ${label} ID.`); +} + function minuteWindow(now: Date): string { return new Date(Math.floor(now.getTime() / 60_000) * 60_000).toISOString(); }