@tellescope/sdk 1.255.19 → 1.256.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/beluga_trigger_refill.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js +224 -0
- package/lib/cjs/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
- 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/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/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-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.js +351 -0
- package/lib/cjs/tests/api_tests/start_ai_conversation.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +477 -260
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js +220 -0
- package/lib/esm/tests/api_tests/beluga_trigger_refill.test.js.map +1 -0
- 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/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/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-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
- package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.js +347 -0
- package/lib/esm/tests/api_tests/start_ai_conversation.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +477 -260
- 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/beluga_trigger_refill.test.ts +152 -0
- 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/openloop_webhooks.test.ts +138 -0
- package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -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-0015-webhook-outbound-ssrf.test.ts +292 -0
- package/src/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.ts +179 -0
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
- package/src/tests/api_tests/start_ai_conversation.test.ts +256 -0
- package/src/tests/tests.ts +220 -1
- package/test_generated.pdf +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import http from "http"
|
|
4
|
+
import axios from "axios"
|
|
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 LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '::1']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 127.0.0.1 only means "the machine running this test" when the API is also local. Against staging or
|
|
18
|
+
* prod it is the API server itself — the sentinel below would never be contacted, every assertion
|
|
19
|
+
* here would pass for the wrong reason, and we would be probing a live environment. Fail loudly.
|
|
20
|
+
*/
|
|
21
|
+
const assert_local_api = () => {
|
|
22
|
+
const hostname = new URL(host).hostname
|
|
23
|
+
if (!LOCAL_HOSTNAMES.includes(hostname)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`F-0025 SSRF tests must run against a local API (got ${host}). `
|
|
26
|
+
+ `127.0.0.1 would resolve to the API server rather than this machine, so the tests would be meaningless.`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Regression tests for F-0025
|
|
33
|
+
* (security-audit/findings/F-0025-proxy-image-unauth-ssrf.md).
|
|
34
|
+
*
|
|
35
|
+
* GET /v1/proxy-image accepted an arbitrary ?url= and streamed the upstream response body straight
|
|
36
|
+
* back with Access-Control-Allow-Origin: *, with no authentication, no destination validation and no
|
|
37
|
+
* content-type restriction. That reaches http://169.254.169.254/ (EC2 instance credentials under
|
|
38
|
+
* IMDSv1), 127.0.0.1, and anything else inside the VPC.
|
|
39
|
+
*
|
|
40
|
+
* The endpoint's only legitimate job is to re-serve an image that ALREADY renders in the staff
|
|
41
|
+
* browser, with permissive CORS headers, so html2canvas can rasterize a form-response PDF without
|
|
42
|
+
* tainting the canvas (canvas.toDataURL() throws on a tainted canvas, and the form-response view sets
|
|
43
|
+
* crossOrigin="anonymous"). Anything reachable only from inside our network was therefore never a
|
|
44
|
+
* legitimate input — which is why refusing non-public destinations cannot regress the feature.
|
|
45
|
+
*
|
|
46
|
+
* Repro: stand up a local HTTP server that serves a JSON secret, point proxy-image at it with no
|
|
47
|
+
* credentials, and assert the secret does not come back.
|
|
48
|
+
* - red (unfixed): the sentinel body is streamed back to an unauthenticated caller
|
|
49
|
+
* - green (fixed): the request is refused and the sentinel server is never contacted
|
|
50
|
+
*
|
|
51
|
+
* Assertions are deliberately written as "4xx and nothing leaked" rather than pinning an exact status,
|
|
52
|
+
* because the fix both authenticates the endpoint and moves it to POST (the url moves into the body so
|
|
53
|
+
* presigned S3 URLs for patient attachments stop landing in access logs). Either 401 or 404/405 on the
|
|
54
|
+
* old GET shape is an acceptable outcome; streaming the body is not.
|
|
55
|
+
*
|
|
56
|
+
* The authenticated happy-path test lives with the Phase 2 endpoint change — it cannot exist here
|
|
57
|
+
* because the POST route does not yet exist, and a test that fails for "not implemented" would muddy
|
|
58
|
+
* the vulnerability proof.
|
|
59
|
+
*/
|
|
60
|
+
export const proxy_image_ssrf_tests = async ({ sdk } : { sdk: Session, sdkNonAdmin: Session }) => {
|
|
61
|
+
log_header("F-0025: proxy-image unauthenticated SSRF")
|
|
62
|
+
assert_local_api()
|
|
63
|
+
|
|
64
|
+
const SENTINEL = `f0025-sentinel-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
65
|
+
let sentinelHits = 0
|
|
66
|
+
|
|
67
|
+
// Stands in for an internal-only service (IMDS, an internal admin API, a VPC-local port). Serves
|
|
68
|
+
// JSON rather than an image, so it also exercises the missing content-type restriction.
|
|
69
|
+
const sentinel = http.createServer((_, res) => {
|
|
70
|
+
sentinelHits++
|
|
71
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
72
|
+
res.end(JSON.stringify({ sentinel: SENTINEL, credentials: 'internal-only' }))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await new Promise<void>(resolve => sentinel.listen(0, '127.0.0.1', () => resolve()))
|
|
77
|
+
const sentinelPort = (sentinel.address() as any).port
|
|
78
|
+
const sentinelURL = `http://127.0.0.1:${sentinelPort}/latest/meta-data/iam/security-credentials/`
|
|
79
|
+
|
|
80
|
+
// Raw axios rather than the SDK: the SDK always attaches an Authorization header, and the point
|
|
81
|
+
// here is that no credential is presented at all.
|
|
82
|
+
const call_unauthenticated = async () => {
|
|
83
|
+
sentinelHits = 0
|
|
84
|
+
const res = await axios.get(`${host}/v1/proxy-image`, {
|
|
85
|
+
params: { url: sentinelURL },
|
|
86
|
+
validateStatus: () => true, // capture 4xx/5xx instead of throwing
|
|
87
|
+
responseType: 'text',
|
|
88
|
+
transformResponse: [(d: any) => d], // keep the raw body as a string
|
|
89
|
+
timeout: 10_000,
|
|
90
|
+
} as any)
|
|
91
|
+
|
|
92
|
+
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? '')
|
|
93
|
+
return { status: res.status, leaked: body.includes(SENTINEL), sentinelHits }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// T1 — the core SSRF: an unauthenticated caller must not be able to read an internal resource.
|
|
97
|
+
await async_test(
|
|
98
|
+
'F-0025: proxy-image does not stream an internal non-image resource to an unauthenticated caller',
|
|
99
|
+
call_unauthenticated,
|
|
100
|
+
{
|
|
101
|
+
// Positive control: a numeric status proves we actually reached our API rather than failing to
|
|
102
|
+
// connect (a connection error would make "nothing leaked" true for the wrong reason).
|
|
103
|
+
onResult: (r: any) => (
|
|
104
|
+
typeof r.status === 'number'
|
|
105
|
+
&& r.leaked === false
|
|
106
|
+
&& r.sentinelHits === 0
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// T2 — the unauthenticated surface itself: the request must be refused outright, and the
|
|
112
|
+
// destination must never be contacted (no SSRF-as-a-side-effect even when the body is withheld).
|
|
113
|
+
await async_test(
|
|
114
|
+
'F-0025: proxy-image refuses an unauthenticated request without contacting the destination',
|
|
115
|
+
call_unauthenticated,
|
|
116
|
+
{ onResult: (r: any) => r.status >= 400 && r.status < 500 && r.sentinelHits === 0 },
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Authenticating is not enough on its own — a staff session must not be able to reach an internal
|
|
120
|
+
// address either. These use the real session, and are deterministic (no external network).
|
|
121
|
+
const call_authenticated = async (url: string) => {
|
|
122
|
+
sentinelHits = 0
|
|
123
|
+
const res = await axios.post(`${host}/v1/proxy-image`, { url }, {
|
|
124
|
+
headers: { Authorization: `Bearer ${sdk.authToken}` },
|
|
125
|
+
validateStatus: () => true,
|
|
126
|
+
timeout: 10_000,
|
|
127
|
+
} as any)
|
|
128
|
+
return { status: res.status, sentinelHits }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await async_test(
|
|
132
|
+
'F-0025: proxy-image refuses a private destination even for an authenticated staff session',
|
|
133
|
+
() => call_authenticated(sentinelURL),
|
|
134
|
+
{ onResult: (r: any) => r.status === 400 && r.sentinelHits === 0 },
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
await async_test(
|
|
138
|
+
'F-0025: proxy-image refuses cloud metadata for an authenticated staff session',
|
|
139
|
+
() => call_authenticated('https://169.254.169.254/latest/meta-data/'),
|
|
140
|
+
{ onResult: (r: any) => r.status === 400 },
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// Scheme and port are pinned: only https on 443, which is all the PDF export ever needs.
|
|
144
|
+
await async_test(
|
|
145
|
+
'F-0025: proxy-image refuses a plain http destination',
|
|
146
|
+
() => call_authenticated('http://example.com/logo.png'),
|
|
147
|
+
{ onResult: (r: any) => r.status === 400 },
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
await async_test(
|
|
151
|
+
'F-0025: proxy-image refuses a non-443 port',
|
|
152
|
+
() => call_authenticated('https://example.com:8443/logo.png'),
|
|
153
|
+
{ onResult: (r: any) => r.status === 400 },
|
|
154
|
+
)
|
|
155
|
+
} finally {
|
|
156
|
+
sentinel.close()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (require.main === module) {
|
|
161
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
162
|
+
const sdk = new Session({ host })
|
|
163
|
+
const sdkNonAdmin = new Session({ host })
|
|
164
|
+
|
|
165
|
+
const runTests = async () => {
|
|
166
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
167
|
+
await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
runTests()
|
|
171
|
+
.then(() => {
|
|
172
|
+
console.log("✅ F-0025 proxy-image SSRF test suite completed successfully")
|
|
173
|
+
process.exit(0)
|
|
174
|
+
})
|
|
175
|
+
.catch((error) => {
|
|
176
|
+
console.error("❌ F-0025 proxy-image SSRF test suite failed:", error)
|
|
177
|
+
process.exit(1)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
@@ -146,6 +146,8 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
146
146
|
let joinEnduserId: string | undefined
|
|
147
147
|
let formEnduserEmail: string | undefined
|
|
148
148
|
let hostUserId: string | undefined
|
|
149
|
+
let templateId: string | undefined
|
|
150
|
+
let bookingEnduserId: string | undefined
|
|
149
151
|
const eventIds: string[] = []
|
|
150
152
|
|
|
151
153
|
try {
|
|
@@ -245,6 +247,120 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
245
247
|
// bare '/product' prefix entry did — that used to reach /products/prepare-stripe-checkout too
|
|
246
248
|
await expectDenied(S1, 'exact matching: /products not granted by get:/product', publicFormSession.authToken, baselineEnduserToken, 'get', '/products')
|
|
247
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
|
+
|
|
248
364
|
/* ============ S2: join-link session — RED before the fix ============ */
|
|
249
365
|
|
|
250
366
|
const S2 = 'S2 join-link (enduser)'
|
|
@@ -264,6 +380,36 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
264
380
|
await expectAllowed(S2, 'widened: calendar events list', joinSession.authToken, 'get', '/calendar-events')
|
|
265
381
|
await expectAllowed(S2, 'widened: join meeting for event', joinSession.authToken, 'post', '/join-meeting-for-event', { calendarEventId: joinEvent.id })
|
|
266
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
|
+
|
|
267
413
|
// confinement must survive a refresh rather than widening
|
|
268
414
|
const refreshedJoin = await request(joinSession.authToken, 'post', '/refresh-enduser-session', {})
|
|
269
415
|
const refreshedJoinToken = refreshedJoin.body?.authToken
|
|
@@ -398,6 +544,73 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
398
544
|
await expectAllowed(S6, 'after refresh: my-meetings still allowed', rescopedToken, 'get', '/my-meetings')
|
|
399
545
|
}
|
|
400
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
|
+
|
|
401
614
|
/* ============ S5: ordinary sessions with no allowedPaths claim ============ */
|
|
402
615
|
|
|
403
616
|
const S5 = 'S5 unconfined sessions'
|
|
@@ -479,6 +692,8 @@ export const allowed_paths_confinement_tests = async ({ sdk } : { sdk: Session,
|
|
|
479
692
|
} catch {}
|
|
480
693
|
}
|
|
481
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 {} }
|
|
482
697
|
if (productId) { try { await sdk.api.products.deleteOne(productId) } catch {} }
|
|
483
698
|
if (formId) { try { await sdk.api.forms.deleteOne(formId) } catch {} }
|
|
484
699
|
}
|