@tellescope/sdk 1.255.18 → 1.256.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 +7 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +489 -255
- 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 +9 -5
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +489 -255
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
- package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
- package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
- package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
- package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
- package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
- package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
- package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
- package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
- package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
- package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
- package/src/tests/tests.ts +232 -2
- package/test_generated.pdf +0 -0
- package/.env +0 -13
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
|
|
5
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
6
|
+
import {
|
|
7
|
+
assert,
|
|
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
|
+
/**
|
|
15
|
+
* Regression test for F-0166 — allowedPaths wildcard disables path confinement entirely.
|
|
16
|
+
*
|
|
17
|
+
* Several public, unauthenticated endpoints mint a JWT confined to a small set of API paths via an
|
|
18
|
+
* `allowedPaths` claim. The enforcement predicate (api/modules/authentication.ts checkAccess)
|
|
19
|
+
* contained:
|
|
20
|
+
*
|
|
21
|
+
* || (p.endsWith('*') && req.path.split('/')[0] === p.split('/')[0])
|
|
22
|
+
*
|
|
23
|
+
* Every request path begins with '/', so req.path.split('/')[0] is ''. Every wildcard entry also
|
|
24
|
+
* begins with '/', so p.split('/')[0] is ''. The clause reduces to '' === '' — always true. Any
|
|
25
|
+
* allowedPaths array containing a single '*' entry therefore matched EVERY request path and the
|
|
26
|
+
* 403 never fired.
|
|
27
|
+
*
|
|
28
|
+
* Two mint sites contain `/meeting/*`, and both are public and unauthenticated:
|
|
29
|
+
* - session_for_join_link — enduser session, MFA bypassed
|
|
30
|
+
* - session_for_start_link — full STAFF session with mfa: false
|
|
31
|
+
*
|
|
32
|
+
* So an unauthenticated caller holding a link token had an effectively unrestricted session.
|
|
33
|
+
*
|
|
34
|
+
* Fixing that exposed a second defect in the same primitive: entries matched by bare string prefix
|
|
35
|
+
* with no notion of HTTP method, so '/users' and '/endusers' were collection-NAMESPACE grants (22
|
|
36
|
+
* and 21 routes each). The worst consequence was a full escape — PATCH /users/consent called
|
|
37
|
+
* login_user on a freshly-read DB record with no options, minting a token with no allowedPaths at
|
|
38
|
+
* all, so two requests turned a start link into an unconfined staff session.
|
|
39
|
+
*
|
|
40
|
+
* Fix, in two parts:
|
|
41
|
+
* - entries are now method-scoped and exact ('get:/endusers'), with ':param' for segments not
|
|
42
|
+
* knowable at mint time ('get:/meeting/:id'). Un-prefixed and '*' entries remain honoured only
|
|
43
|
+
* so tokens minted before this grammar keep working until they expire.
|
|
44
|
+
* - consent / configure_inbox / switch_account forward req.session.allowedPaths into login_user.
|
|
45
|
+
*
|
|
46
|
+
* Scenarios:
|
|
47
|
+
* S1 public-form session — no wildcard; correctly confined throughout (regression anchor)
|
|
48
|
+
* S2 join-link session — RED before the wildcard fix
|
|
49
|
+
* S3 start-link session — RED before the wildcard fix (staff session; the severe case)
|
|
50
|
+
* S4 side-effects of the /calendar-events entry
|
|
51
|
+
* S6 namespace grants and the privilege-escalation chain — RED before method-scoped exact matching
|
|
52
|
+
* S5 ordinary sessions with no allowedPaths claim, incl. headless API-key callers, are unaffected
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
56
|
+
const OBJECT_ID = '68a1b2c3d4e5f60718293a4b'
|
|
57
|
+
|
|
58
|
+
type ProbeMethod = 'get' | 'post' | 'patch' | 'put' | 'delete'
|
|
59
|
+
|
|
60
|
+
type Probe = {
|
|
61
|
+
scenario: string,
|
|
62
|
+
label: string,
|
|
63
|
+
path: string,
|
|
64
|
+
expected: 'allowed' | 'denied',
|
|
65
|
+
status: number,
|
|
66
|
+
baselineStatus?: number,
|
|
67
|
+
pass: boolean,
|
|
68
|
+
note?: string,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
72
|
+
log_header("F-0166: allowedPaths wildcard bypasses session path confinement")
|
|
73
|
+
|
|
74
|
+
const probes: Probe[] = []
|
|
75
|
+
|
|
76
|
+
// Probe with raw axios rather than the SDK: parseError (sdk/src/session.ts) discards the status
|
|
77
|
+
// code, and a path-confinement denial is byte-identical to a permissions denial.
|
|
78
|
+
const request = async (token: string, method: ProbeMethod, path: string, data?: any) => {
|
|
79
|
+
const r = await axios.request({
|
|
80
|
+
method,
|
|
81
|
+
url: `${host}/v1${path}`,
|
|
82
|
+
...method === 'get' ? { params: data } : { data },
|
|
83
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
84
|
+
validateStatus: () => true,
|
|
85
|
+
})
|
|
86
|
+
return { status: r.status, body: r.data }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isPathDenial = (r: { status: number, body: any }) => (
|
|
90
|
+
r.status === 403 && r.body?.message === 'Inaccessible'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A confined session must be denied `path`.
|
|
95
|
+
*
|
|
96
|
+
* First confirms an UNCONFINED session of the same type gets a non-403 on the same path in the
|
|
97
|
+
* same run. Without that, a 403 could just as easily be an ordinary permissions denial and the
|
|
98
|
+
* test would pass for the wrong reason.
|
|
99
|
+
*/
|
|
100
|
+
const expectDenied = async (
|
|
101
|
+
scenario: string, label: string, confinedToken: string, baselineToken: string,
|
|
102
|
+
method: ProbeMethod, path: string, data?: any,
|
|
103
|
+
) => {
|
|
104
|
+
const baseline = await request(baselineToken, method, path, data)
|
|
105
|
+
const confined = await request(confinedToken, method, path, data)
|
|
106
|
+
|
|
107
|
+
probes.push({
|
|
108
|
+
scenario,
|
|
109
|
+
label,
|
|
110
|
+
path,
|
|
111
|
+
expected: 'denied',
|
|
112
|
+
status: confined.status,
|
|
113
|
+
baselineStatus: baseline.status,
|
|
114
|
+
pass: baseline.status !== 403 && isPathDenial(confined),
|
|
115
|
+
note: baseline.status === 403 ? 'BAD PROBE: baseline session also got 403' : undefined,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The path must not be blocked by confinement.
|
|
121
|
+
*
|
|
122
|
+
* Asserts the status is neither 403 (path denied) nor 401 (unauthenticated) rather than === 200,
|
|
123
|
+
* so a 404 from a nonexistent id or a 400 from a stub body is not a false failure. Rejecting 401
|
|
124
|
+
* matters: without it a probe that never reached the handler would pass as "allowed".
|
|
125
|
+
* `allow401` opts out for paths the session legitimately cannot authenticate against.
|
|
126
|
+
*/
|
|
127
|
+
const expectAllowed = async (
|
|
128
|
+
scenario: string, label: string, token: string,
|
|
129
|
+
method: ProbeMethod, path: string, data?: any,
|
|
130
|
+
{ allow401 = false }: { allow401?: boolean } = {},
|
|
131
|
+
) => {
|
|
132
|
+
const r = await request(token, method, path, data)
|
|
133
|
+
probes.push({
|
|
134
|
+
scenario,
|
|
135
|
+
label,
|
|
136
|
+
path,
|
|
137
|
+
expected: 'allowed',
|
|
138
|
+
status: r.status,
|
|
139
|
+
pass: r.status !== 403 && (allow401 || r.status !== 401),
|
|
140
|
+
note: allow401 && r.status === 401 ? '401 expected: enduser cannot authenticate this model' : undefined,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let formId: string | undefined
|
|
145
|
+
let productId: string | undefined
|
|
146
|
+
let joinEnduserId: string | undefined
|
|
147
|
+
let formEnduserEmail: string | undefined
|
|
148
|
+
let hostUserId: string | undefined
|
|
149
|
+
let templateId: string | undefined
|
|
150
|
+
let bookingEnduserId: string | undefined
|
|
151
|
+
const eventIds: string[] = []
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
/* ================= fixtures ================= */
|
|
155
|
+
|
|
156
|
+
const businessId = sdk.userInfo.businessId
|
|
157
|
+
|
|
158
|
+
// -- public form (no wildcard in its allowedPaths)
|
|
159
|
+
const form = await sdk.api.forms.createOne({
|
|
160
|
+
title: `F-0166 path confinement ${RAND()}`,
|
|
161
|
+
allowPublicURL: true,
|
|
162
|
+
})
|
|
163
|
+
formId = form.id
|
|
164
|
+
await sdk.api.form_fields.createOne({
|
|
165
|
+
formId: form.id,
|
|
166
|
+
title: 'q',
|
|
167
|
+
type: 'string',
|
|
168
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
169
|
+
})
|
|
170
|
+
formEnduserEmail = `f0166-form-${RAND()}@tellescope.example`
|
|
171
|
+
const publicFormSession = await new EnduserSession({ host, businessId })
|
|
172
|
+
.api.form_responses.session_for_public_form({
|
|
173
|
+
businessId,
|
|
174
|
+
formId: form.id,
|
|
175
|
+
email: formEnduserEmail,
|
|
176
|
+
fname: 'Path',
|
|
177
|
+
lname: 'Confinement',
|
|
178
|
+
skipMatch: true, // also skips the intake-phone requirement
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const product = await sdk.api.products.createOne({
|
|
182
|
+
title: `F-0166 product ${RAND()}`,
|
|
183
|
+
cost: { amount: 500, currency: 'USD' },
|
|
184
|
+
processor: 'Stripe',
|
|
185
|
+
} as any)
|
|
186
|
+
productId = product.id
|
|
187
|
+
|
|
188
|
+
// -- join link (enduser session, allowedPaths contains /meeting/*)
|
|
189
|
+
const joinEnduser = await sdk.api.endusers.createOne({
|
|
190
|
+
email: `f0166-join-${RAND()}@tellescope.example`,
|
|
191
|
+
})
|
|
192
|
+
joinEnduserId = joinEnduser.id
|
|
193
|
+
|
|
194
|
+
const joinLinkToken = `f0166-join-${RAND()}${RAND()}`
|
|
195
|
+
const joinEvent = await sdk.api.calendar_events.createOne({
|
|
196
|
+
title: 'F-0166 join', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
197
|
+
attendees: [{ type: 'enduser', id: joinEnduser.id, joinLinkToken }],
|
|
198
|
+
} as any)
|
|
199
|
+
eventIds.push(joinEvent.id)
|
|
200
|
+
|
|
201
|
+
const unrelatedEvent = await sdk.api.calendar_events.createOne({
|
|
202
|
+
title: 'F-0166 unrelated', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
203
|
+
attendees: [{ type: 'enduser', id: joinEnduser.id }],
|
|
204
|
+
} as any)
|
|
205
|
+
eventIds.push(unrelatedEvent.id)
|
|
206
|
+
|
|
207
|
+
const joinSession = await new EnduserSession({ host, businessId })
|
|
208
|
+
.api.calendar_events.session_for_join_link({ token: joinLinkToken })
|
|
209
|
+
|
|
210
|
+
// -- start link (STAFF session, allowedPaths contains /meeting/*)
|
|
211
|
+
// throwaway user; verifiedEmail must be set at create time (it is rejected on update)
|
|
212
|
+
const hostUser = await sdk.api.users.createOne({
|
|
213
|
+
email: `f0166-host-${RAND()}@tellescope.example`,
|
|
214
|
+
fname: 'Path', lname: 'Host',
|
|
215
|
+
verifiedEmail: true,
|
|
216
|
+
notificationEmailsDisabled: true,
|
|
217
|
+
} as any)
|
|
218
|
+
hostUserId = hostUser.id
|
|
219
|
+
|
|
220
|
+
const startLinkToken = `f0166-start-${RAND()}${RAND()}`
|
|
221
|
+
const startEvent = await sdk.api.calendar_events.createOne({
|
|
222
|
+
title: 'F-0166 start', durationInMinutes: 30, startTimeInMS: Date.now() + 3600_000,
|
|
223
|
+
attendees: [{ type: 'user', id: hostUser.id }, { type: 'enduser', id: joinEnduser.id }],
|
|
224
|
+
startLinkToken,
|
|
225
|
+
} as any)
|
|
226
|
+
eventIds.push(startEvent.id)
|
|
227
|
+
|
|
228
|
+
const startSession = await sdk.api.calendar_events.session_for_start_link({ token: startLinkToken })
|
|
229
|
+
|
|
230
|
+
// -- unconfined baselines of each session type (generate_auth_token, never .authenticate)
|
|
231
|
+
const { authToken: baselineEnduserToken } = await sdk.api.endusers.generate_auth_token({ id: joinEnduser.id })
|
|
232
|
+
const { authToken: baselineUserToken } = await sdk.api.users.generate_auth_token({ id: hostUser.id })
|
|
233
|
+
|
|
234
|
+
/* ============ S1: public-form session — no wildcard, correctly confined already ============ */
|
|
235
|
+
|
|
236
|
+
const S1 = 'S1 public-form'
|
|
237
|
+
await expectAllowed(S1, 'always-allowed path', publicFormSession.authToken, 'get', '/test-authenticated')
|
|
238
|
+
await expectAllowed(S1, 'listed: form info for access code', publicFormSession.authToken, 'get', '/form-info-for-access-code', { accessCode: publicFormSession.accessCode })
|
|
239
|
+
await expectAllowed(S1, 'listed: refresh enduser session', publicFormSession.authToken, 'post', '/refresh-enduser-session')
|
|
240
|
+
await expectAllowed(S1, 'listed: product by id', publicFormSession.authToken, 'get', `/product/${productId}`)
|
|
241
|
+
|
|
242
|
+
await expectDenied(S1, 'unlisted: calendar events', publicFormSession.authToken, baselineEnduserToken, 'get', '/calendar-events')
|
|
243
|
+
await expectDenied(S1, 'unlisted: form responses', publicFormSession.authToken, baselineEnduserToken, 'get', '/form-responses')
|
|
244
|
+
await expectDenied(S1, 'unlisted: files', publicFormSession.authToken, baselineEnduserToken, 'get', '/files')
|
|
245
|
+
|
|
246
|
+
// 'get:/product' names one route, so it no longer leaks the whole /products namespace the way a
|
|
247
|
+
// bare '/product' prefix entry did — that used to reach /products/prepare-stripe-checkout too
|
|
248
|
+
await expectDenied(S1, 'exact matching: /products not granted by get:/product', publicFormSession.authToken, baselineEnduserToken, 'get', '/products')
|
|
249
|
+
|
|
250
|
+
/* ---- field confinement on the granted generic update route ----
|
|
251
|
+
* 'patch:/enduser/:id' is granted for Portal V2's terms gate, so the route check alone no longer
|
|
252
|
+
* protects this session — SESSION_SCOPE_WRITABLE_FIELDS does. These probes are the live control:
|
|
253
|
+
* the consent write lands, and every contactable/identity field is refused. Without them the
|
|
254
|
+
* module test is the only thing standing between a scope-table edit and account takeover.
|
|
255
|
+
*/
|
|
256
|
+
const formEnduserId = publicFormSession.enduserId
|
|
257
|
+
|
|
258
|
+
// The refresh payload for a fromPublicSession token must carry the session's OWN id — portal V2
|
|
259
|
+
// seeds its store from it and then addresses itself by id, so without it every write goes to
|
|
260
|
+
// /enduser/undefined — while still withholding everything else about the patient. Both halves
|
|
261
|
+
// matter: adding the id must not become a foothold for widening this payload.
|
|
262
|
+
const publicRefresh = await request(publicFormSession.authToken, 'post', '/refresh-enduser-session', {})
|
|
263
|
+
const refreshedEnduser = publicRefresh.body?.enduser ?? {}
|
|
264
|
+
probes.push({
|
|
265
|
+
scenario: S1,
|
|
266
|
+
label: 'public refresh exposes the session\'s own id',
|
|
267
|
+
path: '/refresh-enduser-session',
|
|
268
|
+
expected: 'allowed',
|
|
269
|
+
status: refreshedEnduser.id === formEnduserId ? 200 : 500,
|
|
270
|
+
pass: refreshedEnduser.id === formEnduserId,
|
|
271
|
+
note: `id=${refreshedEnduser.id}`,
|
|
272
|
+
})
|
|
273
|
+
const withheld = ['email', 'phone', 'dateOfBirth', 'fname', 'lname', 'note', 'insurance', 'tags']
|
|
274
|
+
.filter(f => refreshedEnduser[f] !== undefined)
|
|
275
|
+
probes.push({
|
|
276
|
+
scenario: S1,
|
|
277
|
+
label: 'public refresh still withholds the patient record',
|
|
278
|
+
path: '/refresh-enduser-session',
|
|
279
|
+
expected: 'denied',
|
|
280
|
+
status: withheld.length ? 200 : 403,
|
|
281
|
+
pass: withheld.length === 0,
|
|
282
|
+
note: withheld.length ? `LEAKED [${withheld}]` : `keys=[${Object.keys(refreshedEnduser)}]`,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
await expectAllowed(
|
|
286
|
+
S1, 'field-restricted: may record terms consent', publicFormSession.authToken,
|
|
287
|
+
'patch', `/enduser/${formEnduserId}`,
|
|
288
|
+
{ updates: { termsSigned: new Date().toISOString(), termsVersion: '1' } },
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
// The write being confined is not enough: the update RESPONSE is not redacted for enduser sessions
|
|
292
|
+
// (applyRedactions runs only for user sessions on that path), so an unprojected echo would hand
|
|
293
|
+
// this session the record it can't otherwise read — GET /enduser/:id is not even in this scope.
|
|
294
|
+
// Assert the response carries ONLY what the session was allowed to write.
|
|
295
|
+
const consentEcho = await request(
|
|
296
|
+
publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`,
|
|
297
|
+
{ updates: { termsVersion: '2' } },
|
|
298
|
+
)
|
|
299
|
+
const echoedKeys = Object.keys(consentEcho.body ?? {})
|
|
300
|
+
const leakedKeys = echoedKeys.filter(k => !['id', 'termsSigned', 'termsVersion'].includes(k))
|
|
301
|
+
probes.push({
|
|
302
|
+
scenario: S1,
|
|
303
|
+
label: 'field-restricted: update response echoes only the writable fields',
|
|
304
|
+
path: `/enduser/${formEnduserId}`,
|
|
305
|
+
expected: 'allowed',
|
|
306
|
+
status: leakedKeys.length === 0 ? consentEcho.status : 500,
|
|
307
|
+
pass: consentEcho.status === 200 && leakedKeys.length === 0,
|
|
308
|
+
note: leakedKeys.length ? `LEAKED [${leakedKeys}]` : `echoed [${echoedKeys}]`,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// email/phone are the takeover vector: password reset resolves by findOne({ email }) and OTP codes
|
|
312
|
+
// deliver to enduser.phone. dateOfBirth/fname are the public-form MATCH keys, so writing them would
|
|
313
|
+
// let a session redirect which patient a later submission attaches to.
|
|
314
|
+
const fieldsThatMustBeRefused: [string, any][] = [
|
|
315
|
+
['email', `f0166-takeover-${RAND()}@tellescope.example`],
|
|
316
|
+
['phone', '+15555550123'],
|
|
317
|
+
['landline', '+15555550124'],
|
|
318
|
+
['dateOfBirth', '1990-01-01'],
|
|
319
|
+
['fname', 'F0166Overwritten'],
|
|
320
|
+
]
|
|
321
|
+
for (const [field, value] of fieldsThatMustBeRefused) {
|
|
322
|
+
const r = await request(
|
|
323
|
+
publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`, { updates: { [field]: value } },
|
|
324
|
+
)
|
|
325
|
+
probes.push({
|
|
326
|
+
scenario: S1,
|
|
327
|
+
label: `field-restricted: cannot write enduser '${field}'`,
|
|
328
|
+
path: `/enduser/${formEnduserId}`,
|
|
329
|
+
expected: 'denied',
|
|
330
|
+
status: r.status,
|
|
331
|
+
pass: r.status === 403,
|
|
332
|
+
note: 'route is granted; the field allowlist is what refuses this',
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// the field guard must reject the whole request rather than filtering, or the allowed key would
|
|
337
|
+
// carry a disallowed one through alongside it
|
|
338
|
+
const mixed = await request(
|
|
339
|
+
publicFormSession.authToken, 'patch', `/enduser/${formEnduserId}`,
|
|
340
|
+
{ updates: { termsVersion: '2', email: `f0166-mixed-${RAND()}@tellescope.example` } },
|
|
341
|
+
)
|
|
342
|
+
probes.push({
|
|
343
|
+
scenario: S1,
|
|
344
|
+
label: 'field-restricted: a mixed allowed+denied update is refused whole',
|
|
345
|
+
path: `/enduser/${formEnduserId}`,
|
|
346
|
+
expected: 'denied',
|
|
347
|
+
status: mixed.status,
|
|
348
|
+
pass: mixed.status === 403,
|
|
349
|
+
note: 'no partial application — email must not ride along with termsVersion',
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// and the email on record must be untouched by all of the above
|
|
353
|
+
const afterProbes = await sdk.api.endusers.getOne(formEnduserId)
|
|
354
|
+
probes.push({
|
|
355
|
+
scenario: S1,
|
|
356
|
+
label: 'field-restricted: stored email survived every attempt',
|
|
357
|
+
path: `/enduser/${formEnduserId}`,
|
|
358
|
+
expected: 'denied',
|
|
359
|
+
status: afterProbes.email === formEnduserEmail ? 403 : 200,
|
|
360
|
+
pass: afterProbes.email === formEnduserEmail,
|
|
361
|
+
note: `stored=${afterProbes.email}`,
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
/* ============ S2: join-link session — RED before the fix ============ */
|
|
365
|
+
|
|
366
|
+
const S2 = 'S2 join-link (enduser)'
|
|
367
|
+
await expectDenied(S2, 'unlisted: form responses', joinSession.authToken, baselineEnduserToken, 'get', '/form-responses')
|
|
368
|
+
await expectDenied(S2, 'unlisted: files', joinSession.authToken, baselineEnduserToken, 'get', '/files')
|
|
369
|
+
await expectDenied(S2, 'unlisted: portal customizations', joinSession.authToken, baselineEnduserToken, 'get', '/portal-customizations')
|
|
370
|
+
await expectDenied(S2, 'unlisted: UNRELATED calendar event', joinSession.authToken, baselineEnduserToken, 'get', `/calendar-event/${unrelatedEvent.id}`)
|
|
371
|
+
|
|
372
|
+
await expectAllowed(S2, 'always-allowed path', joinSession.authToken, 'get', '/test-authenticated')
|
|
373
|
+
await expectAllowed(S2, 'listed: my meetings', joinSession.authToken, 'get', '/my-meetings')
|
|
374
|
+
// enduserActions for organizations is {}, so an enduser 401s here regardless of confinement
|
|
375
|
+
await expectAllowed(S2, 'listed: organizations', joinSession.authToken, 'get', '/organizations', undefined, { allow401: true })
|
|
376
|
+
await expectAllowed(S2, 'listed: users', joinSession.authToken, 'get', '/users')
|
|
377
|
+
await expectAllowed(S2, 'listed: pinned calendar event', joinSession.authToken, 'get', `/calendar-event/${joinEvent.id}`)
|
|
378
|
+
await expectAllowed(S2, 'wildcard: meeting by id', joinSession.authToken, 'get', `/meeting/${OBJECT_ID}`)
|
|
379
|
+
// the two paths widened in this change so the portal video flow keeps working
|
|
380
|
+
await expectAllowed(S2, 'widened: calendar events list', joinSession.authToken, 'get', '/calendar-events')
|
|
381
|
+
await expectAllowed(S2, 'widened: join meeting for event', joinSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: joinEvent.id })
|
|
382
|
+
|
|
383
|
+
// The WaitingRoom marks attendance here. This write is deliberately NOT field-restricted: the
|
|
384
|
+
// portal reads the event back through useCalendarEvents, whose updateElement REPLACES the local
|
|
385
|
+
// record with the update response (react state.tsx), so a truncated response would drop event
|
|
386
|
+
// fields out of local state mid-visit and break a live Twilio call. Assert both halves of that
|
|
387
|
+
// contract — the write lands, AND the response is still a whole event.
|
|
388
|
+
const attendanceWrite = await request(
|
|
389
|
+
joinSession.authToken, 'patch', `/calendar-event/${joinEvent.id}`,
|
|
390
|
+
{ updates: { joinedVideoCall: [{ id: joinEnduserId, at: new Date().toISOString() }] } },
|
|
391
|
+
)
|
|
392
|
+
probes.push({
|
|
393
|
+
scenario: S2,
|
|
394
|
+
label: 'marks joinedVideoCall on its pinned event',
|
|
395
|
+
path: `/calendar-event/${joinEvent.id}`,
|
|
396
|
+
expected: 'allowed',
|
|
397
|
+
status: attendanceWrite.status,
|
|
398
|
+
pass: attendanceWrite.status === 200,
|
|
399
|
+
})
|
|
400
|
+
// title/durationInMinutes were set at fixture creation, so their presence proves the response was
|
|
401
|
+
// not projected down to the written field
|
|
402
|
+
const echoedEvent = attendanceWrite.body ?? {}
|
|
403
|
+
probes.push({
|
|
404
|
+
scenario: S2,
|
|
405
|
+
label: 'attendance write returns the WHOLE event (client replaces local state from it)',
|
|
406
|
+
path: `/calendar-event/${joinEvent.id}`,
|
|
407
|
+
expected: 'allowed',
|
|
408
|
+
status: (echoedEvent.title && echoedEvent.durationInMinutes) ? 200 : 500,
|
|
409
|
+
pass: !!echoedEvent.title && !!echoedEvent.durationInMinutes && !!echoedEvent.joinedVideoCall,
|
|
410
|
+
note: `echoed ${Object.keys(echoedEvent).length} keys`,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// confinement must survive a refresh rather than widening
|
|
414
|
+
const refreshedJoin = await request(joinSession.authToken, 'post', '/refresh-enduser-session', {})
|
|
415
|
+
const refreshedJoinToken = refreshedJoin.body?.authToken
|
|
416
|
+
probes.push({
|
|
417
|
+
scenario: S2, label: 'refresh returned a new token', path: '/refresh-enduser-session',
|
|
418
|
+
expected: 'allowed', status: refreshedJoin.status, pass: !!refreshedJoinToken,
|
|
419
|
+
})
|
|
420
|
+
if (refreshedJoinToken) {
|
|
421
|
+
await expectDenied(S2, 'after refresh: still denied form responses', refreshedJoinToken, baselineEnduserToken, 'get', '/form-responses')
|
|
422
|
+
await expectAllowed(S2, 'after refresh: still allowed my meetings', refreshedJoinToken, 'get', '/my-meetings')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ============ S3: start-link session — STAFF, RED before the fix ============ */
|
|
426
|
+
|
|
427
|
+
const S3 = 'S3 start-link (STAFF)'
|
|
428
|
+
await expectDenied(S3, 'unlisted: form responses', startSession.authToken, baselineUserToken, 'get', '/form-responses')
|
|
429
|
+
await expectDenied(S3, 'unlisted: journeys', startSession.authToken, baselineUserToken, 'get', '/journeys')
|
|
430
|
+
await expectDenied(S3, 'unlisted: tickets', startSession.authToken, baselineUserToken, 'get', '/tickets')
|
|
431
|
+
await expectDenied(S3, 'unlisted: data sync (org-wide change firehose)', startSession.authToken, baselineUserToken, 'get', '/data-sync', { from: new Date(Date.now() - 60_000).toISOString() })
|
|
432
|
+
await expectDenied(S3, 'unlisted: file download URL', startSession.authToken, baselineUserToken, 'get', '/file-download-URL', { secureName: 'f0166-does-not-exist' })
|
|
433
|
+
await expectDenied(S3, 'unlisted: UNRELATED calendar event', startSession.authToken, baselineUserToken, 'get', `/calendar-event/${unrelatedEvent.id}`)
|
|
434
|
+
|
|
435
|
+
await expectAllowed(S3, 'always-allowed path', startSession.authToken, 'get', '/test-authenticated')
|
|
436
|
+
await expectAllowed(S3, 'listed: my meetings', startSession.authToken, 'get', '/my-meetings')
|
|
437
|
+
await expectAllowed(S3, 'listed: calendar events', startSession.authToken, 'get', '/calendar-events')
|
|
438
|
+
await expectAllowed(S3, 'listed: organizations', startSession.authToken, 'get', '/organizations')
|
|
439
|
+
await expectAllowed(S3, 'listed: users', startSession.authToken, 'get', '/users')
|
|
440
|
+
await expectAllowed(S3, 'listed: endusers', startSession.authToken, 'get', '/endusers')
|
|
441
|
+
await expectAllowed(S3, 'listed: pinned calendar event', startSession.authToken, 'get', `/calendar-event/${startEvent.id}`)
|
|
442
|
+
await expectAllowed(S3, 'wildcard: meeting by id', startSession.authToken, 'get', `/meeting/${OBJECT_ID}`)
|
|
443
|
+
await expectAllowed(S3, 'prefix descendant: users bulk-read', startSession.authToken, 'post', '/users/bulk-read', { ids: [hostUser.id] })
|
|
444
|
+
await expectAllowed(S3, 'prefix descendant: endusers bulk-read', startSession.authToken, 'post', '/endusers/bulk-read', { ids: [joinEnduser.id] })
|
|
445
|
+
await expectAllowed(S3, 'widened: join meeting for event', startSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: startEvent.id })
|
|
446
|
+
await expectAllowed(S3, 'widened: add attendees to meeting', startSession.authToken, 'post', '/add-attendees-to-meeting', { id: OBJECT_ID })
|
|
447
|
+
|
|
448
|
+
// login_user propagates allowedPaths implicitly through its ...toSign rest spread — the
|
|
449
|
+
// "(ASSUMPTION)" comments in the mint sites depend on this, so assert it explicitly
|
|
450
|
+
const refreshedStart = await request(startSession.authToken, 'post', '/refresh-session', {})
|
|
451
|
+
const refreshedStartToken = refreshedStart.body?.authToken
|
|
452
|
+
probes.push({
|
|
453
|
+
scenario: S3, label: 'refresh returned a new token', path: '/refresh-session',
|
|
454
|
+
expected: 'allowed', status: refreshedStart.status, pass: !!refreshedStartToken,
|
|
455
|
+
})
|
|
456
|
+
if (refreshedStartToken) {
|
|
457
|
+
await expectDenied(S3, 'after refresh: still denied journeys', refreshedStartToken, baselineUserToken, 'get', '/journeys')
|
|
458
|
+
await expectAllowed(S3, 'after refresh: still allowed my meetings', refreshedStartToken, 'get', '/my-meetings')
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* ============ S4: side-effects of the widened /calendar-events entry ============ */
|
|
462
|
+
|
|
463
|
+
// Non-wildcard entries still match by bare prefix, so adding '/calendar-events' to the join-link
|
|
464
|
+
// list also prefix-grants '/calendar-events/bulk-update' and friends. Those are staff-only and
|
|
465
|
+
// gated by enduserActions, so an enduser session is still refused — assert that rather than
|
|
466
|
+
// assume it. This is the concrete argument for the segment-boundary follow-up.
|
|
467
|
+
const S4 = 'S4 widening side-effects'
|
|
468
|
+
const prefixGranted: [('patch' | 'post'), string][] = [
|
|
469
|
+
['patch', '/calendar-events/bulk-update'],
|
|
470
|
+
['post', '/calendar-events/report'],
|
|
471
|
+
]
|
|
472
|
+
for (const [method, path] of prefixGranted) {
|
|
473
|
+
const r = await axios.request({
|
|
474
|
+
method,
|
|
475
|
+
url: `${host}/v1${path}`,
|
|
476
|
+
data: {},
|
|
477
|
+
headers: { Authorization: `Bearer ${joinSession.authToken}` },
|
|
478
|
+
validateStatus: () => true,
|
|
479
|
+
})
|
|
480
|
+
probes.push({
|
|
481
|
+
scenario: S4,
|
|
482
|
+
label: `enduser action gating blocks ${method.toUpperCase()} ${path}`,
|
|
483
|
+
path,
|
|
484
|
+
expected: 'denied',
|
|
485
|
+
status: r.status,
|
|
486
|
+
pass: r.status === 401 || r.status === 403,
|
|
487
|
+
note: 'prefix-granted by /calendar-events; blocked by enduserActions instead',
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ============ S6: the namespace grants and the privilege-escalation chain ============ */
|
|
492
|
+
|
|
493
|
+
// Before method-scoped exact matching, '/users' and '/endusers' were collection-namespace
|
|
494
|
+
// grants: 22 and 21 routes each. The worst of them, PATCH /users/consent, called
|
|
495
|
+
// login_user on a fresh DB record and minted a token with NO allowedPaths at all — so an
|
|
496
|
+
// unauthenticated start link could be traded for a fully unconfined staff session.
|
|
497
|
+
const S6 = 'S6 namespace escapes'
|
|
498
|
+
|
|
499
|
+
// expectDenied issues the BASELINE request for real, so mutating probes must be given inert
|
|
500
|
+
// payloads — nonexistent ids or bodies that fail validation. A 400/404 baseline still proves the
|
|
501
|
+
// route is reachable (the check is only `!== 403`). Passing real ids here once made the
|
|
502
|
+
// unconfined baseline actually perform an /endusers/merge, deleting the fixture enduser.
|
|
503
|
+
const ABSENT_ID_A = '68a1b2c3d4e5f60718293a4b'
|
|
504
|
+
const ABSENT_ID_B = '68a1b2c3d4e5f60718293a4c'
|
|
505
|
+
const INVALID_EMAIL = 'f0166-not-an-email'
|
|
506
|
+
|
|
507
|
+
await expectDenied(S6, 'ESCAPE: /users/consent (minted an unconfined token)', startSession.authToken, baselineUserToken, 'patch', '/users/consent', { termsVersion: '1' })
|
|
508
|
+
await expectDenied(S6, 'ESCAPE: /users/switch-account', startSession.authToken, baselineUserToken, 'post', '/users/switch-account', { targetUserId: ABSENT_ID_A })
|
|
509
|
+
await expectDenied(S6, 'ESCAPE: /users/configure-inbox', startSession.authToken, baselineUserToken, 'post', '/users/configure-inbox', {})
|
|
510
|
+
await expectDenied(S6, 'PHI: /endusers/load-inbox-data', startSession.authToken, baselineUserToken, 'get', '/endusers/load-inbox-data')
|
|
511
|
+
await expectDenied(S6, 'PHI: /endusers/report', startSession.authToken, baselineUserToken, 'get', '/endusers/report')
|
|
512
|
+
await expectDenied(S6, 'PHI: /endusers/engagement', startSession.authToken, baselineUserToken, 'get', '/endusers/engagement')
|
|
513
|
+
await expectDenied(S6, 'DESTRUCTIVE: /endusers/merge', startSession.authToken, baselineUserToken, 'post', '/endusers/merge', { sourceEnduserId: ABSENT_ID_A, destinationEnduserId: ABSENT_ID_B })
|
|
514
|
+
await expectDenied(S6, 'DESTRUCTIVE: /endusers/bulk-update', startSession.authToken, baselineUserToken, 'patch', '/endusers/bulk-update', { ids: [ABSENT_ID_A], fields: {} })
|
|
515
|
+
|
|
516
|
+
// method scoping, end to end: the same path is readable but not writable.
|
|
517
|
+
// Baselines use the ADMIN token — the throwaway host user cannot create users or endusers, so it
|
|
518
|
+
// would 403 on permissions and the probe could not distinguish that from a confinement denial.
|
|
519
|
+
await expectAllowed(S6, 'method scoping: GET /endusers allowed', startSession.authToken, 'get', '/endusers')
|
|
520
|
+
await expectDenied(S6, 'method scoping: POST /endusers (create) denied', startSession.authToken, sdk.authToken, 'post', '/endusers', { email: INVALID_EMAIL })
|
|
521
|
+
await expectAllowed(S6, 'method scoping: GET /users allowed', startSession.authToken, 'get', '/users')
|
|
522
|
+
await expectDenied(S6, 'method scoping: POST /users (create) denied', startSession.authToken, sdk.authToken, 'post', '/users', { email: INVALID_EMAIL })
|
|
523
|
+
|
|
524
|
+
// even if consent were reachable, the token it mints must stay confined (defense in depth)
|
|
525
|
+
const consentAttempt = await request(startSession.authToken, 'patch', '/users/consent', { termsVersion: '1' })
|
|
526
|
+
if (consentAttempt.status === 200 && consentAttempt.body?.authToken) {
|
|
527
|
+
await expectDenied(S6, 'consent-minted token is still confined', consentAttempt.body.authToken, baselineUserToken, 'get', '/journeys')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Confinement now lives in a `sessionScopes` claim resolved server-side. Refresh must carry that
|
|
531
|
+
// claim, not silently drop it — login_user's toSign is a deny-list so it propagates, but that is
|
|
532
|
+
// exactly the kind of implicit behavior worth asserting.
|
|
533
|
+
const refreshedForScopes = await request(startSession.authToken, 'post', '/refresh-session', {})
|
|
534
|
+
const rescopedToken = refreshedForScopes.body?.authToken
|
|
535
|
+
probes.push({
|
|
536
|
+
scenario: S6, label: 'refresh returned a new token', path: '/refresh-session',
|
|
537
|
+
expected: 'allowed', status: refreshedForScopes.status, pass: !!rescopedToken,
|
|
538
|
+
})
|
|
539
|
+
if (rescopedToken) {
|
|
540
|
+
await expectDenied(S6, 'after refresh: still cannot reach /users/consent', rescopedToken, baselineUserToken, 'patch', '/users/consent', { termsVersion: '1' })
|
|
541
|
+
await expectDenied(S6, 'after refresh: still cannot reach /endusers/load-inbox-data', rescopedToken, baselineUserToken, 'get', '/endusers/load-inbox-data')
|
|
542
|
+
await expectDenied(S6, 'after refresh: POST /endusers still denied', rescopedToken, baselineUserToken, 'post', '/endusers', { email: `f0166-should-not-exist-${RAND()}@tellescope.example` })
|
|
543
|
+
await expectAllowed(S6, 'after refresh: GET /endusers still allowed', rescopedToken, 'get', '/endusers')
|
|
544
|
+
await expectAllowed(S6, 'after refresh: my-meetings still allowed', rescopedToken, 'get', '/my-meetings')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* ============ S7: appointment-booking session — the SAME terms gate as S1 ============
|
|
548
|
+
* Portal V2's terms gate wraps the booking route too, so this scope needs the same enduser write
|
|
549
|
+
* (and only that write). Without the grant the gate is unpassable and the patient never reaches the
|
|
550
|
+
* booking page at all. Field restriction and response projection must behave exactly as in S1 —
|
|
551
|
+
* mirrored here rather than assumed, since the two scopes are separate table entries.
|
|
552
|
+
*/
|
|
553
|
+
const S7 = 'S7 appointment-booking'
|
|
554
|
+
|
|
555
|
+
const bookingTemplate = await sdk.api.calendar_event_templates.createOne({
|
|
556
|
+
title: `F-0166 booking ${RAND()}`, durationInMinutes: 30,
|
|
557
|
+
})
|
|
558
|
+
templateId = bookingTemplate.id
|
|
559
|
+
|
|
560
|
+
const bookingEnduserEmail = `f0166-booking-${RAND()}@tellescope.example`
|
|
561
|
+
const bookingEnduser = await sdk.api.endusers.createOne({
|
|
562
|
+
email: bookingEnduserEmail, fname: 'Booking', lname: 'Confinement',
|
|
563
|
+
})
|
|
564
|
+
bookingEnduserId = bookingEnduser.id
|
|
565
|
+
|
|
566
|
+
const bookingSession = await new EnduserSession({ host, businessId })
|
|
567
|
+
.api.calendar_events.session_for_public_appointment_booking({
|
|
568
|
+
businessId,
|
|
569
|
+
calendarEventTemplateId: bookingTemplate.id,
|
|
570
|
+
email: bookingEnduserEmail,
|
|
571
|
+
fname: 'Booking',
|
|
572
|
+
lname: 'Confinement',
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
await expectAllowed(
|
|
576
|
+
S7, 'field-restricted: may record terms consent', bookingSession.authToken,
|
|
577
|
+
'patch', `/enduser/${bookingEnduser.id}`,
|
|
578
|
+
{ updates: { termsSigned: new Date().toISOString(), termsVersion: '1' } },
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
const bookingEcho = await request(
|
|
582
|
+
bookingSession.authToken, 'patch', `/enduser/${bookingEnduser.id}`, { updates: { termsVersion: '2' } },
|
|
583
|
+
)
|
|
584
|
+
const bookingLeaked = Object.keys(bookingEcho.body ?? {})
|
|
585
|
+
.filter(k => !['id', 'termsSigned', 'termsVersion'].includes(k))
|
|
586
|
+
probes.push({
|
|
587
|
+
scenario: S7,
|
|
588
|
+
label: 'field-restricted: update response echoes only the writable fields',
|
|
589
|
+
path: `/enduser/${bookingEnduser.id}`,
|
|
590
|
+
expected: 'allowed',
|
|
591
|
+
status: bookingLeaked.length === 0 ? bookingEcho.status : 500,
|
|
592
|
+
pass: bookingEcho.status === 200 && bookingLeaked.length === 0,
|
|
593
|
+
note: bookingLeaked.length ? `LEAKED [${bookingLeaked}]` : undefined,
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
for (const [field, value] of [['email', `f0166-bt-${RAND()}@tellescope.example`], ['phone', '+15555550188']] as [string, any][]) {
|
|
597
|
+
const r = await request(bookingSession.authToken, 'patch', `/enduser/${bookingEnduser.id}`, { updates: { [field]: value } })
|
|
598
|
+
probes.push({
|
|
599
|
+
scenario: S7,
|
|
600
|
+
label: `field-restricted: cannot write enduser '${field}'`,
|
|
601
|
+
path: `/enduser/${bookingEnduser.id}`,
|
|
602
|
+
expected: 'denied',
|
|
603
|
+
status: r.status,
|
|
604
|
+
pass: r.status === 403,
|
|
605
|
+
note: 'route is granted; the field allowlist is what refuses this',
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// the booking scope must not have gained anything else along with the enduser write
|
|
610
|
+
await expectDenied(S7, 'unlisted: form responses', bookingSession.authToken, baselineEnduserToken, 'get', '/form-responses')
|
|
611
|
+
await expectDenied(S7, 'unlisted: calendar events', bookingSession.authToken, baselineEnduserToken, 'get', '/calendar-events')
|
|
612
|
+
await expectAllowed(S7, 'listed: refresh enduser session', bookingSession.authToken, 'post', '/refresh-enduser-session')
|
|
613
|
+
|
|
614
|
+
/* ============ S5: ordinary sessions with no allowedPaths claim ============ */
|
|
615
|
+
|
|
616
|
+
const S5 = 'S5 unconfined sessions'
|
|
617
|
+
for (const path of ['/journeys', '/form-responses', '/endusers', '/calendar-events', '/tickets']) {
|
|
618
|
+
await expectAllowed(S5, `staff session reaches ${path}`, baselineUserToken, 'get', path)
|
|
619
|
+
}
|
|
620
|
+
await expectAllowed(S5, 'staff session reaches /data-sync', baselineUserToken, 'get', '/data-sync', { from: new Date(Date.now() - 60_000).toISOString() })
|
|
621
|
+
for (const path of ['/form-responses', '/calendar-events', '/files']) {
|
|
622
|
+
await expectAllowed(S5, `enduser session reaches ${path}`, baselineEnduserToken, 'get', path)
|
|
623
|
+
}
|
|
624
|
+
const adminAuthenticated = await sdk.test_authenticated()
|
|
625
|
+
probes.push({
|
|
626
|
+
scenario: S5, label: 'admin sdk still authenticated', path: '/test-authenticated',
|
|
627
|
+
expected: 'allowed', status: 200, pass: adminAuthenticated === 'Authenticated!',
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
// Headless / server-to-server callers authenticate with an API key, which resolves to a DB user
|
|
631
|
+
// record. `allowedPaths` is a JWT claim only (WithAllowedPaths in types-models is used solely on
|
|
632
|
+
// the session type; there is no schema validator for it), so an API-key session can never carry
|
|
633
|
+
// one and is never path-confined. Assert that rather than reason about it.
|
|
634
|
+
const apiKeySDK = new Session({ host, apiKey: '3n5q0SCBT_iUvZz-b9BJtX7o7HQUVJ9v132PgHJNJsg.' })
|
|
635
|
+
for (const path of ['/journeys', '/form-responses', '/endusers', '/calendar-events', '/files']) {
|
|
636
|
+
const r = await axios.request({
|
|
637
|
+
method: 'get',
|
|
638
|
+
url: `${host}/v1${path}`,
|
|
639
|
+
headers: { Authorization: `API_KEY ${(apiKeySDK as any).apiKey}` },
|
|
640
|
+
validateStatus: () => true,
|
|
641
|
+
})
|
|
642
|
+
probes.push({
|
|
643
|
+
scenario: S5,
|
|
644
|
+
label: `API key (headless) reaches ${path}`,
|
|
645
|
+
path,
|
|
646
|
+
expected: 'allowed',
|
|
647
|
+
status: r.status,
|
|
648
|
+
pass: r.status !== 403,
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* ============ report, then assert ============ */
|
|
653
|
+
|
|
654
|
+
// Collect everything before asserting. @tellescope/testing sets EXIT_ON_FIRST_ERROR, so
|
|
655
|
+
// asserting inline would abort at the first failure and hide the rest of the bypass.
|
|
656
|
+
const failures = probes.filter(p => !p.pass)
|
|
657
|
+
|
|
658
|
+
console.log('')
|
|
659
|
+
console.log(' F-0166 — full probe table')
|
|
660
|
+
console.log(' ' + '-'.repeat(112))
|
|
661
|
+
const scenarios = probes.map(p => p.scenario).filter((s, i, all) => all.indexOf(s) === i)
|
|
662
|
+
for (const scenario of scenarios) {
|
|
663
|
+
const inScenario = probes.filter(p => p.scenario === scenario)
|
|
664
|
+
const failed = inScenario.filter(p => !p.pass).length
|
|
665
|
+
console.log(` ${scenario}: ${inScenario.length - failed}/${inScenario.length} passing${failed ? ` <-- ${failed} FAILING` : ''}`)
|
|
666
|
+
for (const p of inScenario) {
|
|
667
|
+
const baseline = p.baselineStatus !== undefined ? ` baseline=${p.baselineStatus}` : ''
|
|
668
|
+
console.log(` ${p.pass ? ' ok ' : 'FAIL'} want=${p.expected.padEnd(7)} got=${p.status}${baseline} ${p.label}${p.note ? ` (${p.note})` : ''}`)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
console.log(' ' + '-'.repeat(112))
|
|
672
|
+
console.log(` TOTAL: ${probes.length - failures.length}/${probes.length} passing, ${failures.length} failing`)
|
|
673
|
+
console.log('')
|
|
674
|
+
|
|
675
|
+
for (const p of probes) {
|
|
676
|
+
assert(
|
|
677
|
+
p.pass,
|
|
678
|
+
`${p.scenario} — ${p.label} (${p.path}): wanted ${p.expected}, got status ${p.status}`
|
|
679
|
+
+ (p.baselineStatus !== undefined ? ` (unconfined baseline got ${p.baselineStatus})` : ''),
|
|
680
|
+
`${p.scenario} — ${p.label}`,
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
} finally {
|
|
684
|
+
for (const id of eventIds) {
|
|
685
|
+
try { await sdk.api.calendar_events.deleteOne(id) } catch {}
|
|
686
|
+
}
|
|
687
|
+
if (joinEnduserId) { try { await sdk.api.endusers.deleteOne(joinEnduserId) } catch {} }
|
|
688
|
+
if (formEnduserEmail) {
|
|
689
|
+
try {
|
|
690
|
+
const e = await sdk.api.endusers.getOne({ email: formEnduserEmail })
|
|
691
|
+
if (e?.id) await sdk.api.endusers.deleteOne(e.id)
|
|
692
|
+
} catch {}
|
|
693
|
+
}
|
|
694
|
+
if (hostUserId) { try { await sdk.api.users.deleteOne(hostUserId) } catch {} }
|
|
695
|
+
if (bookingEnduserId) { try { await sdk.api.endusers.deleteOne(bookingEnduserId) } catch {} }
|
|
696
|
+
if (templateId) { try { await sdk.api.calendar_event_templates.deleteOne(templateId) } catch {} }
|
|
697
|
+
if (productId) { try { await sdk.api.products.deleteOne(productId) } catch {} }
|
|
698
|
+
if (formId) { try { await sdk.api.forms.deleteOne(formId) } catch {} }
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (require.main === module) {
|
|
703
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
704
|
+
const sdk = new Session({ host })
|
|
705
|
+
const sdkNonAdmin = new Session({ host })
|
|
706
|
+
|
|
707
|
+
const runTests = async () => {
|
|
708
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
709
|
+
await allowed_paths_confinement_tests({ sdk, sdkNonAdmin })
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
runTests()
|
|
713
|
+
.then(() => {
|
|
714
|
+
console.log("✅ F-0166 allowedPaths confinement test suite completed successfully")
|
|
715
|
+
process.exit(0)
|
|
716
|
+
})
|
|
717
|
+
.catch((error) => {
|
|
718
|
+
console.error("❌ F-0166 allowedPaths confinement test suite failed:", error)
|
|
719
|
+
process.exit(1)
|
|
720
|
+
})
|
|
721
|
+
}
|