@tellescope/sdk 1.251.0 → 1.252.1
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/calendar_canvas_coding_clear.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.js +139 -0
- package/lib/cjs/tests/api_tests/calendar_canvas_coding_clear.test.js.map +1 -0
- 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_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/integrations_redacted.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/integrations_redacted.test.js +30 -20
- package/lib/cjs/tests/api_tests/integrations_redacted.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js +234 -198
- package/lib/cjs/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -1
- 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/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts +32 -0
- package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js +237 -0
- package/lib/cjs/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts +38 -0
- package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js +222 -0
- package/lib/cjs/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/user_portal_settings.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js +301 -0
- package/lib/cjs/tests/api_tests/user_portal_settings.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +198 -151
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.js +135 -0
- package/lib/esm/tests/api_tests/calendar_canvas_coding_clear.test.js.map +1 -0
- 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_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/integrations_redacted.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/integrations_redacted.test.js +30 -20
- package/lib/esm/tests/api_tests/integrations_redacted.test.js.map +1 -1
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js +235 -199
- package/lib/esm/tests/api_tests/push_forms_to_portal_group_completion.test.js.map +1 -1
- 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/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts +32 -0
- package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js +233 -0
- package/lib/esm/tests/api_tests/security/F-0053-cascade-role-rename-cross-tenant.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts +38 -0
- package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js +218 -0
- package/lib/esm/tests/api_tests/security/F-0076-self-admin-role-assignment.test.js.map +1 -0
- package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/user_portal_settings.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/user_portal_settings.test.js +297 -0
- package/lib/esm/tests/api_tests/user_portal_settings.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +198 -151
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/calendar_event_webhook_template.test.ts +204 -0
- package/src/tests/api_tests/enduser_login_rate_limits.test.ts +178 -0
- package/src/tests/api_tests/integrations_redacted.test.ts +8 -0
- package/src/tests/api_tests/push_forms_to_portal_group_completion.test.ts +113 -88
- 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/security/F-0053-cascade-role-rename-cross-tenant.test.ts +161 -0
- package/src/tests/api_tests/security/F-0076-self-admin-role-assignment.test.ts +165 -0
- package/src/tests/api_tests/user_portal_settings.test.ts +217 -0
- package/src/tests/tests.ts +25 -2
- package/test_generated.pdf +0 -0
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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
assert,
|
|
6
|
+
log_header,
|
|
7
|
+
wait,
|
|
8
|
+
} from "@tellescope/testing"
|
|
9
|
+
import { setup_tests } from "../../setup"
|
|
10
|
+
import { PROVIDER_PERMISSIONS } from "@tellescope/constants"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
// Separate tenant (different businessId), reusing the same hardcoded apiKey that
|
|
15
|
+
// multi_tenant_tests relies on in tests.ts.
|
|
16
|
+
const OTHER_TENANT_API_KEY = "ba745e25162bb95a795c5fa1af70df188d93c4d3aac9c48b34a5c8c9dd7b80f7"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tenant-boundary guard for cascade_role_rename (relates to security-audit finding F-0053,
|
|
20
|
+
* which was investigated and closed as a FALSE POSITIVE — see that file for the code trace).
|
|
21
|
+
*
|
|
22
|
+
* The `cascade_role_rename` side-effect handler
|
|
23
|
+
* ([event_handlers_v2/role_based_access_permissions.ts](packages/private/api/api/v1/event_handlers_v2/role_based_access_permissions.ts))
|
|
24
|
+
* runs when a `role_based_access_permissions` doc's `role` field changes. It finds every user
|
|
25
|
+
* with the old role name (via `DBUnrestricted.users`) and rewrites their `roles` array, then
|
|
26
|
+
* deauthenticates them. F-0053 hypothesized this query was globally cross-tenant. It is NOT:
|
|
27
|
+
* `DBUnrestricted` bypasses per-user/per-role RBAC but is STILL scoped to the acting session's
|
|
28
|
+
* `businessId` (see `modifyFilterForAccessConstraint` injecting `{ businessId }` at
|
|
29
|
+
* database.ts:1761-1763, reached via the `unrestricted: true` branch at database.ts:2137-2144).
|
|
30
|
+
*
|
|
31
|
+
* This test locks that boundary in place so a future refactor of `DBUnrestricted` semantics
|
|
32
|
+
* can't silently turn the cascade into a cross-tenant write:
|
|
33
|
+
* 1. Tenant A creates a role `ROLE_OLD` and assigns it to a Tenant A user (positive control).
|
|
34
|
+
* 2. Tenant B (separate businessId) has a user whose roles include the SAME `ROLE_OLD`.
|
|
35
|
+
* 3. Tenant A renames the role `ROLE_OLD` -> `ROLE_NEW`.
|
|
36
|
+
* 4. Assert the Tenant B user's roles are UNCHANGED (still `[ROLE_OLD]`) <-- guards the tenant boundary.
|
|
37
|
+
* 5. Assert the Tenant A user's roles ARE renamed to `[ROLE_NEW]` <-- same-tenant cascade works.
|
|
38
|
+
*
|
|
39
|
+
* Expected on current (correct) code: BOTH assertions pass. A regression that drops the
|
|
40
|
+
* `businessId` scoping would flip assertion #4 to red (Tenant B user becomes `[ROLE_NEW]`).
|
|
41
|
+
*
|
|
42
|
+
* A collision-proof unique role name (timestamped) is used so the test never touches real roles.
|
|
43
|
+
*/
|
|
44
|
+
export const cascade_role_rename_cross_tenant_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
45
|
+
log_header("F-0053: cascade role rename cross-tenant regression")
|
|
46
|
+
|
|
47
|
+
const stamp = Date.now()
|
|
48
|
+
const ROLE_OLD = `XTenantRename_${stamp}`
|
|
49
|
+
const ROLE_NEW = `${ROLE_OLD}_renamed`
|
|
50
|
+
|
|
51
|
+
// Tenant B = a genuinely separate businessId (the apiKey's org). admin is required to create /
|
|
52
|
+
// set roles on users, so the apiKey user must be an Admin in its org.
|
|
53
|
+
const sdkOther = new Session({ host, apiKey: OTHER_TENANT_API_KEY })
|
|
54
|
+
|
|
55
|
+
let rbapId: string | undefined
|
|
56
|
+
// We create dedicated throwaway users in BOTH tenants and delete them in `finally`. We never
|
|
57
|
+
// mutate any pre-existing user (an earlier version of this test clobbered the apiKey's own
|
|
58
|
+
// admin user via getSome+replaceObjectFields — see F-0053 finding notes). controlUser lives in
|
|
59
|
+
// Tenant A and SHOULD be renamed by the same-tenant cascade; victimUser lives in Tenant B and
|
|
60
|
+
// must be UNTOUCHED.
|
|
61
|
+
let controlUserId: string | undefined
|
|
62
|
+
let victimUserId: string | undefined
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const tenantABusinessId = sdk.userInfo.businessId
|
|
66
|
+
|
|
67
|
+
// 1. Tenant A: create the role to be renamed.
|
|
68
|
+
const rbap = await sdk.api.role_based_access_permissions.createOne({
|
|
69
|
+
role: ROLE_OLD,
|
|
70
|
+
permissions: { ...PROVIDER_PERMISSIONS },
|
|
71
|
+
})
|
|
72
|
+
rbapId = rbap.id
|
|
73
|
+
|
|
74
|
+
// 2. Tenant A: create a throwaway control user holding ROLE_OLD (notification emails off).
|
|
75
|
+
const controlUser = await sdk.api.users.createOne({
|
|
76
|
+
email: `f0053-control-${stamp}@example.com`,
|
|
77
|
+
notificationEmailsDisabled: true,
|
|
78
|
+
} as any)
|
|
79
|
+
controlUserId = controlUser.id
|
|
80
|
+
await sdk.api.users.updateOne(controlUserId, { roles: [ROLE_OLD] }, { replaceObjectFields: true })
|
|
81
|
+
|
|
82
|
+
// 3. Tenant B: create a throwaway victim user holding the SAME ROLE_OLD.
|
|
83
|
+
const victimUser = await sdkOther.api.users.createOne({
|
|
84
|
+
email: `f0053-victim-${stamp}@example.com`,
|
|
85
|
+
notificationEmailsDisabled: true,
|
|
86
|
+
} as any)
|
|
87
|
+
victimUserId = victimUser.id
|
|
88
|
+
await sdkOther.api.users.updateOne(victimUserId, { roles: [ROLE_OLD] }, { replaceObjectFields: true })
|
|
89
|
+
|
|
90
|
+
// 4. Setup sanity: tenants are distinct and both users actually hold ROLE_OLD before the rename.
|
|
91
|
+
// (Distinguishes a setup failure from the security assertion below.)
|
|
92
|
+
assert(
|
|
93
|
+
victimUser.businessId !== tenantABusinessId,
|
|
94
|
+
`Victim user shares businessId with Tenant A (${victimUser.businessId}) — not a cross-tenant scenario`,
|
|
95
|
+
'F-0053 setup: tenants are distinct',
|
|
96
|
+
)
|
|
97
|
+
const victimBefore = await sdkOther.api.users.getOne(victimUserId)
|
|
98
|
+
const controlBefore = await sdk.api.users.getOne(controlUserId)
|
|
99
|
+
assert(
|
|
100
|
+
JSON.stringify(victimBefore.roles) === JSON.stringify([ROLE_OLD])
|
|
101
|
+
&& JSON.stringify(controlBefore.roles) === JSON.stringify([ROLE_OLD]),
|
|
102
|
+
`Setup failed: expected both users to hold [${ROLE_OLD}] (victim=${JSON.stringify(victimBefore.roles)}, control=${JSON.stringify(controlBefore.roles)})`,
|
|
103
|
+
'F-0053 setup: both users hold ROLE_OLD',
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// 5. Tenant A renames the role. This triggers cascade_role_rename.
|
|
107
|
+
await sdk.api.role_based_access_permissions.updateOne(rbapId, { role: ROLE_NEW })
|
|
108
|
+
await wait(undefined, 1500) // let the side effect run
|
|
109
|
+
|
|
110
|
+
// 6. SECURITY ASSERTION — the Tenant B victim must be untouched by Tenant A's rename.
|
|
111
|
+
const victimAfter = await sdkOther.api.users.getOne(victimUserId)
|
|
112
|
+
assert(
|
|
113
|
+
JSON.stringify(victimAfter.roles) === JSON.stringify([ROLE_OLD]),
|
|
114
|
+
`CROSS-TENANT LEAK: Tenant B victim roles changed to ${JSON.stringify(victimAfter.roles)} `
|
|
115
|
+
+ `after Tenant A renamed its role. Expected [${ROLE_OLD}].`,
|
|
116
|
+
'F-0053: Tenant B user roles unaffected by other-tenant role rename',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// 7. POSITIVE CONTROL — the Tenant A control user SHOULD be renamed (same-tenant cascade intact).
|
|
120
|
+
const controlAfter = await sdk.api.users.getOne(controlUserId)
|
|
121
|
+
assert(
|
|
122
|
+
JSON.stringify(controlAfter.roles) === JSON.stringify([ROLE_NEW]),
|
|
123
|
+
`Same-tenant cascade broken: Tenant A control roles are ${JSON.stringify(controlAfter.roles)}, `
|
|
124
|
+
+ `expected [${ROLE_NEW}].`,
|
|
125
|
+
'F-0053: Tenant A user roles renamed by same-tenant cascade',
|
|
126
|
+
)
|
|
127
|
+
} finally {
|
|
128
|
+
// Cleanup: delete both throwaway users and the role doc. Never touches pre-existing users.
|
|
129
|
+
if (victimUserId) {
|
|
130
|
+
try { await sdkOther.api.users.deleteOne(victimUserId) } catch {}
|
|
131
|
+
}
|
|
132
|
+
if (controlUserId) {
|
|
133
|
+
try { await sdk.api.users.deleteOne(controlUserId) } catch {}
|
|
134
|
+
}
|
|
135
|
+
if (rbapId) {
|
|
136
|
+
try { await sdk.api.role_based_access_permissions.deleteOne(rbapId) } catch {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Allow running this test file independently
|
|
142
|
+
if (require.main === module) {
|
|
143
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
144
|
+
const sdk = new Session({ host })
|
|
145
|
+
const sdkNonAdmin = new Session({ host })
|
|
146
|
+
|
|
147
|
+
const runTests = async () => {
|
|
148
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
149
|
+
await cascade_role_rename_cross_tenant_tests({ sdk, sdkNonAdmin })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
runTests()
|
|
153
|
+
.then(() => {
|
|
154
|
+
console.log("✅ F-0053 cascade role rename cross-tenant test suite completed successfully")
|
|
155
|
+
process.exit(0)
|
|
156
|
+
})
|
|
157
|
+
.catch((error) => {
|
|
158
|
+
console.error("❌ F-0053 cascade role rename cross-tenant test suite failed:", error)
|
|
159
|
+
process.exit(1)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { Session } from "../../../sdk"
|
|
4
|
+
import {
|
|
5
|
+
assert,
|
|
6
|
+
log_header,
|
|
7
|
+
wait,
|
|
8
|
+
} from "@tellescope/testing"
|
|
9
|
+
import { setup_tests } from "../../setup"
|
|
10
|
+
|
|
11
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Self-role privilege-escalation guard (relates to security-audit finding F-0076, which was
|
|
15
|
+
* investigated and closed as a FALSE POSITIVE — see that file for the full code trace).
|
|
16
|
+
*
|
|
17
|
+
* F-0076 hypothesized that a non-admin staff user could `PATCH /v1/users/{their-own-id}` with
|
|
18
|
+
* `{ roles: ['Admin'] }` and self-promote to Admin, because the FIRST `users` relationship
|
|
19
|
+
* constraint ("Only admin users can set the admin role",
|
|
20
|
+
* [schema.ts:3446](packages/public/schema/src/schema.ts#L3446)) has a self-exception
|
|
21
|
+
* (`if (_id === session.id) return`).
|
|
22
|
+
*
|
|
23
|
+
* That analysis missed the SECOND constraint, "Only admin users can update user roles"
|
|
24
|
+
* ([schema.ts:3486](packages/public/schema/src/schema.ts#L3486)), which has NO self-exception.
|
|
25
|
+
* Relationship constraints are AND-evaluated — `validateRelationshipConstraints`
|
|
26
|
+
* ([routing.ts:1240-1252](packages/private/api/api/modules/routing.ts#L1240)) loops the whole
|
|
27
|
+
* array and throws 400 on the FIRST evaluator that returns a string. So a non-admin self-update
|
|
28
|
+
* that includes `roles` passes constraint #1 (self-exception) but is rejected by constraint #2.
|
|
29
|
+
* The self-promotion is blocked.
|
|
30
|
+
*
|
|
31
|
+
* This test locks that boundary in place so a future refactor of the role constraints can't
|
|
32
|
+
* silently reintroduce the escalation. A dedicated throwaway non-admin user is used as the
|
|
33
|
+
* "attacker" (we never mutate the shared sdkNonAdmin's roles):
|
|
34
|
+
* 1. Admin creates a throwaway user and assigns it a non-admin role (`['Provider']`).
|
|
35
|
+
* 2. Authenticate AS that user via a freshly-minted auth token.
|
|
36
|
+
* 3. As the attacker, attempt four self-role mutations — ['Admin'], ['Provider','Admin'],
|
|
37
|
+
* an arbitrary role, and [] — and assert EACH is blocked. <-- the security assertions
|
|
38
|
+
* 4. Confirm (as admin) the attacker's roles are still ['Provider'] — nothing slipped through.
|
|
39
|
+
* 5. Positive control: admin CAN update the throwaway user's roles. <-- guards against an
|
|
40
|
+
* over-restrictive regression that would block legitimate admin role management.
|
|
41
|
+
*
|
|
42
|
+
* Expected on current (correct) code: all assertions pass. A regression that made the self-update
|
|
43
|
+
* path role-writable by non-admins would flip the step-3/step-4 assertions to red.
|
|
44
|
+
*/
|
|
45
|
+
export const self_admin_role_assignment_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
46
|
+
log_header("F-0076: self-admin role assignment privilege-escalation regression")
|
|
47
|
+
|
|
48
|
+
const stamp = Date.now()
|
|
49
|
+
const NON_ADMIN_ROLE = 'Provider'
|
|
50
|
+
|
|
51
|
+
// Assert that a self-role-update attempt is rejected by the relationship constraints.
|
|
52
|
+
const expect_blocked = async (fn: () => Promise<any>, description: string) => {
|
|
53
|
+
try {
|
|
54
|
+
await fn()
|
|
55
|
+
assert(false, `${description} - SELF-ROLE ESCALATION SUCCEEDED (expected it to be blocked)`)
|
|
56
|
+
} catch (e: any) {
|
|
57
|
+
// CRUD relationship-constraint failures surface as 400 { message, info } via SDK parseError.
|
|
58
|
+
assert(
|
|
59
|
+
e?.code === 400 || e?.statusCode === 400 || typeof e?.message === 'string',
|
|
60
|
+
`${description} - expected a block error, got: ${JSON.stringify(e)}`,
|
|
61
|
+
description,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let attackerId: string | undefined
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// 1. Admin creates a throwaway non-admin user (notification emails off, timestamped email).
|
|
70
|
+
// verifiedEmail is set at creation (admin-only, updatesDisabled after) so the attacker can
|
|
71
|
+
// drive an authenticated session — otherwise actions are blocked on email-verification, not
|
|
72
|
+
// on the role constraint we're actually testing.
|
|
73
|
+
const attacker = await sdk.api.users.createOne({
|
|
74
|
+
email: `f0076-attacker-${stamp}@example.com`,
|
|
75
|
+
notificationEmailsDisabled: true,
|
|
76
|
+
verifiedEmail: true,
|
|
77
|
+
} as any)
|
|
78
|
+
attackerId = attacker.id
|
|
79
|
+
await sdk.api.users.updateOne(attackerId, { roles: [NON_ADMIN_ROLE] }, { replaceObjectFields: true })
|
|
80
|
+
await wait(undefined, 2000) // role change triggers a logout; let it propagate before minting a token
|
|
81
|
+
|
|
82
|
+
// Setup sanity: the attacker holds exactly the non-admin role and is NOT an admin.
|
|
83
|
+
const attackerBefore = await sdk.api.users.getOne(attackerId)
|
|
84
|
+
assert(
|
|
85
|
+
JSON.stringify(attackerBefore.roles) === JSON.stringify([NON_ADMIN_ROLE]),
|
|
86
|
+
`Setup failed: expected attacker to hold [${NON_ADMIN_ROLE}], got ${JSON.stringify(attackerBefore.roles)}`,
|
|
87
|
+
'F-0076 setup: attacker holds a non-admin role',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// 2. Authenticate AS the attacker (no password needed — admin mints an auth token).
|
|
91
|
+
const sdkAttacker = new Session({
|
|
92
|
+
host,
|
|
93
|
+
authToken: (await sdk.api.users.generate_auth_token({ id: attackerId })).authToken,
|
|
94
|
+
})
|
|
95
|
+
await sdkAttacker.refresh_session() // populate userInfo from the freshly-minted token
|
|
96
|
+
assert(
|
|
97
|
+
sdkAttacker.userInfo.id === attackerId && !(sdkAttacker.userInfo.roles ?? []).includes('Admin'),
|
|
98
|
+
`Setup failed: attacker session is not the expected non-admin user`,
|
|
99
|
+
'F-0076 setup: authenticated as the non-admin attacker',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// 3. SECURITY ASSERTIONS — every self-role mutation by the non-admin must be blocked.
|
|
103
|
+
await expect_blocked(
|
|
104
|
+
() => sdkAttacker.api.users.updateOne(attackerId!, { roles: ['Admin'] }, { replaceObjectFields: true }),
|
|
105
|
+
'F-0076: non-admin self-update to [Admin] is blocked',
|
|
106
|
+
)
|
|
107
|
+
await expect_blocked(
|
|
108
|
+
() => sdkAttacker.api.users.updateOne(attackerId!, { roles: [NON_ADMIN_ROLE, 'Admin'] }, { replaceObjectFields: true }),
|
|
109
|
+
'F-0076: non-admin self-update to [Provider, Admin] is blocked',
|
|
110
|
+
)
|
|
111
|
+
await expect_blocked(
|
|
112
|
+
() => sdkAttacker.api.users.updateOne(attackerId!, { roles: [`Arbitrary_${stamp}`] }, { replaceObjectFields: true }),
|
|
113
|
+
'F-0076: non-admin self-update to an arbitrary role is blocked',
|
|
114
|
+
)
|
|
115
|
+
await expect_blocked(
|
|
116
|
+
() => sdkAttacker.api.users.updateOne(attackerId!, { roles: [] }, { replaceObjectFields: true }),
|
|
117
|
+
'F-0076: non-admin self-update to [] (would grant defaults) is blocked',
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// 4. STATE ASSERTION — nothing slipped through; roles are still the original non-admin role.
|
|
121
|
+
const attackerAfter = await sdk.api.users.getOne(attackerId)
|
|
122
|
+
assert(
|
|
123
|
+
JSON.stringify(attackerAfter.roles) === JSON.stringify([NON_ADMIN_ROLE]),
|
|
124
|
+
`ESCALATION LEAK: attacker roles changed to ${JSON.stringify(attackerAfter.roles)} `
|
|
125
|
+
+ `after self-update attempts. Expected [${NON_ADMIN_ROLE}].`,
|
|
126
|
+
'F-0076: attacker roles unchanged after all self-escalation attempts',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// 5. POSITIVE CONTROL — an Admin CAN update the user's roles (mechanism is not over-restricted).
|
|
130
|
+
await sdk.api.users.updateOne(attackerId, { roles: [NON_ADMIN_ROLE] }, { replaceObjectFields: true })
|
|
131
|
+
const afterAdminUpdate = await sdk.api.users.getOne(attackerId)
|
|
132
|
+
assert(
|
|
133
|
+
JSON.stringify(afterAdminUpdate.roles) === JSON.stringify([NON_ADMIN_ROLE]),
|
|
134
|
+
`Admin role update failed: roles are ${JSON.stringify(afterAdminUpdate.roles)}, expected [${NON_ADMIN_ROLE}]`,
|
|
135
|
+
'F-0076: admin can manage user roles (positive control)',
|
|
136
|
+
)
|
|
137
|
+
} finally {
|
|
138
|
+
// Cleanup: delete the throwaway attacker. Never touches pre-existing users.
|
|
139
|
+
if (attackerId) {
|
|
140
|
+
try { await sdk.api.users.deleteOne(attackerId) } catch {}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Allow running this test file independently
|
|
146
|
+
if (require.main === module) {
|
|
147
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
148
|
+
const sdk = new Session({ host })
|
|
149
|
+
const sdkNonAdmin = new Session({ host })
|
|
150
|
+
|
|
151
|
+
const runTests = async () => {
|
|
152
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
153
|
+
await self_admin_role_assignment_tests({ sdk, sdkNonAdmin })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
runTests()
|
|
157
|
+
.then(() => {
|
|
158
|
+
console.log("✅ F-0076 self-admin role assignment test suite completed successfully")
|
|
159
|
+
process.exit(0)
|
|
160
|
+
})
|
|
161
|
+
.catch((error) => {
|
|
162
|
+
console.error("❌ F-0076 self-admin role assignment test suite failed:", error)
|
|
163
|
+
process.exit(1)
|
|
164
|
+
})
|
|
165
|
+
}
|