fix: validate automation state identifiers

This commit is contained in:
Brad Groux 2026-08-30 05:56:57 -05:00
parent ec1c714fa3
commit 0e755cafdb
2 changed files with 23 additions and 2 deletions

View file

@ -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();

View file

@ -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<AutomationVersion> {
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<AutomationBindingStatus, 'active' | 'paused' | 'revoked'>,
reason: string
): Promise<AutomationBinding> {
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<AutomationRunClaimResult> {
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<T>(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();
}