@tellescope/sdk 1.255.6 → 1.255.7
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/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.js +250 -0
- package/lib/cjs/tests/api_tests/journey_delete_cancels_actions.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.d.ts +36 -0
- package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.js +203 -0
- package/lib/cjs/tests/api_tests/security/register_business_id_injection.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +174 -166
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +2 -3
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.js +223 -0
- package/lib/esm/tests/api_tests/journey_delete_cancels_actions.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/register_business_id_injection.test.d.ts +36 -0
- package/lib/esm/tests/api_tests/security/register_business_id_injection.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/register_business_id_injection.test.js +196 -0
- package/lib/esm/tests/api_tests/security/register_business_id_injection.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +174 -166
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/src/tests/api_tests/journey_delete_cancels_actions.test.ts +152 -0
- package/src/tests/api_tests/security/register_business_id_injection.test.ts +140 -0
- package/src/tests/tests.ts +4 -0
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Session } from '../../sdk'
|
|
2
|
+
import { log_header, wait } from "@tellescope/testing"
|
|
3
|
+
import { AUTOMATED_ACTION_CANCEL_REASONS } from "@tellescope/constants"
|
|
4
|
+
|
|
5
|
+
const host = process.env.API_URL || 'http://localhost:8080'
|
|
6
|
+
|
|
7
|
+
export const journey_delete_cancels_actions_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
|
|
8
|
+
log_header("Journey Delete Cancels Pending Actions Tests")
|
|
9
|
+
|
|
10
|
+
let journeyId: string | undefined
|
|
11
|
+
let enduserId: string | undefined
|
|
12
|
+
let stepId: string | undefined
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
// Create a journey + an automation_step
|
|
16
|
+
const journey = await sdk.api.journeys.createOne({ title: "Cascade Cancel Journey " + Date.now() })
|
|
17
|
+
journeyId = journey.id
|
|
18
|
+
|
|
19
|
+
const step = await sdk.api.automation_steps.createOne({
|
|
20
|
+
journeyId: journey.id,
|
|
21
|
+
action: { type: 'setEnduserStatus', info: { status: 'Test Status' } },
|
|
22
|
+
events: [{ type: 'onJourneyStart', info: {} }],
|
|
23
|
+
})
|
|
24
|
+
stepId = step.id
|
|
25
|
+
|
|
26
|
+
// Create an enduser
|
|
27
|
+
const enduser = await sdk.api.endusers.createOne({
|
|
28
|
+
fname: 'Cascade',
|
|
29
|
+
lname: 'Cancel',
|
|
30
|
+
email: 'test-cascade-cancel-' + Date.now() + '@example.com',
|
|
31
|
+
})
|
|
32
|
+
enduserId = enduser.id
|
|
33
|
+
|
|
34
|
+
// Create an active action with a far-future processAfter so the worker won't process it during the test
|
|
35
|
+
const activeAction = await sdk.api.automated_actions.createOne({
|
|
36
|
+
journeyId: journey.id,
|
|
37
|
+
automationStepId: step.id,
|
|
38
|
+
enduserId: enduser.id,
|
|
39
|
+
processAfter: Date.now() + 1000000, // far future so the worker won't process it
|
|
40
|
+
status: 'active',
|
|
41
|
+
event: { type: 'onJourneyStart', info: {} },
|
|
42
|
+
action: { type: 'setEnduserStatus', info: { status: 'Test Status' } },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Create a finished action to confirm it is left untouched by the cascade
|
|
46
|
+
const finishedAction = await sdk.api.automated_actions.createOne({
|
|
47
|
+
journeyId: journey.id,
|
|
48
|
+
automationStepId: step.id,
|
|
49
|
+
enduserId: enduser.id,
|
|
50
|
+
processAfter: Date.now() + 1000000,
|
|
51
|
+
status: 'finished',
|
|
52
|
+
event: { type: 'onJourneyStart', info: {} },
|
|
53
|
+
action: { type: 'setEnduserStatus', info: { status: 'Test Status' } },
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Assert the action starts active
|
|
57
|
+
const before = await sdk.api.automated_actions.getOne(activeAction.id)
|
|
58
|
+
if (before.status !== 'active') {
|
|
59
|
+
throw new Error(`Expected action to start 'active', got '${before.status}'`)
|
|
60
|
+
}
|
|
61
|
+
console.log("✓ Action is active before journey deletion")
|
|
62
|
+
|
|
63
|
+
// Delete the journey — this should cascade cancel the pending action
|
|
64
|
+
await sdk.api.journeys.deleteOne(journey.id)
|
|
65
|
+
journeyId = undefined // already deleted, skip in cleanup
|
|
66
|
+
|
|
67
|
+
// Poll until the active action transitions to cancelled with the Journey Deleted reason,
|
|
68
|
+
// attributed to the deleting user via cancelledBy
|
|
69
|
+
const expectedReason = AUTOMATED_ACTION_CANCEL_REASONS.indexOf('Journey Deleted')
|
|
70
|
+
await pollForResults(
|
|
71
|
+
async () => {
|
|
72
|
+
const actions = await sdk.api.automated_actions.getSome({ filter: { enduserId: enduser.id } })
|
|
73
|
+
const active = actions.find(a => a.id === activeAction.id)
|
|
74
|
+
const finished = actions.find(a => a.id === finishedAction.id)
|
|
75
|
+
return { active, finished }
|
|
76
|
+
},
|
|
77
|
+
({ active }) => (
|
|
78
|
+
active?.status === 'cancelled'
|
|
79
|
+
&& active?.cancelReason === expectedReason
|
|
80
|
+
&& active?.cancelledBy === sdk.userInfo.id
|
|
81
|
+
),
|
|
82
|
+
'Active action should be cancelled with Journey Deleted reason and cancelledBy after journey deletion',
|
|
83
|
+
)
|
|
84
|
+
console.log("✓ Active action cancelled with Journey Deleted reason and cancelledBy set to deleting user")
|
|
85
|
+
|
|
86
|
+
// Not covered here: journey deletion also removes outstanding_froms_trackers. Creating a tracker
|
|
87
|
+
// requires an automation to actually send an email/SMS with multiple form links, and the collection
|
|
88
|
+
// is internal-only (no API read access), so it can't be verified from an SDK test.
|
|
89
|
+
|
|
90
|
+
// Confirm the finished action was left untouched
|
|
91
|
+
const finishedAfter = await sdk.api.automated_actions.getOne(finishedAction.id)
|
|
92
|
+
if (finishedAfter.status !== 'finished') {
|
|
93
|
+
throw new Error(`Expected finished action to remain 'finished', got '${finishedAfter.status}'`)
|
|
94
|
+
}
|
|
95
|
+
console.log("✓ Finished action left untouched")
|
|
96
|
+
|
|
97
|
+
console.log("✅ Journey Delete Cancels Pending Actions tests completed successfully")
|
|
98
|
+
} finally {
|
|
99
|
+
// Cleanup — journey is already deleted if the flow completed
|
|
100
|
+
await Promise.all([
|
|
101
|
+
enduserId ? sdk.api.endusers.deleteOne(enduserId).catch(() => {}) : undefined,
|
|
102
|
+
stepId ? sdk.api.automation_steps.deleteOne(stepId).catch(() => {}) : undefined,
|
|
103
|
+
journeyId ? sdk.api.journeys.deleteOne(journeyId).catch(() => {}) : undefined,
|
|
104
|
+
])
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const pollForResults = async <T>(
|
|
109
|
+
fetchFn: () => Promise<T>,
|
|
110
|
+
evaluateFn: (result: T) => boolean,
|
|
111
|
+
description: string,
|
|
112
|
+
intervalMs = 500,
|
|
113
|
+
maxIterations = 30,
|
|
114
|
+
): Promise<void> => {
|
|
115
|
+
let lastResult: T | undefined
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
118
|
+
await wait(undefined, intervalMs)
|
|
119
|
+
|
|
120
|
+
lastResult = await fetchFn()
|
|
121
|
+
if (evaluateFn(lastResult)) {
|
|
122
|
+
console.log(`✓ ${description} - completed after ${(i + 1) * intervalMs}ms`)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log('Final polling result:', lastResult)
|
|
128
|
+
throw new Error(`Polling timeout: ${description} - waited ${maxIterations * intervalMs}ms`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Allow running this test file independently
|
|
132
|
+
if (require.main === module) {
|
|
133
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
134
|
+
const sdk = new Session({ host })
|
|
135
|
+
const sdkNonAdmin = new Session({ host })
|
|
136
|
+
|
|
137
|
+
const runTests = async () => {
|
|
138
|
+
const { setup_tests } = await import("../setup")
|
|
139
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
140
|
+
await journey_delete_cancels_actions_tests({ sdk, sdkNonAdmin })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
runTests()
|
|
144
|
+
.then(() => {
|
|
145
|
+
console.log("✅ Journey Delete Cancels Pending Actions test suite completed successfully")
|
|
146
|
+
process.exit(0)
|
|
147
|
+
})
|
|
148
|
+
.catch((error) => {
|
|
149
|
+
console.error("❌ Journey Delete Cancels Pending Actions test suite failed:", error)
|
|
150
|
+
process.exit(1)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
import { Session } from "../../../sdk"
|
|
5
|
+
import {
|
|
6
|
+
assert,
|
|
7
|
+
log_header,
|
|
8
|
+
wait,
|
|
9
|
+
} from "@tellescope/testing"
|
|
10
|
+
import { setup_tests } from "../../setup"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
const post = async (path: string, body: any, headers: Record<string, string> = {}) => {
|
|
15
|
+
try {
|
|
16
|
+
const res = await axios.post(`${host}${path}`, body, {
|
|
17
|
+
validateStatus: () => true,
|
|
18
|
+
headers,
|
|
19
|
+
})
|
|
20
|
+
return { status: res.status, data: res.data }
|
|
21
|
+
} catch (err: any) {
|
|
22
|
+
return { status: err?.response?.status, data: err?.response?.data }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reproduction + regression test for the reported vulnerability:
|
|
28
|
+
*
|
|
29
|
+
* A @wearehackerone.com email "joined" a customer's organization without being invited, and
|
|
30
|
+
* duplicate user records existed for that email — each bound to a different businessId.
|
|
31
|
+
*
|
|
32
|
+
* Root cause: the public `POST /v1/users/register` endpoint. `addPublicEndpoint` accepts an
|
|
33
|
+
* OPTIONAL, caller-supplied `businessId` (routing.ts) and bakes it into the handler's DB. The
|
|
34
|
+
* register handler's only duplicate guard (`DB.users.findOne({ email })`) is therefore scoped to
|
|
35
|
+
* whatever org the caller named, and the subsequent `DB.users.insertOne(...)` stamps that
|
|
36
|
+
* caller-supplied businessId onto a new `accountType: 'Business'` user — bypassing the
|
|
37
|
+
* globalUnique: ['email'] enforcement that only runs in the generic create route.
|
|
38
|
+
*
|
|
39
|
+
* The "victim org" here is the test admin's own org (sdk.userInfo.businessId): an org the
|
|
40
|
+
* anonymous caller was never invited to. The admin is used purely to OBSERVE whether an
|
|
41
|
+
* uninvited user record materialized inside their org (getOne({ email }) is org-scoped and
|
|
42
|
+
* throws when the email is absent from the caller's tenant).
|
|
43
|
+
*
|
|
44
|
+
* Expected results:
|
|
45
|
+
* - VULNERABLE code: the assertions FAIL — the planted user appears inside the victim org,
|
|
46
|
+
* proving the exploit ("EXPLOIT CONFIRMED" in the failure message).
|
|
47
|
+
* - FIXED code: register ignores the caller-supplied businessId (self-signups are created
|
|
48
|
+
* org-less and duplicates are rejected globally), so nothing appears in the victim org and
|
|
49
|
+
* the assertions PASS.
|
|
50
|
+
*
|
|
51
|
+
* Uses unique emails per run (Date.now()) so re-runs never collide on the platform-wide unique
|
|
52
|
+
* email constraint. NOTE: on the FIXED path these registrations create org-less user records
|
|
53
|
+
* that an org-scoped admin cannot delete — harmless residue (unique emails), inherent to the
|
|
54
|
+
* legitimate "root register, no org" signup behavior.
|
|
55
|
+
*/
|
|
56
|
+
export const register_business_id_injection_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
57
|
+
log_header("register businessId injection")
|
|
58
|
+
|
|
59
|
+
const victimBusinessId = sdk.userInfo.businessId
|
|
60
|
+
const password = 'testpassword123!'
|
|
61
|
+
const termsVersion = '1'
|
|
62
|
+
|
|
63
|
+
// ==================================================================================
|
|
64
|
+
// Scenario 1 — uninvited org join
|
|
65
|
+
// An anonymous caller registers with businessId set to the victim org. On vulnerable
|
|
66
|
+
// code this creates a Business user INSIDE the victim org that no one invited.
|
|
67
|
+
// ==================================================================================
|
|
68
|
+
const email1 = `security+reg-inject-${Date.now()}@wearehackerone.com`
|
|
69
|
+
|
|
70
|
+
const before = await sdk.api.users.getOne({ email: email1 }).catch(() => null)
|
|
71
|
+
assert(before === null, `precondition failed: ${email1} already present in victim org`, 'precondition: victim org has no such user')
|
|
72
|
+
|
|
73
|
+
const res1 = await post('/v1/users/register', { email: email1, password, termsVersion, businessId: victimBusinessId })
|
|
74
|
+
console.log(` register(businessId=victim) -> HTTP ${res1.status}`)
|
|
75
|
+
|
|
76
|
+
const planted1: any = await sdk.api.users.getOne({ email: email1 }).catch(() => null)
|
|
77
|
+
const injectedIntoVictimOrg = !!planted1 && planted1.businessId === victimBusinessId
|
|
78
|
+
|
|
79
|
+
// Clean up BEFORE asserting (a failed assert calls process.exit(1) and would skip cleanup).
|
|
80
|
+
if (planted1?.id) await sdk.api.users.deleteOne(planted1.id).catch(() => {})
|
|
81
|
+
|
|
82
|
+
assert(
|
|
83
|
+
!injectedIntoVictimOrg,
|
|
84
|
+
`❌ EXPLOIT CONFIRMED: anonymous POST /v1/users/register with businessId=${victimBusinessId} created user `
|
|
85
|
+
+ `${email1} (accountType=${planted1?.accountType}) inside the victim org — no invite required`,
|
|
86
|
+
'✅ register ignores caller-supplied businessId — no user injected into victim org',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await wait(undefined, 1200) // register endpoint rate-limits ~1/sec per IP
|
|
90
|
+
|
|
91
|
+
// ==================================================================================
|
|
92
|
+
// Scenario 2 — cross-org duplicate for the same email
|
|
93
|
+
// First register with NO businessId (the legitimate "root register, no org" record —
|
|
94
|
+
// the "oldest" account in the report). Then register the SAME email WITH the victim
|
|
95
|
+
// businessId. On vulnerable code the scoped duplicate check misses the org-less record,
|
|
96
|
+
// so a SECOND user record for the same email is created inside the victim org.
|
|
97
|
+
// ==================================================================================
|
|
98
|
+
const email2 = `security+reg-dup-${Date.now()}@wearehackerone.com`
|
|
99
|
+
|
|
100
|
+
const resRoot = await post('/v1/users/register', { email: email2, password, termsVersion })
|
|
101
|
+
console.log(` register(no businessId) -> HTTP ${resRoot.status}`)
|
|
102
|
+
|
|
103
|
+
await wait(undefined, 1200)
|
|
104
|
+
|
|
105
|
+
const resDup = await post('/v1/users/register', { email: email2, password, termsVersion, businessId: victimBusinessId })
|
|
106
|
+
console.log(` register(same email, businessId=victim) -> HTTP ${resDup.status}`)
|
|
107
|
+
|
|
108
|
+
const planted2: any = await sdk.api.users.getOne({ email: email2 }).catch(() => null)
|
|
109
|
+
const duplicateInVictimOrg = !!planted2 && planted2.businessId === victimBusinessId
|
|
110
|
+
|
|
111
|
+
if (planted2?.id) await sdk.api.users.deleteOne(planted2.id).catch(() => {})
|
|
112
|
+
|
|
113
|
+
assert(
|
|
114
|
+
!duplicateInVictimOrg,
|
|
115
|
+
`❌ EXPLOIT CONFIRMED: same email ${email2} registered into a second org — duplicate user `
|
|
116
|
+
+ `(accountType=${planted2?.accountType}) created in victim org ${victimBusinessId}`,
|
|
117
|
+
'✅ register enforces global email uniqueness — no cross-org duplicate created',
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Allow running this test file independently
|
|
122
|
+
if (require.main === module) {
|
|
123
|
+
const sdk = new Session({ host })
|
|
124
|
+
const sdkNonAdmin = new Session({ host })
|
|
125
|
+
|
|
126
|
+
const runTests = async () => {
|
|
127
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
128
|
+
await register_business_id_injection_tests({ sdk, sdkNonAdmin })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
runTests()
|
|
132
|
+
.then(() => {
|
|
133
|
+
console.log("✅ register businessId injection test suite completed successfully")
|
|
134
|
+
process.exit(0)
|
|
135
|
+
})
|
|
136
|
+
.catch((error) => {
|
|
137
|
+
console.error("❌ register businessId injection test suite failed:", error)
|
|
138
|
+
process.exit(1)
|
|
139
|
+
})
|
|
140
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { appointment_rescheduled_trigger_tests } from "./api_tests/appointment_r
|
|
|
53
53
|
import { appointment_no_showed_trigger_tests } from "./api_tests/appointment_no_showed_trigger.test"
|
|
54
54
|
import { group_event_attendee_status_triggers_tests } from "./api_tests/group_event_attendee_status_triggers.test"
|
|
55
55
|
import { journey_error_branching_tests } from "./api_tests/journey_error_branching.test"
|
|
56
|
+
import { journey_delete_cancels_actions_tests } from "./api_tests/journey_delete_cancels_actions.test"
|
|
56
57
|
import { afteraction_day_of_month_delay_tests } from "./api_tests/afteraction_day_of_month_delay.test"
|
|
57
58
|
import { push_forms_to_portal_group_completion_tests } from "./api_tests/push_forms_to_portal_group_completion.test"
|
|
58
59
|
import { setup_tests } from "./setup"
|
|
@@ -102,6 +103,7 @@ import { ai_conversations_rbac_tests } from "./api_tests/security/F-0005-ai-conv
|
|
|
102
103
|
import { cascade_role_rename_cross_tenant_tests } from "./api_tests/security/F-0053-cascade-role-rename-cross-tenant.test";
|
|
103
104
|
import { self_admin_role_assignment_tests } from "./api_tests/security/F-0076-self-admin-role-assignment.test";
|
|
104
105
|
import { invite_user_enumeration_tests } from "./api_tests/security/F-0007-invite-user-enumeration.test";
|
|
106
|
+
import { register_business_id_injection_tests } from "./api_tests/security/register_business_id_injection.test";
|
|
105
107
|
import { handle_incoming_communication_cross_tenant_tests } from "./api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test";
|
|
106
108
|
import { sanitize_user_html_xss_tests } from "./api_tests/security/F-0013-sanitize-user-html.test";
|
|
107
109
|
import { prototype_pollution_tests } from "./api_tests/security/F-0016-prototype-pollution.test";
|
|
@@ -15036,6 +15038,7 @@ const ip_address_form_tests = async () => {
|
|
|
15036
15038
|
await replace_form_field_template_values_tests()
|
|
15037
15039
|
await mfa_tests()
|
|
15038
15040
|
await setup_tests(sdk, sdkNonAdmin)
|
|
15041
|
+
await register_business_id_injection_tests({ sdk, sdkNonAdmin })
|
|
15039
15042
|
await automation_trigger_tests()
|
|
15040
15043
|
await resource_access_tags_tests({ sdk, sdkNonAdmin })
|
|
15041
15044
|
await beluga_manual_sync_tests({ sdk, sdkNonAdmin })
|
|
@@ -15111,6 +15114,7 @@ const ip_address_form_tests = async () => {
|
|
|
15111
15114
|
await test_ticket_automation_assignment_and_optimization()
|
|
15112
15115
|
await monthly_availability_restrictions_tests({ sdk, sdkNonAdmin })
|
|
15113
15116
|
await journey_error_branching_tests({ sdk, sdkNonAdmin })
|
|
15117
|
+
await journey_delete_cancels_actions_tests({ sdk, sdkNonAdmin })
|
|
15114
15118
|
await message_assignment_trigger_tests({ sdk })
|
|
15115
15119
|
await inbox_threads_building_tests()
|
|
15116
15120
|
await inbox_threads_loading_tests()
|
package/test_generated.pdf
CHANGED
|
Binary file
|