@tellescope/sdk 1.256.15 → 1.256.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellescope/sdk",
3
- "version": "1.256.15",
3
+ "version": "1.256.16",
4
4
  "description": "Code for interacting with the Tellescope API",
5
5
  "main": "./lib/cjs/sdk.js",
6
6
  "module": "./lib/esm/sdk.js",
@@ -32,14 +32,14 @@
32
32
  },
33
33
  "homepage": "https://github.com/tellescope-os/tellescope#readme",
34
34
  "dependencies": {
35
- "@tellescope/constants": "1.256.15",
36
- "@tellescope/schema": "1.256.15",
37
- "@tellescope/testing": "1.256.15",
38
- "@tellescope/types-client": "1.256.15",
39
- "@tellescope/types-models": "1.256.15",
40
- "@tellescope/types-utilities": "1.256.15",
41
- "@tellescope/utilities": "1.256.15",
42
- "@tellescope/validation": "1.256.15",
35
+ "@tellescope/constants": "1.256.16",
36
+ "@tellescope/schema": "1.256.16",
37
+ "@tellescope/testing": "1.256.16",
38
+ "@tellescope/types-client": "1.256.16",
39
+ "@tellescope/types-models": "1.256.16",
40
+ "@tellescope/types-utilities": "1.256.16",
41
+ "@tellescope/utilities": "1.256.16",
42
+ "@tellescope/validation": "1.256.16",
43
43
  "axios": "0.30.3",
44
44
  "dotenv": "14.3.2",
45
45
  "form-data": "4.0.4",
@@ -55,5 +55,5 @@
55
55
  "publishConfig": {
56
56
  "access": "public"
57
57
  },
58
- "gitHead": "180f82301125bdd5e73608991061e49ed3d41e96"
58
+ "gitHead": "5874f0e4ca75d8ce70768a5933cd5bb5b3b15c58"
59
59
  }
