Skip to content

Commit 63b7e46

Browse files
fix: Prevent scheduling background events less than 1 second in the future
1 parent bc050af commit 63b7e46

File tree

4 files changed

+100
-25
lines changed

4 files changed

+100
-25
lines changed

packages/snaps-controllers/src/cronjob/CronjobController.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ describe('CronjobController', () => {
435435

436436
const backgroundEvent = {
437437
snapId: MOCK_SNAP_ID,
438-
date: '2022-01-01T01:00Z',
438+
date: '2025-05-21T13:25:21.500Z',
439439
request: {
440440
method: 'handleEvent',
441441
params: ['p1'],
@@ -449,7 +449,7 @@ describe('CronjobController', () => {
449449
{
450450
id,
451451
snapId: MOCK_SNAP_ID,
452-
date: '2022-01-01T01:00Z',
452+
date: '2025-05-21T13:25:21Z',
453453
request: {
454454
method: 'handleEvent',
455455
params: ['p1'],
@@ -1023,7 +1023,7 @@ describe('CronjobController', () => {
10231023
'CronjobController:scheduleBackgroundEvent',
10241024
{
10251025
snapId: MOCK_SNAP_ID,
1026-
date: '2022-01-01T01:00Z',
1026+
date: '2025-05-21T13:25:21.500Z',
10271027
request: {
10281028
method: 'handleExport',
10291029
params: ['p1'],
@@ -1036,7 +1036,7 @@ describe('CronjobController', () => {
10361036
id,
10371037
snapId: MOCK_SNAP_ID,
10381038
scheduledAt: expect.any(String),
1039-
date: '2022-01-01T01:00Z',
1039+
date: '2025-05-21T13:25:21.500Z',
10401040
request: {
10411041
method: 'handleExport',
10421042
params: ['p1'],
@@ -1054,7 +1054,7 @@ describe('CronjobController', () => {
10541054
id,
10551055
snapId: MOCK_SNAP_ID,
10561056
scheduledAt: expect.any(String),
1057-
date: '2022-01-01T01:00Z',
1057+
date: '2025-05-21T13:25:21Z',
10581058
request: {
10591059
method: 'handleExport',
10601060
params: ['p1'],

packages/snaps-controllers/src/cronjob/CronjobController.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
logError,
2121
logWarning,
2222
} from '@metamask/snaps-utils';
23-
import { assert, Duration, inMilliseconds } from '@metamask/utils';
23+
import { assert, Duration, hasProperty, inMilliseconds } from '@metamask/utils';
2424
import { castDraft } from 'immer';
2525
import { DateTime } from 'luxon';
2626
import { nanoid } from 'nanoid';
@@ -331,9 +331,6 @@ export class CronjobController extends BaseController<
331331
};
332332

333333
this.#setUpBackgroundEvent(event);
334-
this.update((state) => {
335-
state.events[event.id] = castDraft(event);
336-
});
337334

338335
return event.id;
339336
}
@@ -379,6 +376,13 @@ export class CronjobController extends BaseController<
379376
throw new Error('Cannot schedule an event in the past.');
380377
}
381378

379+
// The event may already be in state when we get here.
380+
if (!hasProperty(this.state.events, event.id)) {
381+
this.update((state) => {
382+
state.events[event.id] = castDraft(event);
383+
});
384+
}
385+
382386
const timer = new Timer(ms);
383387
timer.start(() => {
384388
this.messagingSystem
@@ -410,9 +414,17 @@ export class CronjobController extends BaseController<
410414
* @returns An array of background events.
411415
*/
412416
getBackgroundEvents(snapId: SnapId): BackgroundEvent[] {
413-
return Object.values(this.state.events).filter(
414-
(snapEvent) => snapEvent.snapId === snapId,
415-
);
417+
return Object.values(this.state.events)
418+
.filter((snapEvent) => snapEvent.snapId === snapId)
419+
.map((event) => ({
420+
...event,
421+
// Truncate dates to remove milliseconds.
422+
date: DateTime.fromISO(event.date, { setZone: true })
423+
.startOf('second')
424+
.toISO({
425+
suppressMilliseconds: true,
426+
}) as string,
427+
}));
416428
}
417429

418430
/**

packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ describe('snap_scheduleBackgroundEvent', () => {
2323
});
2424

2525
describe('implementation', () => {
26+
beforeAll(() => {
27+
jest.useFakeTimers();
28+
29+
// Specifically setting a Date that is in between two seconds.
30+
jest.setSystemTime(1747833920500);
31+
});
32+
33+
afterAll(() => {
34+
jest.useRealTimers();
35+
});
36+
2637
const createOriginMiddleware =
2738
(origin: string) =>
2839
(request: any, _response: unknown, next: () => void, _end: unknown) => {
@@ -151,7 +162,55 @@ describe('snap_scheduleBackgroundEvent', () => {
151162
id: 1,
152163
method: 'snap_scheduleBackgroundEvent',
153164
params: {
154-
duration: 'PT30S',
165+
duration: 'PT1S',
166+
request: {
167+
method: 'handleExport',
168+
params: ['p1'],
169+
},
170+
},
171+
});
172+
173+
expect(scheduleBackgroundEvent).toHaveBeenCalledWith({
174+
date: '2025-05-21T13:25:21.500Z',
175+
request: {
176+
method: 'handleExport',
177+
params: ['p1'],
178+
},
179+
});
180+
});
181+
182+
it('schedules a background event using a minimum duration of 1 second', async () => {
183+
const { implementation } = scheduleBackgroundEventHandler;
184+
185+
const scheduleBackgroundEvent = jest.fn();
186+
const hasPermission = jest.fn().mockImplementation(() => true);
187+
188+
const hooks = {
189+
scheduleBackgroundEvent,
190+
hasPermission,
191+
};
192+
193+
const engine = new JsonRpcEngine();
194+
195+
engine.push(createOriginMiddleware(MOCK_SNAP_ID));
196+
engine.push((request, response, next, end) => {
197+
const result = implementation(
198+
request as JsonRpcRequest<ScheduleBackgroundEventParams>,
199+
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
200+
next,
201+
end,
202+
hooks,
203+
);
204+
205+
result?.catch(end);
206+
});
207+
208+
await engine.handle({
209+
jsonrpc: '2.0',
210+
id: 1,
211+
method: 'snap_scheduleBackgroundEvent',
212+
params: {
213+
duration: 'PT0.5S',
155214
request: {
156215
method: 'handleExport',
157216
params: ['p1'],
@@ -160,7 +219,7 @@ describe('snap_scheduleBackgroundEvent', () => {
160219
});
161220

162221
expect(scheduleBackgroundEvent).toHaveBeenCalledWith({
163-
date: expect.any(String),
222+
date: '2025-05-21T13:25:21.500Z',
164223
request: {
165224
method: 'handleExport',
166225
params: ['p1'],

packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,21 @@ export type ScheduleBackgroundEventParameters = InferMatching<
8484
*/
8585
function getStartDate(params: ScheduleBackgroundEventParams) {
8686
if ('duration' in params) {
87-
return DateTime.fromJSDate(new Date())
88-
.toUTC()
89-
.plus(Duration.fromISO(params.duration));
87+
const parsed = Duration.fromISO(params.duration);
88+
89+
// Disallow durations less than 1 second.
90+
const duration =
91+
parsed.as('seconds') >= 1 ? parsed : Duration.fromObject({ seconds: 1 });
92+
93+
return DateTime.fromJSDate(new Date()).toUTC().plus(duration).toISO();
9094
}
9195

92-
return DateTime.fromISO(params.date, { setZone: true });
96+
const date = DateTime.fromISO(params.date, { setZone: true });
97+
98+
// Make sure any millisecond precision is removed.
99+
return date.startOf('second').toISO({
100+
suppressMilliseconds: true,
101+
});
93102
}
94103

95104
/**
@@ -128,14 +137,9 @@ async function getScheduleBackgroundEventImplementation(
128137

129138
const date = getStartDate(validatedParams);
130139

131-
// Make sure any millisecond precision is removed.
132-
const truncatedDate = date.startOf('second').toISO({
133-
suppressMilliseconds: true,
134-
});
135-
136-
assert(truncatedDate);
140+
assert(date);
137141

138-
const id = scheduleBackgroundEvent({ date: truncatedDate, request });
142+
const id = scheduleBackgroundEvent({ date, request });
139143
res.result = id;
140144
} catch (error) {
141145
return end(error);

0 commit comments

Comments
 (0)