Skip to content

Commit e101e0f

Browse files
fix: Prevent scheduling background events less than 1 second in the future (#3414)
This PR fixes an issue with scheduling background events using durations. Since we were truncating durations to remove milliseconds we would effectively allow scheduling events less than 1 second into the future. To work around this, durations are now allowed to be a minimum of 1 second and are untruncated. The execution date will still be truncated when the Snap requests it to prevent leakage of millisecond precision. --------- Co-authored-by: Maarten Zuidhoorn <[email protected]>
1 parent 4acb5c3 commit e101e0f

File tree

8 files changed

+131
-29
lines changed

8 files changed

+131
-29
lines changed
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"branches": 94.41,
2+
"branches": 94.42,
33
"functions": 98.4,
4-
"lines": 98.68,
4+
"lines": 98.69,
55
"statements": 98.52
66
}

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: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import {
1919
parseCronExpression,
2020
logError,
2121
logWarning,
22+
toCensoredISO8601String,
2223
} from '@metamask/snaps-utils';
23-
import { assert, Duration, inMilliseconds } from '@metamask/utils';
24+
import { assert, Duration, hasProperty, inMilliseconds } from '@metamask/utils';
2425
import { castDraft } from 'immer';
2526
import { DateTime } from 'luxon';
2627
import { nanoid } from 'nanoid';
@@ -331,9 +332,6 @@ export class CronjobController extends BaseController<
331332
};
332333

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

338336
return event.id;
339337
}
@@ -379,6 +377,13 @@ export class CronjobController extends BaseController<
379377
throw new Error('Cannot schedule an event in the past.');
380378
}
381379

380+
// The event may already be in state when we get here.
381+
if (!hasProperty(this.state.events, event.id)) {
382+
this.update((state) => {
383+
state.events[event.id] = castDraft(event);
384+
});
385+
}
386+
382387
const timer = new Timer(ms);
383388
timer.start(() => {
384389
this.messagingSystem
@@ -410,9 +415,13 @@ export class CronjobController extends BaseController<
410415
* @returns An array of background events.
411416
*/
412417
getBackgroundEvents(snapId: SnapId): BackgroundEvent[] {
413-
return Object.values(this.state.events).filter(
414-
(snapEvent) => snapEvent.snapId === snapId,
415-
);
418+
return Object.values(this.state.events)
419+
.filter((snapEvent) => snapEvent.snapId === snapId)
420+
.map((event) => ({
421+
...event,
422+
// Truncate dates to remove milliseconds.
423+
date: toCensoredISO8601String(event.date),
424+
}));
416425
}
417426

418427
/**

packages/snaps-rpc-methods/jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module.exports = deepmerge(baseConfig, {
1010
],
1111
coverageThreshold: {
1212
global: {
13-
branches: 95,
13+
branches: 95.03,
1414
functions: 98.65,
1515
lines: 98.78,
1616
statements: 98.47,

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: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CronjobRpcRequestStruct,
1313
ISO8601DateStruct,
1414
ISO8601DurationStruct,
15+
toCensoredISO8601String,
1516
} from '@metamask/snaps-utils';
1617
import { StructError, create, object } from '@metamask/superstruct';
1718
import {
@@ -84,12 +85,17 @@ export type ScheduleBackgroundEventParameters = InferMatching<
8485
*/
8586
function getStartDate(params: ScheduleBackgroundEventParams) {
8687
if ('duration' in params) {
87-
return DateTime.fromJSDate(new Date())
88-
.toUTC()
89-
.plus(Duration.fromISO(params.duration));
88+
const parsed = Duration.fromISO(params.duration);
89+
90+
// Disallow durations less than 1 second.
91+
const duration =
92+
parsed.as('seconds') >= 1 ? parsed : Duration.fromObject({ seconds: 1 });
93+
94+
return DateTime.fromJSDate(new Date()).toUTC().plus(duration).toISO();
9095
}
9196

92-
return DateTime.fromISO(params.date, { setZone: true });
97+
// Make sure any millisecond precision is removed.
98+
return toCensoredISO8601String(params.date);
9399
}
94100

95101
/**
@@ -128,14 +134,9 @@ async function getScheduleBackgroundEventImplementation(
128134

129135
const date = getStartDate(validatedParams);
130136

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

138-
const id = scheduleBackgroundEvent({ date: truncatedDate, request });
139+
const id = scheduleBackgroundEvent({ date, request });
139140
res.result = id;
140141
} catch (error) {
141142
return end(error);

packages/snaps-utils/src/time.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { create, is } from '@metamask/superstruct';
22
import { DateTime } from 'luxon';
33

4-
import { ISO8601DateStruct, ISO8601DurationStruct } from './time';
4+
import {
5+
ISO8601DateStruct,
6+
ISO8601DurationStruct,
7+
toCensoredISO8601String,
8+
} from './time';
59

610
describe('ISO8601DateStruct', () => {
711
it('should return true for a valid ISO 8601 date', () => {
@@ -52,3 +56,17 @@ describe('ISO8601DurationStruct', () => {
5256
);
5357
});
5458
});
59+
60+
describe('toCensoredISO8601String', () => {
61+
it('returns ISO dates as-is with no millisecond precision', () => {
62+
expect(toCensoredISO8601String('2025-05-21T13:25:25Z')).toBe(
63+
'2025-05-21T13:25:25Z',
64+
);
65+
});
66+
67+
it('removes millisecond precision', () => {
68+
expect(toCensoredISO8601String('2025-05-21T13:25:21.500Z')).toBe(
69+
'2025-05-21T13:25:21Z',
70+
);
71+
});
72+
});

packages/snaps-utils/src/time.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,18 @@ export const ISO8601DateStruct = refine(string(), 'ISO 8601 date', (value) => {
3838

3939
return true;
4040
});
41+
42+
/**
43+
* Remove millisecond precision from an ISO 8601 string.
44+
*
45+
* @param value - A valid ISO 8601 date.
46+
* @returns A valid ISO 8601 date with millisecond precision removed.
47+
*/
48+
export function toCensoredISO8601String(value: string) {
49+
const date = DateTime.fromISO(value, { setZone: true });
50+
51+
// Make sure any millisecond precision is removed.
52+
return date.startOf('second').toISO({
53+
suppressMilliseconds: true,
54+
}) as string;
55+
}

0 commit comments

Comments
 (0)