@tellescope/sdk 1.250.2 → 1.252.0
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/sdk.d.ts +9 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +3 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/account_switcher.test.js +1700 -306
- package/lib/cjs/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js +337 -0
- package/lib/cjs/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js +315 -0
- package/lib/cjs/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js +287 -0
- package/lib/cjs/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +406 -0
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +349 -0
- package/lib/cjs/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +247 -0
- package/lib/cjs/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +278 -0
- package/lib/cjs/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +201 -0
- package/lib/cjs/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js +148 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js +88 -0
- package/lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js +373 -0
- package/lib/cjs/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/cjs/tests/setup.d.ts.map +1 -1
- package/lib/cjs/tests/setup.js +47 -32
- package/lib/cjs/tests/setup.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +215 -159
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +9 -0
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +3 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/account_switcher.test.js +1702 -305
- package/lib/esm/tests/api_tests/account_switcher.test.js.map +1 -1
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js +333 -0
- package/lib/esm/tests/api_tests/calendar_event_webhook_template.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js +308 -0
- package/lib/esm/tests/api_tests/enduser_login.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js +268 -0
- package/lib/esm/tests/api_tests/enduser_login_phi_disclosure.test.js.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js +280 -0
- package/lib/esm/tests/api_tests/enduser_login_rate_limits.test.js.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +402 -0
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts +28 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js +345 -0
- package/lib/esm/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts +28 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js +243 -0
- package/lib/esm/tests/api_tests/security/F-0005-ai-conversations-rbac.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js +271 -0
- package/lib/esm/tests/api_tests/security/F-0007-invite-user-enumeration.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts +24 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js +194 -0
- package/lib/esm/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js +144 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js +84 -0
- package/lib/esm/tests/api_tests/security/F-0016-prototype-pollution.test.js.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js +369 -0
- package/lib/esm/tests/api_tests/set_fields_order_templates.test.js.map +1 -0
- package/lib/esm/tests/setup.d.ts.map +1 -1
- package/lib/esm/tests/setup.js +47 -32
- package/lib/esm/tests/setup.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +215 -159
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +12 -0
- package/src/tests/api_tests/account_switcher.test.ts +1283 -0
- package/src/tests/api_tests/calendar_event_webhook_template.test.ts +204 -0
- package/src/tests/api_tests/enduser_login.test.ts +215 -0
- package/src/tests/api_tests/enduser_login_rate_limits.test.ts +178 -0
- package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +223 -0
- package/src/tests/api_tests/security/F-0001-data-sync-redaction-bypass.test.ts +236 -0
- package/src/tests/api_tests/security/F-0005-ai-conversations-rbac.test.ts +154 -0
- package/src/tests/api_tests/security/F-0007-invite-user-enumeration.test.ts +198 -0
- package/src/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.ts +130 -0
- package/src/tests/api_tests/security/F-0013-sanitize-user-html.test.ts +109 -0
- package/src/tests/api_tests/security/F-0016-prototype-pollution.test.ts +50 -0
- package/src/tests/api_tests/set_fields_order_templates.test.ts +258 -0
- package/src/tests/setup.ts +8 -1
- package/src/tests/tests.ts +35 -5
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
import { Session } from "../../../sdk"
|
|
5
|
+
import {
|
|
6
|
+
async_test,
|
|
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 CROSS_ORG_API_KEY = process.env.CROSS_ORG_API_KEY
|
|
15
|
+
const CROSS_ORG_TARGET_BUSINESS_ID = process.env.CROSS_ORG_TARGET_BUSINESS_ID
|
|
16
|
+
|
|
17
|
+
const post = async (path: string, body: any, headers: Record<string, string> = {}) => {
|
|
18
|
+
try {
|
|
19
|
+
const res = await axios.post(`${host}${path}`, body, {
|
|
20
|
+
validateStatus: () => true,
|
|
21
|
+
headers,
|
|
22
|
+
})
|
|
23
|
+
return { status: res.status, data: res.data }
|
|
24
|
+
} catch (err: any) {
|
|
25
|
+
return { status: err?.response?.status, data: err?.response?.data }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Regression test for F-0007 (security-audit/findings/F-0007-invite-user-cross-tenant-email-enumeration.md).
|
|
31
|
+
*
|
|
32
|
+
* `users.invite_user` previously used `buildAllQueries({ unrestricted: true, organizationIds: [] }).users.findOne({ email })`
|
|
33
|
+
* to enforce platform-wide email uniqueness and threw the distinctive `"A user with this email already exists"`
|
|
34
|
+
* error on duplicate — regardless of which tenant the existing user was in. Any authenticated user could
|
|
35
|
+
* therefore probe whether email X is registered to any tenant on the platform.
|
|
36
|
+
*
|
|
37
|
+
* **Tests only the negative case** — never drives a successful invite (which would create a real user
|
|
38
|
+
* record and send a real transactional email). All assertions use either:
|
|
39
|
+
* - An email that already exists in the test tenant (the admin's own email), so each call short-circuits
|
|
40
|
+
* at the same-tenant duplicate check or rate-limit check before any invite work happens, OR
|
|
41
|
+
* - The cross-org infrastructure (CROSS_ORG_API_KEY env var) targeting an email that exists in a different
|
|
42
|
+
* tenant — verifies the post-fix response does NOT distinguish "exists elsewhere" from a generic outcome.
|
|
43
|
+
*
|
|
44
|
+
* Assertions:
|
|
45
|
+
* 1. Rate-limit defense-in-depth: rapid same-tenant duplicate requests trip 429 within ~12 attempts.
|
|
46
|
+
* 2. Same-tenant duplicate: returns the same `"already exists"` error pre/post-fix (this branch is
|
|
47
|
+
* unchanged by the fix; asserted for regression-safety).
|
|
48
|
+
* 3. Cross-tenant duplicate (env-gated): post-fix response shape does NOT contain the `"already exists"`
|
|
49
|
+
* string and matches the silent-no-op shape `{ created: { id: ... } }`. Skipped when CROSS_ORG_*
|
|
50
|
+
* env vars are not set, mirroring cross_org_api_key.test.ts convention.
|
|
51
|
+
*/
|
|
52
|
+
export const invite_user_enumeration_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
53
|
+
log_header("F-0007: users.invite_user cross-tenant enumeration regression")
|
|
54
|
+
|
|
55
|
+
// Reset state so prior tests' rate-limit accounting doesn't leak in.
|
|
56
|
+
await sdk.reset_db()
|
|
57
|
+
|
|
58
|
+
const organizationId = sdk.userInfo.organizationIds?.[0] ?? sdk.userInfo.businessId
|
|
59
|
+
// Use the test admin's own email — guaranteed to exist in the test tenant, so every invite
|
|
60
|
+
// request short-circuits at the same-tenant duplicate check (or earlier at the rate-limit
|
|
61
|
+
// check). No real users get created, no emails get sent.
|
|
62
|
+
const sameTenantExistingEmail = process.env.TEST_EMAIL!
|
|
63
|
+
|
|
64
|
+
// ====================================================================
|
|
65
|
+
// Assertion 1: rate-limit defense-in-depth
|
|
66
|
+
// Fire 15 requests with the admin's own email. Post-fix: rate limit
|
|
67
|
+
// fires before the same-tenant duplicate check on call 11+. Pre-fix:
|
|
68
|
+
// every call returns 400 "already exists" forever.
|
|
69
|
+
// ====================================================================
|
|
70
|
+
let rateLimitedAt = -1
|
|
71
|
+
for (let i = 0; i < 15; i++) {
|
|
72
|
+
const r = await post(
|
|
73
|
+
'/v1/invite-user-to-organization',
|
|
74
|
+
{
|
|
75
|
+
email: sameTenantExistingEmail,
|
|
76
|
+
fname: 'F0007', lname: 'RateLimit',
|
|
77
|
+
organizationId,
|
|
78
|
+
},
|
|
79
|
+
{ Authorization: `Bearer ${sdk.authToken}` },
|
|
80
|
+
)
|
|
81
|
+
if (r.status === 429) {
|
|
82
|
+
rateLimitedAt = i
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await async_test(
|
|
88
|
+
"F-0007: invite_user rate-limits within ~12 rapid requests (defense-in-depth; no invites sent)",
|
|
89
|
+
async () => ({ rateLimitedAt }),
|
|
90
|
+
{ onResult: r => r.rateLimitedAt >= 0 && r.rateLimitedAt <= 12 },
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// ====================================================================
|
|
94
|
+
// Assertion 2: same-tenant duplicate returns the descriptive error
|
|
95
|
+
// (unchanged by the fix; regression guard so a future change to the
|
|
96
|
+
// duplicate-detection path doesn't accidentally suppress same-tenant
|
|
97
|
+
// errors that operators rely on).
|
|
98
|
+
// ====================================================================
|
|
99
|
+
// Let rate limit decay so this single call isn't blocked.
|
|
100
|
+
await wait(undefined, 5000)
|
|
101
|
+
await sdk.reset_db()
|
|
102
|
+
|
|
103
|
+
const sameTenantRes = await post(
|
|
104
|
+
'/v1/invite-user-to-organization',
|
|
105
|
+
{
|
|
106
|
+
email: sameTenantExistingEmail,
|
|
107
|
+
fname: 'F0007', lname: 'SameTenant',
|
|
108
|
+
organizationId,
|
|
109
|
+
},
|
|
110
|
+
{ Authorization: `Bearer ${sdk.authToken}` },
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await async_test(
|
|
114
|
+
"F-0007: invite_user same-tenant duplicate returns 400 'already exists' (unchanged)",
|
|
115
|
+
async () => ({
|
|
116
|
+
status: sameTenantRes.status,
|
|
117
|
+
message: sameTenantRes.data?.message ?? '',
|
|
118
|
+
}),
|
|
119
|
+
{ onResult: r => r.status === 400 && r.message.toLowerCase().includes('already exists') },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// ====================================================================
|
|
123
|
+
// Assertion 3: cross-tenant duplicate must NOT reveal existence
|
|
124
|
+
// Env-gated; skipped when cross-org infra not configured.
|
|
125
|
+
// ====================================================================
|
|
126
|
+
if (!(CROSS_ORG_API_KEY && CROSS_ORG_TARGET_BUSINESS_ID)) {
|
|
127
|
+
console.log(" [F-0007] Skipping cross-tenant silent no-op assertion — CROSS_ORG_* env vars not set")
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The target email is the admin's email in the home (test) tenant. We then attempt to invite
|
|
132
|
+
// that same email FROM a session belonging to the cross-org target tenant. Post-fix, the
|
|
133
|
+
// response should NOT contain "already exists" — it must be indistinguishable from a
|
|
134
|
+
// successful invite OR a generic non-revealing response. Pre-fix, the response is the
|
|
135
|
+
// distinctive "already exists" error revealing cross-tenant existence.
|
|
136
|
+
const sdkCrossOrg = new Session({
|
|
137
|
+
host,
|
|
138
|
+
apiKey: CROSS_ORG_API_KEY,
|
|
139
|
+
headers: { 'x-tellescope-organization': CROSS_ORG_TARGET_BUSINESS_ID },
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Resolve an organizationId in the target business. Falls back to the businessId itself.
|
|
143
|
+
let targetOrgId = CROSS_ORG_TARGET_BUSINESS_ID
|
|
144
|
+
try {
|
|
145
|
+
const orgs = await sdkCrossOrg.api.organizations.getSome({ limit: 1 })
|
|
146
|
+
if (orgs?.[0]?.id) targetOrgId = orgs[0].id
|
|
147
|
+
} catch {}
|
|
148
|
+
|
|
149
|
+
const crossRes = await post(
|
|
150
|
+
'/v1/invite-user-to-organization',
|
|
151
|
+
{
|
|
152
|
+
email: sameTenantExistingEmail,
|
|
153
|
+
fname: 'F0007', lname: 'CrossTenantProbe',
|
|
154
|
+
organizationId: targetOrgId,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
Authorization: `API_KEY ${CROSS_ORG_API_KEY}`,
|
|
158
|
+
'x-tellescope-organization': CROSS_ORG_TARGET_BUSINESS_ID,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
await async_test(
|
|
163
|
+
"F-0007: cross-tenant invite of existing email must NOT return 'already exists' (no enumeration)",
|
|
164
|
+
async () => ({
|
|
165
|
+
status: crossRes.status,
|
|
166
|
+
message: crossRes.data?.message ?? '',
|
|
167
|
+
hasCreatedShape: !!crossRes.data?.created?.id,
|
|
168
|
+
}),
|
|
169
|
+
{
|
|
170
|
+
onResult: r =>
|
|
171
|
+
// The response MUST NOT contain the "already exists" string regardless of status.
|
|
172
|
+
// Acceptable post-fix shapes: 200 with `{ created: { id: ... } }` (silent no-op), or
|
|
173
|
+
// 200 with a generic ack. Rate-limit 429 also OK if it slipped through to here.
|
|
174
|
+
!r.message.toLowerCase().includes('already exists'),
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (require.main === module) {
|
|
180
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
181
|
+
const sdk = new Session({ host })
|
|
182
|
+
const sdkNonAdmin = new Session({ host })
|
|
183
|
+
|
|
184
|
+
const runTests = async () => {
|
|
185
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
186
|
+
await invite_user_enumeration_tests({ sdk, sdkNonAdmin })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
runTests()
|
|
190
|
+
.then(() => {
|
|
191
|
+
console.log("✅ F-0007 invite_user enumeration test suite completed successfully")
|
|
192
|
+
process.exit(0)
|
|
193
|
+
})
|
|
194
|
+
.catch((error) => {
|
|
195
|
+
console.error("❌ F-0007 invite_user enumeration test suite failed:", error)
|
|
196
|
+
process.exit(1)
|
|
197
|
+
})
|
|
198
|
+
}
|
package/src/tests/api_tests/security/F-0008-handle-incoming-communication-cross-tenant.test.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
import { ObjectId } from 'bson'
|
|
5
|
+
import { Session } from "../../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
async_test,
|
|
8
|
+
log_header,
|
|
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 CROSS_ORG_API_KEY = process.env.CROSS_ORG_API_KEY
|
|
15
|
+
const CROSS_ORG_TARGET_BUSINESS_ID = process.env.CROSS_ORG_TARGET_BUSINESS_ID
|
|
16
|
+
|
|
17
|
+
const post = async (path: string, body: any, headers: Record<string, string> = {}) => {
|
|
18
|
+
try {
|
|
19
|
+
const res = await axios.post(`${host}${path}`, body, {
|
|
20
|
+
validateStatus: () => true,
|
|
21
|
+
headers,
|
|
22
|
+
})
|
|
23
|
+
return { status: res.status, data: res.data }
|
|
24
|
+
} catch (err: any) {
|
|
25
|
+
return { status: err?.response?.status, data: err?.response?.data }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Regression test for F-0008 (security-audit/findings/F-0008-handle-incoming-communication-cross-tenant-enduser-lookup.md).
|
|
31
|
+
*
|
|
32
|
+
* `journeys.handle_incoming_communication` previously used `buildAllQueries({ unrestricted: true, organizationIds: [] }).endusers.findById(enduserId)`,
|
|
33
|
+
* permitting cross-tenant lookup of any enduser by id. The handler then called `handleIncomingCommunication(...)`
|
|
34
|
+
* against the matched enduser, triggering journey progression and automated actions on someone else's tenant.
|
|
35
|
+
*
|
|
36
|
+
* The fix switches to the standard tenant-scoped `DB.endusers.findById(enduserId)` wrapper, which automatically
|
|
37
|
+
* filters by `req.session.businessId`. Cross-tenant lookups now return null → handler returns 404 → no side effect.
|
|
38
|
+
*
|
|
39
|
+
* Note: the same-tenant happy path is already covered by the existing test at
|
|
40
|
+
* `packages/public/sdk/src/tests/tests.ts:7588` ("handle_incoming_communication test for other enduser") — that
|
|
41
|
+
* test creates endusers in the test tenant, sets up journeys, calls handle_incoming_communication, and asserts
|
|
42
|
+
* journey-step cancellation. This file covers the negative cases only.
|
|
43
|
+
*
|
|
44
|
+
* **Negative-only by design**: the test never drives `handleIncomingCommunication` against a cross-tenant
|
|
45
|
+
* enduser — the post-fix code returns 404 before any side effects fire, and the assertion confirms that.
|
|
46
|
+
*/
|
|
47
|
+
export const handle_incoming_communication_cross_tenant_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
48
|
+
log_header("F-0008: handle_incoming_communication cross-tenant rejection")
|
|
49
|
+
|
|
50
|
+
// ====================================================================
|
|
51
|
+
// Assertion 1: nonexistent enduserId returns 404 (baseline; regression
|
|
52
|
+
// guard for the not-found path that any cross-tenant call now lands in).
|
|
53
|
+
// ====================================================================
|
|
54
|
+
const nonexistentId = new ObjectId().toHexString()
|
|
55
|
+
const nonexistentRes = await post(
|
|
56
|
+
'/v1/journeys/handle-incoming-communication',
|
|
57
|
+
{ enduserId: nonexistentId },
|
|
58
|
+
{ Authorization: `Bearer ${sdk.authToken}` },
|
|
59
|
+
)
|
|
60
|
+
await async_test(
|
|
61
|
+
"F-0008: handle_incoming_communication with nonexistent enduserId returns 404",
|
|
62
|
+
async () => ({ status: nonexistentRes.status }),
|
|
63
|
+
{ onResult: r => r.status === 404 },
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// ====================================================================
|
|
67
|
+
// Assertion 2: cross-tenant enduserId returns 404 (the actual F-0008 fix).
|
|
68
|
+
// Env-gated; skipped when cross-org infra isn't configured.
|
|
69
|
+
// Safe to run post-fix because the tenant-scoped DB returns null for
|
|
70
|
+
// cross-tenant lookups — no handleIncomingCommunication side effect fires.
|
|
71
|
+
// ====================================================================
|
|
72
|
+
if (!(CROSS_ORG_API_KEY && CROSS_ORG_TARGET_BUSINESS_ID)) {
|
|
73
|
+
console.log(" [F-0008] Skipping cross-tenant rejection assertion — CROSS_ORG_* env vars not set")
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sdkCrossOrg = new Session({
|
|
78
|
+
host,
|
|
79
|
+
apiKey: CROSS_ORG_API_KEY,
|
|
80
|
+
headers: { 'x-tellescope-organization': CROSS_ORG_TARGET_BUSINESS_ID },
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// Create a sentinel enduser in the cross-org tenant. Use a clearly-test email
|
|
84
|
+
// so any accidental side-effect routing is obvious in logs.
|
|
85
|
+
const ts = Date.now()
|
|
86
|
+
const crossEnduser = await sdkCrossOrg.api.endusers.createOne({
|
|
87
|
+
fname: 'F0008CrossTenant', lname: 'Sentinel',
|
|
88
|
+
email: `f0008-cross-${ts}@tellescope.com`,
|
|
89
|
+
} as any)
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const crossRes = await post(
|
|
93
|
+
'/v1/journeys/handle-incoming-communication',
|
|
94
|
+
{ enduserId: crossEnduser.id },
|
|
95
|
+
{ Authorization: `Bearer ${sdk.authToken}` },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await async_test(
|
|
99
|
+
"F-0008: handle_incoming_communication with cross-tenant enduserId returns 404 (no side effect)",
|
|
100
|
+
async () => ({
|
|
101
|
+
status: crossRes.status,
|
|
102
|
+
message: crossRes.data?.message ?? null,
|
|
103
|
+
}),
|
|
104
|
+
{ onResult: r => r.status === 404 },
|
|
105
|
+
)
|
|
106
|
+
} finally {
|
|
107
|
+
try { await sdkCrossOrg.api.endusers.deleteOne(crossEnduser.id) } catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (require.main === module) {
|
|
112
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
113
|
+
const sdk = new Session({ host })
|
|
114
|
+
const sdkNonAdmin = new Session({ host })
|
|
115
|
+
|
|
116
|
+
const runTests = async () => {
|
|
117
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
118
|
+
await handle_incoming_communication_cross_tenant_tests({ sdk, sdkNonAdmin })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
runTests()
|
|
122
|
+
.then(() => {
|
|
123
|
+
console.log("✅ F-0008 handle_incoming_communication cross-tenant test suite completed successfully")
|
|
124
|
+
process.exit(0)
|
|
125
|
+
})
|
|
126
|
+
.catch((error) => {
|
|
127
|
+
console.error("❌ F-0008 handle_incoming_communication cross-tenant test suite failed:", error)
|
|
128
|
+
process.exit(1)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { sanitize_user_html } from "@tellescope/utilities"
|
|
2
|
+
|
|
3
|
+
// Regression test for F-0013 / F-0014 (pattern 06 — XSS via dangerouslySetInnerHTML).
|
|
4
|
+
// sanitize_user_html is the canonical render-time sanitizer that replaced remove_script_tags
|
|
5
|
+
// at every dangerouslySetInnerHTML sink. This asserts it neutralizes XSS vectors (incl. encoded /
|
|
6
|
+
// whitespace / mixed-case / iframe-srcdoc bypass variants) while preserving legitimate
|
|
7
|
+
// customization HTML (tables, headings, lists, links, images, inline styles).
|
|
8
|
+
//
|
|
9
|
+
// Pure-function test — no Session needed. Runs as part of the main suite and standalone:
|
|
10
|
+
// ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html.test.js
|
|
11
|
+
|
|
12
|
+
const fail = (msg: string) => { throw new Error(msg) }
|
|
13
|
+
|
|
14
|
+
const has_no_executable_vector = (out: string) => {
|
|
15
|
+
const o = out.toLowerCase()
|
|
16
|
+
// A handler smuggled into an attribute VALUE (e.g. title="<img onerror=...>") is inert
|
|
17
|
+
// text — strip quoted values before checking for *live* on*= attributes to avoid false positives.
|
|
18
|
+
const withoutValues = o.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''")
|
|
19
|
+
return !/\son[a-z]+\s*=/.test(withoutValues) // no live on*= event-handler attribute
|
|
20
|
+
&& !o.includes('javascript:') // dropped schemes never appear in safe output
|
|
21
|
+
&& !o.includes('vbscript:')
|
|
22
|
+
&& !o.includes('<script') // literal dangerous tags (encoded <script is fine)
|
|
23
|
+
&& !o.includes('<iframe')
|
|
24
|
+
&& !o.includes('<svg')
|
|
25
|
+
&& !o.includes('<math')
|
|
26
|
+
&& !o.includes('<object')
|
|
27
|
+
&& !o.includes('<embed')
|
|
28
|
+
&& !o.includes('<form')
|
|
29
|
+
&& !o.includes('<noscript')
|
|
30
|
+
&& !o.includes('<template')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const sanitize_user_html_xss_tests = async () => {
|
|
34
|
+
console.log("Running F-0013/F-0014 sanitize_user_html XSS regression tests")
|
|
35
|
+
|
|
36
|
+
const xssPayloads: [string, string][] = [
|
|
37
|
+
['img onerror', `<img src=x onerror="alert(document.domain)">`],
|
|
38
|
+
['svg onload', `<svg onload="alert(1)"></svg>`],
|
|
39
|
+
['svg animate onbegin', `<svg><animate onbegin="alert(1)" attributeName="x" dur="1s"></svg>`],
|
|
40
|
+
['details ontoggle', `<details open ontoggle="alert(1)"></details>`],
|
|
41
|
+
['input onfocus autofocus', `<input autofocus onfocus="alert(1)">`],
|
|
42
|
+
['body onpageshow', `<body onpageshow="alert(1)">`],
|
|
43
|
+
['a javascript scheme', `<a href="javascript:alert(1)">x</a>`],
|
|
44
|
+
['a javascript entity-encoded', `<a href="jav	ascript:alert(1)">x</a>`],
|
|
45
|
+
['iframe javascript src', `<iframe src="javascript:alert(1)"></iframe>`],
|
|
46
|
+
['iframe srcdoc nested', `<iframe srcdoc="<img src=x onerror=alert(1)>"></iframe>`],
|
|
47
|
+
['script tag', `<script>alert(1)</script>`],
|
|
48
|
+
['onerror newline before =', `<img src=x onerror\n="alert(1)">`],
|
|
49
|
+
['onerror mixed case', `<IMG SRC=x OnErRoR="alert(1)">`],
|
|
50
|
+
['marquee onstart', `<marquee onstart="alert(1)">x</marquee>`],
|
|
51
|
+
// mutation / namespace confusion — svg/math/noscript/template must be stripped
|
|
52
|
+
['mathml mglyph style mxss', `<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>`],
|
|
53
|
+
['svg foreignObject', `<svg><foreignObject><img src=x onerror=alert(1)></foreignObject></svg>`],
|
|
54
|
+
['noscript context confusion', `<noscript><p title="</noscript><img src=x onerror=alert(1)>">`],
|
|
55
|
+
['template content', `<template><img src=x onerror=alert(1)></template>`],
|
|
56
|
+
// comment / CDATA confusion
|
|
57
|
+
['comment confusion', `<!--><img src=x onerror=alert(1)>-->`],
|
|
58
|
+
['cdata confusion', `<![CDATA[<img src=x onerror=alert(1)>]]>`],
|
|
59
|
+
// markup smuggled inside an attribute value must stay inert
|
|
60
|
+
['markup inside attr value', `<img src="x" alt="<script>alert(1)</script>">`],
|
|
61
|
+
// protocol obfuscation
|
|
62
|
+
['vbscript scheme', `<a href="vbscript:msgbox(1)">x</a>`],
|
|
63
|
+
['data text/html href', `<a href="data:text/html,<script>alert(1)</script>">x</a>`],
|
|
64
|
+
['javascript decimal entity', `<a href="Javascript:alert(1)">x</a>`],
|
|
65
|
+
['javascript newline entity', `<a href="jav
ascript:alert(1)">x</a>`],
|
|
66
|
+
]
|
|
67
|
+
for (const [name, payload] of xssPayloads) {
|
|
68
|
+
const out = sanitize_user_html(payload)
|
|
69
|
+
if (!has_no_executable_vector(out)) fail(`XSS not neutralized [${name}] -> ${out}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// DOM clobbering: caller-controlled id/name must be stripped
|
|
73
|
+
const clobber = sanitize_user_html(`<a id="x" name="getElementById">link</a><img name="y">`)
|
|
74
|
+
if (/\b(id|name)\s*=/.test(clobber)) fail(`id/name not stripped (DOM clobbering): ${clobber}`)
|
|
75
|
+
|
|
76
|
+
// legitimate customization HTML must survive
|
|
77
|
+
const heading = sanitize_user_html(`<h1>Welcome</h1><h3 style="color:#333">Sub</h3>`)
|
|
78
|
+
if (!(heading.includes('<h1>') && heading.includes('<h3') && heading.toLowerCase().includes('color'))) fail(`headings/style stripped: ${heading}`)
|
|
79
|
+
|
|
80
|
+
const table = sanitize_user_html(`<table><thead><tr><th>H</th></tr></thead><tbody><tr><td style="padding:4px" colspan="2">cell</td></tr></tbody></table>`)
|
|
81
|
+
if (!(table.includes('<table') && table.includes('<td') && table.includes('colspan'))) fail(`table stripped: ${table}`)
|
|
82
|
+
|
|
83
|
+
const list = sanitize_user_html(`<ul><li>a</li></ul><ol start="3"><li>c</li></ol>`)
|
|
84
|
+
if (!(list.includes('<ul') && list.includes('<li') && list.includes('<ol'))) fail(`list stripped: ${list}`)
|
|
85
|
+
|
|
86
|
+
const link = sanitize_user_html(`<a href="https://example.com">link</a>`)
|
|
87
|
+
if (!link.includes('href="https://example.com"')) fail(`safe link stripped: ${link}`)
|
|
88
|
+
if (!link.toLowerCase().includes('noopener')) fail(`external link not hardened: ${link}`)
|
|
89
|
+
|
|
90
|
+
const img = sanitize_user_html(`<img src="https://cdn.example.com/a.png" alt="pic" width="200">`)
|
|
91
|
+
if (!(img.includes('src="https://cdn.example.com/a.png"') && img.includes('alt="pic"'))) fail(`http image stripped: ${img}`)
|
|
92
|
+
|
|
93
|
+
const dataimg = sanitize_user_html(`<img src="data:image/png;base64,iVBORw0KGgo=">`)
|
|
94
|
+
if (!dataimg.includes('data:image/png')) fail(`data: image stripped: ${dataimg}`)
|
|
95
|
+
|
|
96
|
+
const fmt = sanitize_user_html(`<p><strong>b</strong> <em>i</em> <span style="font-size:14px">s</span></p><blockquote>q</blockquote>`)
|
|
97
|
+
if (!(fmt.includes('<strong>') && fmt.includes('<span') && fmt.toLowerCase().includes('font-size'))) fail(`formatting stripped: ${fmt}`)
|
|
98
|
+
|
|
99
|
+
const mixed = sanitize_user_html(`<p>Hello <b>name</b></p><img src=x onerror="steal()">`)
|
|
100
|
+
if (!(mixed.includes('<b>name</b>') && !/\son[a-z]+\s*=/.test(mixed.toLowerCase()))) fail(`mixed content not handled: ${mixed}`)
|
|
101
|
+
|
|
102
|
+
console.log("✅ F-0013/F-0014 sanitize_user_html XSS regression tests passed")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (require.main === module) {
|
|
106
|
+
sanitize_user_html_xss_tests()
|
|
107
|
+
.then(() => { console.log("✅ suite completed"); process.exit(0) })
|
|
108
|
+
.catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { add_value_for_dotted_key } from "@tellescope/utilities"
|
|
2
|
+
|
|
3
|
+
// Regression test for F-0016 (pattern 17 — prototype pollution).
|
|
4
|
+
// add_value_for_dotted_key must NOT write through __proto__/constructor/prototype path segments
|
|
5
|
+
// (which would pollute Object.prototype process-wide), while still performing legitimate dotted assignment.
|
|
6
|
+
//
|
|
7
|
+
// Pure-function test — no Session needed. Runs in the main suite and standalone:
|
|
8
|
+
// ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0016-prototype-pollution.test.js
|
|
9
|
+
|
|
10
|
+
const fail = (msg: string) => { throw new Error(msg) }
|
|
11
|
+
|
|
12
|
+
export const prototype_pollution_tests = async () => {
|
|
13
|
+
console.log("Running F-0016 prototype-pollution regression tests")
|
|
14
|
+
|
|
15
|
+
// 1. __proto__ path must not pollute Object.prototype
|
|
16
|
+
add_value_for_dotted_key({ insurance: {} } as any, 'insurance.__proto__.__pp_a__', 'polluted')
|
|
17
|
+
const leakedA = ({} as any).__pp_a__
|
|
18
|
+
delete (Object.prototype as any).__pp_a__ // clean up regardless, so a failure here can't contaminate the rest of the suite
|
|
19
|
+
if (leakedA !== undefined) fail('Object.prototype polluted via __proto__ path')
|
|
20
|
+
|
|
21
|
+
// 2. constructor.prototype path must not pollute
|
|
22
|
+
add_value_for_dotted_key({ insurance: {} } as any, 'insurance.constructor.prototype.__pp_b__', 'polluted')
|
|
23
|
+
const leakedB = ({} as any).__pp_b__
|
|
24
|
+
delete (Object.prototype as any).__pp_b__
|
|
25
|
+
if (leakedB !== undefined) fail('Object.prototype polluted via constructor.prototype path')
|
|
26
|
+
|
|
27
|
+
// 3. a leading __proto__ segment must not pollute either
|
|
28
|
+
add_value_for_dotted_key({} as any, '__proto__.__pp_c__', 'polluted')
|
|
29
|
+
const leakedC = ({} as any).__pp_c__
|
|
30
|
+
delete (Object.prototype as any).__pp_c__
|
|
31
|
+
if (leakedC !== undefined) fail('Object.prototype polluted via leading __proto__ segment')
|
|
32
|
+
|
|
33
|
+
// 4. legitimate dotted assignment still works (existing intermediate objects)
|
|
34
|
+
const obj = { a: { b: {} } } as any
|
|
35
|
+
add_value_for_dotted_key(obj, 'a.b.c', 42)
|
|
36
|
+
if (obj.a.b.c !== 42) fail('legitimate dotted assignment broke')
|
|
37
|
+
|
|
38
|
+
// 5. single-key assignment still works
|
|
39
|
+
const flat = {} as any
|
|
40
|
+
add_value_for_dotted_key(flat, 'name', 'ok')
|
|
41
|
+
if (flat.name !== 'ok') fail('single-key assignment broke')
|
|
42
|
+
|
|
43
|
+
console.log("✅ F-0016 prototype-pollution regression tests passed")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (require.main === module) {
|
|
47
|
+
prototype_pollution_tests()
|
|
48
|
+
.then(() => { console.log("✅ suite completed"); process.exit(0) })
|
|
49
|
+
.catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
|
|
50
|
+
}
|