@oneuptime/common 10.0.14 → 10.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/User.ts +27 -0
- package/Server/API/UserWebAuthnAPI.ts +0 -2
- package/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.ts +23 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/MonitorTestService.ts +102 -1
- package/Server/Services/UserWebAuthnService.ts +116 -8
- package/Server/Utils/Browser.ts +0 -1
- package/Server/Utils/Memory.ts +81 -0
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +4 -1
- package/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.ts +5 -1
- package/Server/Utils/VM/VMRunner.ts +147 -1
- package/Tests/Server/Utils/VM/VMAPI.test.ts +4 -0
- package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +2 -2
- package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +136 -11
- package/UI/Components/TextArea/TextArea.tsx +2 -1
- package/Utils/Number.ts +27 -0
- package/build/dist/Models/DatabaseModels/User.js +31 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/UserWebAuthnAPI.js +0 -2
- package/build/dist/Server/API/UserWebAuthnAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772280000000-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/MonitorTestService.js +74 -0
- package/build/dist/Server/Services/MonitorTestService.js.map +1 -1
- package/build/dist/Server/Services/UserWebAuthnService.js +83 -5
- package/build/dist/Server/Services/UserWebAuthnService.js.map +1 -1
- package/build/dist/Server/Utils/Browser.js +0 -1
- package/build/dist/Server/Utils/Browser.js.map +1 -1
- package/build/dist/Server/Utils/Memory.js +55 -0
- package/build/dist/Server/Utils/Memory.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +3 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.js +4 -1
- package/build/dist/Server/Utils/Monitor/Criteria/IncomingEmailCriteria.js.map +1 -1
- package/build/dist/Server/Utils/VM/VMRunner.js +89 -0
- package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
- package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js +4 -0
- package/build/dist/Tests/Server/Utils/VM/VMAPI.test.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +2 -2
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +72 -5
- package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js +2 -2
- package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
- package/build/dist/Utils/Number.js +16 -0
- package/build/dist/Utils/Number.js.map +1 -1
- package/package.json +1 -1
|
@@ -351,6 +351,33 @@ class User extends UserModel {
|
|
|
351
351
|
})
|
|
352
352
|
public resetPasswordExpires?: Date = undefined;
|
|
353
353
|
|
|
354
|
+
@ColumnAccessControl({
|
|
355
|
+
create: [],
|
|
356
|
+
read: [],
|
|
357
|
+
update: [],
|
|
358
|
+
})
|
|
359
|
+
@TableColumn({ type: TableColumnType.ShortText })
|
|
360
|
+
@Column({
|
|
361
|
+
type: ColumnType.ShortText,
|
|
362
|
+
length: ColumnLength.ShortText,
|
|
363
|
+
nullable: true,
|
|
364
|
+
unique: false,
|
|
365
|
+
})
|
|
366
|
+
public webauthnChallenge?: string = undefined;
|
|
367
|
+
|
|
368
|
+
@ColumnAccessControl({
|
|
369
|
+
create: [],
|
|
370
|
+
read: [],
|
|
371
|
+
update: [],
|
|
372
|
+
})
|
|
373
|
+
@TableColumn({ type: TableColumnType.Date })
|
|
374
|
+
@Column({
|
|
375
|
+
type: ColumnType.Date,
|
|
376
|
+
nullable: true,
|
|
377
|
+
unique: false,
|
|
378
|
+
})
|
|
379
|
+
public webauthnChallengeExpiresAt?: Date = undefined;
|
|
380
|
+
|
|
354
381
|
@ColumnAccessControl({
|
|
355
382
|
create: [],
|
|
356
383
|
read: [Permission.CurrentUser],
|
|
@@ -54,12 +54,10 @@ export default class UserWebAuthnAPI extends BaseAPI<
|
|
|
54
54
|
const databaseProps: DatabaseCommonInteractionProps =
|
|
55
55
|
await CommonAPI.getDatabaseCommonInteractionProps(req);
|
|
56
56
|
|
|
57
|
-
const expectedChallenge: string = data["challenge"] as string;
|
|
58
57
|
const credential: any = data["credential"];
|
|
59
58
|
const name: string = data["name"] as string;
|
|
60
59
|
|
|
61
60
|
await UserWebAuthnService.verifyRegistration({
|
|
62
|
-
challenge: expectedChallenge,
|
|
63
61
|
credential: credential,
|
|
64
62
|
name: name,
|
|
65
63
|
props: databaseProps,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
export class MigrationName1772280000000 implements MigrationInterface {
|
|
4
|
+
public name = "MigrationName1772280000000";
|
|
5
|
+
|
|
6
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
+
await queryRunner.query(
|
|
8
|
+
`ALTER TABLE "User" ADD "webauthnChallenge" character varying(100)`,
|
|
9
|
+
);
|
|
10
|
+
await queryRunner.query(
|
|
11
|
+
`ALTER TABLE "User" ADD "webauthnChallengeExpiresAt" TIMESTAMP WITH TIME ZONE`,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
16
|
+
await queryRunner.query(
|
|
17
|
+
`ALTER TABLE "User" DROP COLUMN "webauthnChallengeExpiresAt"`,
|
|
18
|
+
);
|
|
19
|
+
await queryRunner.query(
|
|
20
|
+
`ALTER TABLE "User" DROP COLUMN "webauthnChallenge"`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -260,6 +260,7 @@ import { MigrationName1770833704656 } from "./1770833704656-MigrationName";
|
|
|
260
260
|
import { MigrationName1770834237090 } from "./1770834237090-MigrationName";
|
|
261
261
|
import { MigrationName1770834237091 } from "./1770834237091-MigrationName";
|
|
262
262
|
import { MigrationName1772111896988 } from "./1772111896988-MigrationName";
|
|
263
|
+
import { MigrationName1772280000000 } from "./1772280000000-MigrationName";
|
|
263
264
|
|
|
264
265
|
export default [
|
|
265
266
|
InitialMigration,
|
|
@@ -524,4 +525,5 @@ export default [
|
|
|
524
525
|
MigrationName1770834237090,
|
|
525
526
|
MigrationName1770834237091,
|
|
526
527
|
MigrationName1772111896988,
|
|
528
|
+
MigrationName1772280000000,
|
|
527
529
|
];
|
|
@@ -1,11 +1,112 @@
|
|
|
1
|
-
import DatabaseService from "./DatabaseService";
|
|
1
|
+
import DatabaseService, { EntityManager } from "./DatabaseService";
|
|
2
2
|
import MonitorTest from "../../Models/DatabaseModels/MonitorTest";
|
|
3
|
+
import ObjectID from "../../Types/ObjectID";
|
|
4
|
+
import OneUptimeDate from "../../Types/Date";
|
|
5
|
+
import { MonitorStepProbeResponse } from "../../Models/DatabaseModels/MonitorProbe";
|
|
3
6
|
|
|
4
7
|
export class Service extends DatabaseService<MonitorTest> {
|
|
8
|
+
private static readonly STALE_TEST_CLAIM_TIMEOUT_IN_MINUTES: number = 10;
|
|
9
|
+
|
|
5
10
|
public constructor() {
|
|
6
11
|
super(MonitorTest);
|
|
7
12
|
this.hardDeleteItemsOlderThanInDays("createdAt", 2); // this is temporary data. Clear it after 2 days.
|
|
8
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Atomically claims monitor tests for a specific probe instance using
|
|
17
|
+
* FOR UPDATE SKIP LOCKED so concurrent probe replicas do not execute the
|
|
18
|
+
* same monitor test.
|
|
19
|
+
*/
|
|
20
|
+
public async claimMonitorTestsForProbing(data: {
|
|
21
|
+
probeId: ObjectID;
|
|
22
|
+
limit: number;
|
|
23
|
+
}): Promise<Array<ObjectID>> {
|
|
24
|
+
const staleClaimThreshold: Date = OneUptimeDate.addRemoveMinutes(
|
|
25
|
+
OneUptimeDate.getCurrentDate(),
|
|
26
|
+
-Service.STALE_TEST_CLAIM_TIMEOUT_IN_MINUTES,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return await this.executeTransaction(
|
|
30
|
+
async (transactionalEntityManager: EntityManager) => {
|
|
31
|
+
const selectQuery: string = `
|
|
32
|
+
SELECT mt."_id"
|
|
33
|
+
FROM "MonitorTest" mt
|
|
34
|
+
WHERE mt."probeId" = $1
|
|
35
|
+
AND (
|
|
36
|
+
mt."isInQueue" = true
|
|
37
|
+
OR (
|
|
38
|
+
mt."isInQueue" = false
|
|
39
|
+
AND mt."updatedAt" <= $3
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
AND mt."monitorStepProbeResponse" IS NULL
|
|
43
|
+
AND mt."deletedAt" IS NULL
|
|
44
|
+
ORDER BY mt."createdAt" ASC
|
|
45
|
+
LIMIT $2
|
|
46
|
+
FOR UPDATE OF mt SKIP LOCKED
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const selectedRows: Array<{ _id: string }> =
|
|
50
|
+
await transactionalEntityManager.query(selectQuery, [
|
|
51
|
+
data.probeId.toString(),
|
|
52
|
+
data.limit,
|
|
53
|
+
staleClaimThreshold,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
if (selectedRows.length === 0) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ids: Array<string> = selectedRows.map((row: { _id: string }) => {
|
|
61
|
+
return row._id;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const updateQuery: string = `
|
|
65
|
+
UPDATE "MonitorTest"
|
|
66
|
+
SET "isInQueue" = false,
|
|
67
|
+
"updatedAt" = now()
|
|
68
|
+
WHERE "_id" = ANY($1::uuid[])
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
await transactionalEntityManager.query(updateQuery, [ids]);
|
|
72
|
+
|
|
73
|
+
return ids.map((id: string) => {
|
|
74
|
+
return new ObjectID(id);
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Merge a single step response into monitorStepProbeResponse atomically.
|
|
82
|
+
* This avoids step-response overwrite races when multiple step jobs are
|
|
83
|
+
* processed concurrently.
|
|
84
|
+
*/
|
|
85
|
+
public async mergeStepProbeResponse(data: {
|
|
86
|
+
testId: ObjectID;
|
|
87
|
+
monitorStepProbeResponse: MonitorStepProbeResponse;
|
|
88
|
+
}): Promise<void> {
|
|
89
|
+
const testedAt: Date = OneUptimeDate.getCurrentDate();
|
|
90
|
+
|
|
91
|
+
await this.executeTransaction(
|
|
92
|
+
async (transactionalEntityManager: EntityManager) => {
|
|
93
|
+
const updateQuery: string = `
|
|
94
|
+
UPDATE "MonitorTest"
|
|
95
|
+
SET "monitorStepProbeResponse" = COALESCE("monitorStepProbeResponse", '{}'::jsonb) || $2::jsonb,
|
|
96
|
+
"testedAt" = $3,
|
|
97
|
+
"updatedAt" = now()
|
|
98
|
+
WHERE "_id" = $1
|
|
99
|
+
AND "deletedAt" IS NULL
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
await transactionalEntityManager.query(updateQuery, [
|
|
103
|
+
data.testId.toString(),
|
|
104
|
+
JSON.stringify(data.monitorStepProbeResponse),
|
|
105
|
+
testedAt,
|
|
106
|
+
]);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
9
110
|
}
|
|
10
111
|
|
|
11
112
|
export default new Service();
|
|
@@ -19,6 +19,9 @@ import ObjectID from "../../Types/ObjectID";
|
|
|
19
19
|
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
20
20
|
import UserTotpAuth from "../../Models/DatabaseModels/UserTotpAuth";
|
|
21
21
|
import UserTotpAuthService from "./UserTotpAuthService";
|
|
22
|
+
import OneUptimeDate from "../../Types/Date";
|
|
23
|
+
|
|
24
|
+
const WEBAUTHN_CHALLENGE_TTL_MINUTES: number = 5;
|
|
22
25
|
|
|
23
26
|
export class Service extends DatabaseService<Model> {
|
|
24
27
|
public constructor() {
|
|
@@ -102,6 +105,21 @@ export class Service extends DatabaseService<Model> {
|
|
|
102
105
|
);
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
// Store the challenge server-side so verification uses a trusted value
|
|
109
|
+
await UserService.updateOneById({
|
|
110
|
+
id: data.userId,
|
|
111
|
+
data: {
|
|
112
|
+
webauthnChallenge: options.challenge,
|
|
113
|
+
webauthnChallengeExpiresAt: OneUptimeDate.addRemoveMinutes(
|
|
114
|
+
OneUptimeDate.getCurrentDate(),
|
|
115
|
+
WEBAUTHN_CHALLENGE_TTL_MINUTES,
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
props: {
|
|
119
|
+
isRoot: true,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
105
123
|
return {
|
|
106
124
|
options: options as any,
|
|
107
125
|
challenge: options.challenge,
|
|
@@ -110,16 +128,24 @@ export class Service extends DatabaseService<Model> {
|
|
|
110
128
|
|
|
111
129
|
@CaptureSpan()
|
|
112
130
|
public async verifyRegistration(data: {
|
|
113
|
-
challenge: string;
|
|
114
131
|
credential: any;
|
|
115
132
|
name: string;
|
|
116
133
|
props: DatabaseCommonInteractionProps;
|
|
117
134
|
}): Promise<void> {
|
|
135
|
+
if (!data.props.userId) {
|
|
136
|
+
throw new BadDataException("User ID not found in request");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Retrieve the challenge from the server-side store
|
|
140
|
+
const storedChallenge: string = await this.getAndClearStoredChallenge(
|
|
141
|
+
data.props.userId,
|
|
142
|
+
);
|
|
143
|
+
|
|
118
144
|
const expectedOrigin: string = `${HttpProtocol}${Host.toString()}`;
|
|
119
145
|
|
|
120
146
|
const verification: any = await verifyRegistrationResponse({
|
|
121
147
|
response: data.credential,
|
|
122
|
-
expectedChallenge:
|
|
148
|
+
expectedChallenge: storedChallenge,
|
|
123
149
|
expectedOrigin: expectedOrigin,
|
|
124
150
|
expectedRPID: Host.toString(),
|
|
125
151
|
});
|
|
@@ -134,10 +160,6 @@ export class Service extends DatabaseService<Model> {
|
|
|
134
160
|
throw new BadDataException("Registration info not found");
|
|
135
161
|
}
|
|
136
162
|
|
|
137
|
-
if (!data.props.userId) {
|
|
138
|
-
throw new BadDataException("User ID not found in request");
|
|
139
|
-
}
|
|
140
|
-
|
|
141
163
|
// Save the credential
|
|
142
164
|
const userWebAuthn: Model = Model.fromJSON(
|
|
143
165
|
{
|
|
@@ -213,6 +235,21 @@ export class Service extends DatabaseService<Model> {
|
|
|
213
235
|
options.challenge = Buffer.from(options.challenge).toString("base64url");
|
|
214
236
|
// allowCredentials id is already base64url string
|
|
215
237
|
|
|
238
|
+
// Store the challenge server-side so verification uses a trusted value
|
|
239
|
+
await UserService.updateOneById({
|
|
240
|
+
id: user.id!,
|
|
241
|
+
data: {
|
|
242
|
+
webauthnChallenge: options.challenge,
|
|
243
|
+
webauthnChallengeExpiresAt: OneUptimeDate.addRemoveMinutes(
|
|
244
|
+
OneUptimeDate.getCurrentDate(),
|
|
245
|
+
WEBAUTHN_CHALLENGE_TTL_MINUTES,
|
|
246
|
+
),
|
|
247
|
+
},
|
|
248
|
+
props: {
|
|
249
|
+
isRoot: true,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
216
253
|
return {
|
|
217
254
|
options: options as any,
|
|
218
255
|
challenge: options.challenge,
|
|
@@ -223,9 +260,13 @@ export class Service extends DatabaseService<Model> {
|
|
|
223
260
|
@CaptureSpan()
|
|
224
261
|
public async verifyAuthentication(data: {
|
|
225
262
|
userId: string;
|
|
226
|
-
challenge: string;
|
|
227
263
|
credential: any;
|
|
228
264
|
}): Promise<User> {
|
|
265
|
+
// Retrieve the challenge from the server-side store
|
|
266
|
+
const storedChallenge: string = await this.getAndClearStoredChallenge(
|
|
267
|
+
new ObjectID(data.userId),
|
|
268
|
+
);
|
|
269
|
+
|
|
229
270
|
const user: User | null = await UserService.findOneById({
|
|
230
271
|
id: new ObjectID(data.userId),
|
|
231
272
|
select: {
|
|
@@ -267,7 +308,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
267
308
|
|
|
268
309
|
const verification: any = await verifyAuthenticationResponse({
|
|
269
310
|
response: data.credential,
|
|
270
|
-
expectedChallenge:
|
|
311
|
+
expectedChallenge: storedChallenge,
|
|
271
312
|
expectedOrigin: expectedOrigin,
|
|
272
313
|
expectedRPID: Host.toString(),
|
|
273
314
|
credential: {
|
|
@@ -295,6 +336,73 @@ export class Service extends DatabaseService<Model> {
|
|
|
295
336
|
return user;
|
|
296
337
|
}
|
|
297
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Retrieves the stored WebAuthn challenge for the given user,
|
|
341
|
+
* validates it has not expired, and clears it so it cannot be reused.
|
|
342
|
+
*/
|
|
343
|
+
private async getAndClearStoredChallenge(userId: ObjectID): Promise<string> {
|
|
344
|
+
const user: User | null = await UserService.findOneById({
|
|
345
|
+
id: userId,
|
|
346
|
+
select: {
|
|
347
|
+
webauthnChallenge: true,
|
|
348
|
+
webauthnChallengeExpiresAt: true,
|
|
349
|
+
},
|
|
350
|
+
props: {
|
|
351
|
+
isRoot: true,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!user) {
|
|
356
|
+
throw new BadDataException("User not found");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!user.webauthnChallenge || !user.webauthnChallengeExpiresAt) {
|
|
360
|
+
throw new BadDataException(
|
|
361
|
+
"No pending WebAuthn challenge found. Please initiate the WebAuthn flow again.",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check expiry
|
|
366
|
+
if (
|
|
367
|
+
OneUptimeDate.isBefore(
|
|
368
|
+
user.webauthnChallengeExpiresAt,
|
|
369
|
+
OneUptimeDate.getCurrentDate(),
|
|
370
|
+
)
|
|
371
|
+
) {
|
|
372
|
+
// Clear the expired challenge
|
|
373
|
+
await UserService.updateOneById({
|
|
374
|
+
id: userId,
|
|
375
|
+
data: {
|
|
376
|
+
webauthnChallenge: null as any,
|
|
377
|
+
webauthnChallengeExpiresAt: null as any,
|
|
378
|
+
},
|
|
379
|
+
props: {
|
|
380
|
+
isRoot: true,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
throw new BadDataException(
|
|
385
|
+
"WebAuthn challenge has expired. Please initiate the WebAuthn flow again.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const challenge: string = user.webauthnChallenge;
|
|
390
|
+
|
|
391
|
+
// Clear the challenge immediately so it cannot be reused (one-time use)
|
|
392
|
+
await UserService.updateOneById({
|
|
393
|
+
id: userId,
|
|
394
|
+
data: {
|
|
395
|
+
webauthnChallenge: null as any,
|
|
396
|
+
webauthnChallengeExpiresAt: null as any,
|
|
397
|
+
},
|
|
398
|
+
props: {
|
|
399
|
+
isRoot: true,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
return challenge;
|
|
404
|
+
}
|
|
405
|
+
|
|
298
406
|
@CaptureSpan()
|
|
299
407
|
protected override async onBeforeCreate(
|
|
300
408
|
createBy: CreateBy<Model>,
|
package/Server/Utils/Browser.ts
CHANGED
|
@@ -27,7 +27,6 @@ export default class BrowserUtil {
|
|
|
27
27
|
"--disable-features=dbus", // additional D-Bus feature gate
|
|
28
28
|
"--no-zygote", // skip zygote process that fails OOM score adjustments in containers
|
|
29
29
|
// Memory optimization flags
|
|
30
|
-
"--single-process", // run browser in single process to reduce memory overhead
|
|
31
30
|
"--disable-extensions", // no extensions needed for monitoring
|
|
32
31
|
"--disable-background-networking", // disable background network requests
|
|
33
32
|
"--disable-default-apps", // don't load default apps
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
|
|
4
|
+
const UNLIMITED_CGROUP_THRESHOLD_BYTES: number = 1 << 60; // treat absurdly large cgroup limit as "unlimited"
|
|
5
|
+
|
|
6
|
+
export default class MemoryUtil {
|
|
7
|
+
public static getHostFreeMemoryInBytes(): number {
|
|
8
|
+
return os.freemem();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public static getContainerAwareAvailableMemoryInBytes(): number {
|
|
12
|
+
const hostFreeMemory: number = this.getHostFreeMemoryInBytes();
|
|
13
|
+
const cgroupAvailableMemory: number | null =
|
|
14
|
+
this.getCgroupAvailableMemoryInBytes();
|
|
15
|
+
|
|
16
|
+
if (cgroupAvailableMemory === null) {
|
|
17
|
+
return hostFreeMemory;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Be conservative: never exceed container-available memory.
|
|
21
|
+
return Math.min(hostFreeMemory, cgroupAvailableMemory);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public static getCgroupAvailableMemoryInBytes(): number | null {
|
|
25
|
+
// cgroup v2
|
|
26
|
+
const v2Limit: number | null = this.readNumericFile(
|
|
27
|
+
"/sys/fs/cgroup/memory.max",
|
|
28
|
+
);
|
|
29
|
+
const v2Usage: number | null = this.readNumericFile(
|
|
30
|
+
"/sys/fs/cgroup/memory.current",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
v2Limit &&
|
|
35
|
+
v2Usage !== null &&
|
|
36
|
+
v2Limit > 0 &&
|
|
37
|
+
v2Limit < UNLIMITED_CGROUP_THRESHOLD_BYTES
|
|
38
|
+
) {
|
|
39
|
+
return Math.max(v2Limit - v2Usage, 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// cgroup v1
|
|
43
|
+
const v1Limit: number | null = this.readNumericFile(
|
|
44
|
+
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
45
|
+
);
|
|
46
|
+
const v1Usage: number | null = this.readNumericFile(
|
|
47
|
+
"/sys/fs/cgroup/memory/memory.usage_in_bytes",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
v1Limit &&
|
|
52
|
+
v1Usage !== null &&
|
|
53
|
+
v1Limit > 0 &&
|
|
54
|
+
v1Limit < UNLIMITED_CGROUP_THRESHOLD_BYTES
|
|
55
|
+
) {
|
|
56
|
+
return Math.max(v1Limit - v1Usage, 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private static readNumericFile(path: string): number | null {
|
|
63
|
+
try {
|
|
64
|
+
const rawValue: string = fs.readFileSync(path, "utf8").trim();
|
|
65
|
+
|
|
66
|
+
if (!rawValue || rawValue === "max") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const value: number = Number(rawValue);
|
|
71
|
+
|
|
72
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return value;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -235,7 +235,10 @@ export default class CompareCriteria {
|
|
|
235
235
|
|
|
236
236
|
if (data.criteriaFilter.filterType === FilterType.IsNotEmpty) {
|
|
237
237
|
if (data.value !== null && data.value !== undefined) {
|
|
238
|
-
|
|
238
|
+
const valueStr: string = String(data.value);
|
|
239
|
+
const truncatedValue: string =
|
|
240
|
+
valueStr.length > 500 ? valueStr.substring(0, 500) + "..." : valueStr;
|
|
241
|
+
return `${data.criteriaFilter.checkOn} is not empty. Value: ${truncatedValue}`;
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
return null;
|
|
@@ -238,7 +238,11 @@ export default class IncomingEmailCriteria {
|
|
|
238
238
|
|
|
239
239
|
if (criteriaFilter.filterType === FilterType.IsNotEmpty) {
|
|
240
240
|
if (fieldValue && fieldValue.trim() !== "") {
|
|
241
|
-
|
|
241
|
+
const truncatedValue: string =
|
|
242
|
+
fieldValue.length > 500
|
|
243
|
+
? fieldValue.substring(0, 500) + "..."
|
|
244
|
+
: fieldValue;
|
|
245
|
+
return `${fieldName} is not empty. Value: ${truncatedValue}`;
|
|
242
246
|
}
|
|
243
247
|
return null;
|
|
244
248
|
}
|
|
@@ -1,13 +1,159 @@
|
|
|
1
1
|
import ReturnResult from "../../../Types/IsolatedVM/ReturnResult";
|
|
2
|
-
import { JSONObject } from "../../../Types/JSON";
|
|
2
|
+
import { JSONObject, JSONValue } from "../../../Types/JSON";
|
|
3
3
|
import axios, { AxiosResponse } from "axios";
|
|
4
4
|
import crypto from "crypto";
|
|
5
5
|
import http from "http";
|
|
6
6
|
import https from "https";
|
|
7
7
|
import ivm from "isolated-vm";
|
|
8
8
|
import CaptureSpan from "../Telemetry/CaptureSpan";
|
|
9
|
+
import Dictionary from "../../../Types/Dictionary";
|
|
10
|
+
import GenericObject from "../../../Types/GenericObject";
|
|
11
|
+
import vm, { Context } from "vm";
|
|
9
12
|
|
|
10
13
|
export default class VMRunner {
|
|
14
|
+
@CaptureSpan()
|
|
15
|
+
public static async runCodeInNodeVM(data: {
|
|
16
|
+
code: string;
|
|
17
|
+
options: {
|
|
18
|
+
timeout?: number;
|
|
19
|
+
args?: JSONObject | undefined;
|
|
20
|
+
context?: Dictionary<GenericObject | string> | undefined;
|
|
21
|
+
};
|
|
22
|
+
}): Promise<ReturnResult> {
|
|
23
|
+
const { code, options } = data;
|
|
24
|
+
const timeout: number = options.timeout || 5000;
|
|
25
|
+
|
|
26
|
+
const logMessages: string[] = [];
|
|
27
|
+
const MAX_LOG_BYTES: number = 1_000_000; // 1MB cap
|
|
28
|
+
let totalLogBytes: number = 0;
|
|
29
|
+
|
|
30
|
+
// Track timer handles so we can clean them up after execution
|
|
31
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
32
|
+
const pendingTimeouts: TimerHandle[] = [];
|
|
33
|
+
const pendingIntervals: TimerHandle[] = [];
|
|
34
|
+
|
|
35
|
+
const wrappedSetTimeout: (
|
|
36
|
+
fn: (...args: unknown[]) => void,
|
|
37
|
+
ms?: number,
|
|
38
|
+
...rest: unknown[]
|
|
39
|
+
) => TimerHandle = (
|
|
40
|
+
fn: (...args: unknown[]) => void,
|
|
41
|
+
ms?: number,
|
|
42
|
+
...rest: unknown[]
|
|
43
|
+
): TimerHandle => {
|
|
44
|
+
const handle: TimerHandle = setTimeout(fn, ms, ...rest);
|
|
45
|
+
pendingTimeouts.push(handle);
|
|
46
|
+
return handle;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const wrappedClearTimeout: (handle: TimerHandle) => void = (
|
|
50
|
+
handle: TimerHandle,
|
|
51
|
+
): void => {
|
|
52
|
+
clearTimeout(handle);
|
|
53
|
+
const idx: number = pendingTimeouts.indexOf(handle);
|
|
54
|
+
if (idx !== -1) {
|
|
55
|
+
pendingTimeouts.splice(idx, 1);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const wrappedSetInterval: (
|
|
60
|
+
fn: (...args: unknown[]) => void,
|
|
61
|
+
ms?: number,
|
|
62
|
+
...rest: unknown[]
|
|
63
|
+
) => TimerHandle = (
|
|
64
|
+
fn: (...args: unknown[]) => void,
|
|
65
|
+
ms?: number,
|
|
66
|
+
...rest: unknown[]
|
|
67
|
+
): TimerHandle => {
|
|
68
|
+
const handle: TimerHandle = setInterval(fn, ms, ...rest);
|
|
69
|
+
pendingIntervals.push(handle);
|
|
70
|
+
return handle;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const wrappedClearInterval: (handle: TimerHandle) => void = (
|
|
74
|
+
handle: TimerHandle,
|
|
75
|
+
): void => {
|
|
76
|
+
clearInterval(handle);
|
|
77
|
+
const idx: number = pendingIntervals.indexOf(handle);
|
|
78
|
+
if (idx !== -1) {
|
|
79
|
+
pendingIntervals.splice(idx, 1);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let sandbox: Context = {
|
|
84
|
+
process: Object.freeze(Object.create(null)),
|
|
85
|
+
console: {
|
|
86
|
+
log: (...args: JSONValue[]) => {
|
|
87
|
+
const msg: string = args.join(" ");
|
|
88
|
+
totalLogBytes += msg.length;
|
|
89
|
+
if (totalLogBytes <= MAX_LOG_BYTES) {
|
|
90
|
+
logMessages.push(msg);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
http: http,
|
|
95
|
+
https: https,
|
|
96
|
+
axios: axios,
|
|
97
|
+
crypto: crypto,
|
|
98
|
+
setTimeout: wrappedSetTimeout,
|
|
99
|
+
clearTimeout: wrappedClearTimeout,
|
|
100
|
+
setInterval: wrappedSetInterval,
|
|
101
|
+
clearInterval: wrappedClearInterval,
|
|
102
|
+
...options.context,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (options.args) {
|
|
106
|
+
sandbox = {
|
|
107
|
+
...sandbox,
|
|
108
|
+
args: options.args,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
vm.createContext(sandbox);
|
|
113
|
+
|
|
114
|
+
const script: string = `(async()=>{
|
|
115
|
+
${code}
|
|
116
|
+
})()`;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
/*
|
|
120
|
+
* vm timeout only covers synchronous CPU time, so wrap with
|
|
121
|
+
* Promise.race to also cover async operations (network, timers, etc.)
|
|
122
|
+
*/
|
|
123
|
+
const vmPromise: Promise<unknown> = vm.runInContext(script, sandbox, {
|
|
124
|
+
timeout: timeout,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const overallTimeout: Promise<never> = new Promise(
|
|
128
|
+
(_resolve: (value: never) => void, reject: (reason: Error) => void) => {
|
|
129
|
+
const handle: NodeJS.Timeout = global.setTimeout(() => {
|
|
130
|
+
reject(new Error("Script execution timed out"));
|
|
131
|
+
}, timeout + 5000);
|
|
132
|
+
// Don't let this timer keep the process alive
|
|
133
|
+
handle.unref();
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const returnVal: unknown = await Promise.race([
|
|
138
|
+
vmPromise,
|
|
139
|
+
overallTimeout,
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
returnValue: returnVal,
|
|
144
|
+
logMessages,
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
// Clean up any lingering timers to prevent resource leaks
|
|
148
|
+
for (const handle of pendingTimeouts) {
|
|
149
|
+
clearTimeout(handle);
|
|
150
|
+
}
|
|
151
|
+
for (const handle of pendingIntervals) {
|
|
152
|
+
clearInterval(handle);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
11
157
|
@CaptureSpan()
|
|
12
158
|
public static async runCodeInSandbox(data: {
|
|
13
159
|
code: string;
|
|
@@ -7,6 +7,7 @@ jest.mock("../../../../Server/EnvironmentConfig", () => {
|
|
|
7
7
|
|
|
8
8
|
jest.mock("../../../../Server/Middleware/ClusterKeyAuthorization", () => {
|
|
9
9
|
return {
|
|
10
|
+
__esModule: true,
|
|
10
11
|
default: {
|
|
11
12
|
getClusterKeyHeaders: () => {
|
|
12
13
|
return {};
|
|
@@ -17,12 +18,14 @@ jest.mock("../../../../Server/Middleware/ClusterKeyAuthorization", () => {
|
|
|
17
18
|
|
|
18
19
|
jest.mock("../../../../Utils/API", () => {
|
|
19
20
|
return {
|
|
21
|
+
__esModule: true,
|
|
20
22
|
default: { post: jest.fn() },
|
|
21
23
|
};
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
jest.mock("../../../../Server/Utils/Logger", () => {
|
|
25
27
|
return {
|
|
28
|
+
__esModule: true,
|
|
26
29
|
default: {
|
|
27
30
|
error: jest.fn(),
|
|
28
31
|
debug: jest.fn(),
|
|
@@ -34,6 +37,7 @@ jest.mock("../../../../Server/Utils/Logger", () => {
|
|
|
34
37
|
|
|
35
38
|
jest.mock("../../../../Server/Utils/Telemetry/CaptureSpan", () => {
|
|
36
39
|
return {
|
|
40
|
+
__esModule: true,
|
|
37
41
|
default: () => {
|
|
38
42
|
return (
|
|
39
43
|
_target: any,
|