@@ -0,0 +1,291 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+
5
+ import {
6
+ assert_checks,
7
+ Check,
8
+ setup_isolated_tenant,
9
+ } from "../../setup_isolated"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ const RAND = () => Math.random().toString(36).slice(2, 10)
14
+
15
+ /**
16
+ * The `{{files.<id>.link}}` merge field must mint a session that can download ONE file, and nothing
17
+ * else.
18
+ *
19
+ * That merge field is how the webapp's "Generate PDF -> Save and Share -> Email" flow on the enduser
20
+ * timeline (DownloadFormResponse.tsx, ComposeSharePDFEmail) delivers a document to a hand-typed
21
+ * address — a referral contact, a caregiver, an outside provider. The link is resolved at send time
22
+ * by message_templating.ts, which called login_enduser with no confinement at all, so the recipient
23
+ * received a full 24-hour patient-portal session: chat history, every form response, every file,
24
+ * observations, medications, and every write route including PATCH /enduser/:id.
25
+ *
26
+ * Route access was not the only leak. Both portals call refresh-enduser-session during URL-token
27
+ * intake, and for a session not flagged `fromPublicSession` that handler returns the whole patient
28
+ * document (name, dateOfBirth, email, phone, insurance, fields, notes) — so the chart was handed
29
+ * over on page load, before a single route was touched. Probe 7b covers that.
30
+ *
31
+ * Two variants, because one bit — is the recipient the patient? — drives two behaviours the merge
32
+ * field previously could not express:
33
+ *
34
+ * `link` goes to the patient. Keeps the org's MFA policy, keeps OTP recovery.
35
+ * `shared-link` goes to someone who is NOT the patient. An OTP code is delivered to the PATIENT's
36
+ * own email or phone, so for this variant recovery is both unanswerable by the
37
+ * holder and a way to make the platform message the patient on demand. It is
38
+ * refused, and the token is minted bypassMFA so it never needs a challenge either.
39
+ *
40
+ * Probe 12 is the one that decides whether the OTP rule is real: the three OTP endpoints read their
41
+ * `token` argument with `jwtDecode`, which does not verify the signature. The file link puts a
42
+ * decodable JWT in the recipient's URL bar, so they know the enduser id, and a scope name in an
43
+ * unverified token is a suggestion rather than a claim. Without signature verification the rule is
44
+ * a paper wall.
45
+ *
46
+ * Every confinement probe is paired against the tenant's own admin session, so a red check means
47
+ * confinement rather than a broken fixture.
48
+ */
49
+
50
+ type Probe = { status: number, body: any }
51
+
52
+ const probe = async (
53
+ method: 'get' | 'post' | 'patch',
54
+ path: string,
55
+ o: { token?: string, params?: object, data?: object } = {},
56
+ ): Promise<Probe> => {
57
+ const r = await axios.request({
58
+ method,
59
+ url: `${host}/v1${path}`,
60
+ params: o.params,
61
+ data: o.data,
62
+ headers: o.token ? { Authorization: `Bearer ${o.token}` } : undefined,
63
+ validateStatus: () => true,
64
+ })
65
+ return { status: r.status, body: r.data }
66
+ }
67
+
68
+ export const file_link_session_confinement_checks = async (): Promise<Check[]> => {
69
+ const checks: Check[] = []
70
+ const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
71
+
72
+ let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
73
+
74
+ try {
75
+ tenant = await setup_isolated_tenant({
76
+ name: `FileLink ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0,
77
+ })
78
+ const sdk = tenant.sdk
79
+
80
+ // dontsend- so the OTP probes cannot deliver anything real, same convention the provisioner uses
81
+ const enduser = await sdk.api.endusers.createOne({
82
+ fname: 'FileLink', lname: 'Recipient',
83
+ email: `dontsend-filelink-${RAND()}@tellescope.com`,
84
+ phone: '+15555550100',
85
+ dateOfBirth: '01-01-1990',
86
+ })
87
+
88
+ // No S3 upload needed: prepare_file_upload inserts the files record with its secureName
89
+ // immediately, and file_download_URL only SIGNS a GET url — it never checks the object exists.
90
+ const newFile = async (name: string) => (
91
+ (await sdk.api.files.prepare_file_upload({
92
+ name, type: 'application/pdf', size: 1000, enduserId: enduser.id,
93
+ })).file
94
+ )
95
+ const fileA = await newFile(`shared-${RAND()}.pdf`) // the one that gets shared
96
+ const fileB = await newFile(`private-${RAND()}.pdf`) // the rest of the patient's chart
97
+
98
+ /**
99
+ * Mints a token exactly the way a real send does — get_templated_message is the endpoint the
100
+ * worker calls (worker/app.js handleOutgoingEmail).
101
+ *
102
+ * replaceLinks always rewrites the resolved link into a tracking redirect, so `plaintext` holds
103
+ * `doc (${API_URL}/r/<redirectToken>)` rather than the portal URL. Follow the 302 to recover it.
104
+ */
105
+ const mint = async (verb: 'link' | 'shared-link') => {
106
+ const { plaintext } = await sdk.api.templates.get_templated_message({
107
+ channel: 'Email',
108
+ userId: tenant!.users[0].id,
109
+ enduserId: enduser.id,
110
+ message: `{{files.${fileA.id}.${verb}:doc}}`,
111
+ })
112
+
113
+ const redirectURL = plaintext.match(/\((https?:\/\/[^)\s]+)\)/)?.[1]
114
+ if (!redirectURL) return { token: undefined, detail: `no link resolved from: ${plaintext}` }
115
+
116
+ const r = await axios.get(redirectURL, { maxRedirects: 0, validateStatus: () => true })
117
+ const location = r.headers['location']
118
+ if (!location) return { token: undefined, detail: `redirect did not 302 (${r.status})` }
119
+
120
+ const token = new URL(location).searchParams.get('token') ?? undefined
121
+ return { token, detail: token ? 'minted' : `no token in ${location}` }
122
+ }
123
+
124
+ /* ================================ confinement + the pin ================================ */
125
+
126
+ // Positive control first: the admin session reads both files fine, so a 403 below is confinement.
127
+ const adminFiles = await probe('get', '/files', { token: tenant.users[0].authToken })
128
+ record(
129
+ 'control: admin session lists files',
130
+ adminFiles.status === 200, `status ${adminFiles.status}`,
131
+ )
132
+
133
+ const confinement_probes = async (variant: 'link' | 'shared-link', token: string) => {
134
+ const p = (label: string, expected: number, r: Probe) => record(
135
+ `${variant} — ${label}`, r.status === expected, `status ${r.status} (expected ${expected})`,
136
+ )
137
+
138
+ p('1: downloads the shared file', 200,
139
+ await probe('get', '/file-download-URL', { token, params: { secureName: fileA.secureName } }))
140
+
141
+ p('2: cannot download another file on the same patient', 403,
142
+ await probe('get', '/file-download-URL', { token, params: { secureName: fileB.secureName } }))
143
+
144
+ // validateInput merges query, then body, then params — body wins over query, so a pin checked
145
+ // against req.query rather than req.validated would be sidesteppable exactly like this.
146
+ p('3: cannot smuggle another secureName through the body', 403,
147
+ await probe('get', '/file-download-URL', {
148
+ token, params: { secureName: fileA.secureName }, data: { secureName: fileB.secureName },
149
+ }))
150
+
151
+ // a listing would turn the pin into a directory of the patient's chart
152
+ p('4: cannot list files', 403, await probe('get', '/files', { token }))
153
+
154
+ p('5a: cannot read chats', 403, await probe('get', '/chats', { token }))
155
+ p('5b: cannot read form responses', 403, await probe('get', '/form-responses', { token }))
156
+ p('5c: cannot read observations', 403, await probe('get', '/enduser-observations', { token }))
157
+
158
+ p('6: cannot update the patient', 403,
159
+ await probe('patch', `/enduser/${enduser.id}`, { token, data: { updates: { fname: 'Overwritten' } } }))
160
+
161
+ const refreshed = await probe('post', '/refresh-enduser-session', { token })
162
+ p('7: can refresh its own session', 200, refreshed)
163
+
164
+ // 7b — the refresh payload must be the fromPublicSession shape, not the whole chart
165
+ const leaked = ['dateOfBirth', 'email', 'phone', 'fields', 'insurance', 'lname']
166
+ .filter(f => (refreshed.body?.enduser ?? {})[f] !== undefined)
167
+ record(
168
+ `${variant} — 7b: refresh response withholds patient fields`,
169
+ refreshed.status === 200 && leaked.length === 0,
170
+ leaked.length ? `leaked: ${leaked.join(', ')}` : 'no patient fields in payload',
171
+ )
172
+
173
+ // 8 — confinement has to survive the re-mint, or one refresh launders it away
174
+ const refreshedToken = refreshed.body?.authToken
175
+ if (typeof refreshedToken === 'string' && refreshedToken) {
176
+ p('8a: refreshed token still cannot download another file', 403,
177
+ await probe('get', '/file-download-URL', {
178
+ token: refreshedToken, params: { secureName: fileB.secureName },
179
+ }))
180
+ p('8b: refreshed token still cannot update the patient', 403,
181
+ await probe('patch', `/enduser/${enduser.id}`, {
182
+ token: refreshedToken, data: { updates: { fname: 'Overwritten' } },
183
+ }))
184
+ } else {
185
+ record(`${variant} — 8: refreshed token available`, false, 'refresh returned no authToken')
186
+ }
187
+ }
188
+
189
+ const plain = await mint('link')
190
+ if (plain.token) {
191
+ await confinement_probes('link', plain.token)
192
+ } else {
193
+ record('link — minted', false, plain.detail)
194
+ }
195
+
196
+ const shared = await mint('shared-link')
197
+ if (shared.token) {
198
+ await confinement_probes('shared-link', shared.token)
199
+ } else {
200
+ // Before the shared-link verb exists the merge field does not resolve. Record it and skip the
201
+ // shared probes rather than throwing, so the first run reports the whole picture at once.
202
+ record('shared-link — minted', false, shared.detail)
203
+ }
204
+
205
+ /* ========================================= OTP ========================================= */
206
+
207
+ if (shared.token) {
208
+ const methods = await probe('get', '/endusers/otp-methods', { params: { token: shared.token } })
209
+ record(
210
+ 'shared-link — 9: OTP methods are not offered',
211
+ methods.status === 403, `status ${methods.status} (expected 403)`,
212
+ )
213
+
214
+ const send = await probe('post', '/endusers/send-otp-code', {
215
+ data: { token: shared.token, method: 'email' },
216
+ })
217
+ record(
218
+ 'shared-link — 10: cannot make us send the patient a login code',
219
+ send.status === 403, `status ${send.status} (expected 403)`,
220
+ )
221
+
222
+ // rejected on scope BEFORE the code lookup, so it is not an oracle on code validity
223
+ const verify = await probe('post', '/endusers/verify-otp-code', {
224
+ data: { token: shared.token, code: '000000' },
225
+ })
226
+ record(
227
+ 'shared-link — 11: OTP verification refuses the scope before checking the code',
228
+ verify.status === 403, `status ${verify.status} (expected 403, 400 = reached the code check)`,
229
+ )
230
+
231
+ // 12 — the same call with the scope claim stripped and the signature broken. The OTP endpoints
232
+ // jwtDecode without verifying, so without signature verification this sails straight through
233
+ // and every check above is decorative.
234
+ const [header, payload] = shared.token.split('.')
235
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString())
236
+ delete decoded.sessionScopes
237
+ delete decoded.scopeContext
238
+ const forged = [
239
+ header,
240
+ Buffer.from(JSON.stringify(decoded)).toString('base64url'),
241
+ 'not-a-real-signature',
242
+ ].join('.')
243
+
244
+ const forgedSend = await probe('post', '/endusers/send-otp-code', {
245
+ data: { token: forged, method: 'email' },
246
+ })
247
+ record(
248
+ 'shared-link — 12: a forged token cannot strip the scope to unlock OTP',
249
+ forgedSend.status === 403 || forgedSend.status === 401 || forgedSend.status === 404,
250
+ `status ${forgedSend.status} (expected 401/403/404; 204 = signature never verified)`,
251
+ )
252
+ }
253
+
254
+ if (plain.token) {
255
+ // 13 — the patient-facing variant must keep its recovery path. Deliverable to a dontsend-
256
+ // address, so nothing leaves the system.
257
+ const send = await probe('post', '/endusers/send-otp-code', {
258
+ data: { token: plain.token, method: 'email' },
259
+ })
260
+ record(
261
+ 'link — 13: patient OTP recovery still works',
262
+ send.status === 204, `status ${send.status} (expected 204)`,
263
+ )
264
+ }
265
+ } finally {
266
+ // Teardown BEFORE anything asserts — assert calls process.exit, which skips pending finallys and
267
+ // would strand the provisioned tenant permanently.
268
+ if (tenant) { try { await tenant.teardown() } catch (e) { console.error('teardown failed', e) } }
269
+ }
270
+
271
+ return checks
272
+ }
273
+
274
+ /** Asserting wrapper, for standalone runs and for sequential use in the suite. */
275
+ export const file_link_session_confinement_tests = async () => {
276
+ assert_checks('Shared file link session confinement', await file_link_session_confinement_checks())
277
+ }
278
+
279
+ // Allow running this test file independently
280
+ if (require.main === module) {
281
+ console.log(`🌐 Using API URL: ${host}`)
282
+ file_link_session_confinement_tests()
283
+ .then(() => {
284
+ console.log("✅ file link session confinement test suite completed successfully")
285
+ process.exit(0)
286
+ })
287
+ .catch((error) => {
288
+ console.error("❌ file link session confinement test suite failed:", error)
289
+ process.exit(1)
290
+ })
291
+ }
@@ -163,6 +163,7 @@ import { organization_signup_checks } from "./api_tests/organization_signup.test
163
163
  import { add_to_journey_foreign_enduserid_checks } from "./api_tests/security/add-to-journey-foreign-enduserid.test";
164
164
  import { tickets_bulk_assign_from_queue_checks } from "./api_tests/tickets_bulk_assign_from_queue.test";
165
165
  import { enduser_observations_auto_review_checks } from "./api_tests/enduser_observations_auto_review.test";
166
+ import { file_link_session_confinement_checks } from "./api_tests/security/file-link-session-confinement.test";
166
167
  import { run_isolated_in_parallel } from "./setup_isolated";
167
168
  import { elation_user_id_tests } from "./api_tests/elation_user_id.test";
168
169
  import { organization_settings_duplicates_tests } from "./api_tests/organization_settings_duplicates.test";
@@ -15405,6 +15406,7 @@ const ip_address_form_tests = async () => {
15405
15406
  { name: 'Tickets bulk assign from queue', run: tickets_bulk_assign_from_queue_checks },
15406
15407
  { name: 'Vital auto-review on create', run: enduser_observations_auto_review_checks },
15407
15408
  { name: '$JS in stored conditional logic', run: form_logic_js_execution_checks },
15409
+ { name: 'Shared file link session confinement', run: file_link_session_confinement_checks },
15408
15410
  ])
15409
15411
  await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
15410
15412
  await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
Binary file