@tellescope/sdk 1.255.19 → 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.
Files changed (82) hide show
  1. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  2. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  3. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  4. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  5. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  6. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  7. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  8. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  9. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  10. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  11. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  12. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  13. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  14. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  15. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  16. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  17. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  18. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  19. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  20. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  21. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  22. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  23. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  24. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  25. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  27. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  28. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
  29. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
  30. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
  31. package/lib/cjs/tests/tests.d.ts.map +1 -1
  32. package/lib/cjs/tests/tests.js +431 -230
  33. package/lib/cjs/tests/tests.js.map +1 -1
  34. package/lib/esm/enduser.d.ts +0 -1
  35. package/lib/esm/enduser.d.ts.map +1 -1
  36. package/lib/esm/sdk.d.ts +2 -3
  37. package/lib/esm/sdk.d.ts.map +1 -1
  38. package/lib/esm/session.d.ts +0 -1
  39. package/lib/esm/session.d.ts.map +1 -1
  40. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  41. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  42. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  43. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  44. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  45. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  46. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  47. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  48. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  49. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  50. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  51. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  52. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  53. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  54. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  55. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  56. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  57. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  58. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  59. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  60. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  61. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  62. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  63. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  64. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  65. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  66. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  67. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -1
  68. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +524 -294
  69. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -1
  70. package/lib/esm/tests/tests.d.ts.map +1 -1
  71. package/lib/esm/tests/tests.js +431 -230
  72. package/lib/esm/tests/tests.js.map +1 -1
  73. package/lib/tsconfig.tsbuildinfo +1 -1
  74. package/package.json +10 -10
  75. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  76. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  77. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  78. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  79. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  80. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +215 -0
  81. package/src/tests/tests.ts +212 -1
  82. package/test_generated.pdf +0 -0
@@ -0,0 +1,215 @@
1
+ import { DEFAULT_IFRAME_SANDBOX, replace_form_field_template_values, sanitize_user_html, sanitize_user_html_with_iframes } from "@tellescope/utilities"
2
+
3
+ // Companion to F-0013-sanitize-user-html.test.ts (pattern 06 — XSS via dangerouslySetInnerHTML).
4
+ //
5
+ // sanitize_user_html_with_iframes is the variant used by the live form's field description, so
6
+ // admins can embed video / scheduling widgets. It permits <iframe> and NOTHING else beyond what
7
+ // sanitize_user_html allows. This asserts:
8
+ // 1. permitting iframe did not reopen any of the F-0013 vectors (the full payload list is re-run)
9
+ // 2. only absolute https iframes survive — relative, protocol-relative, http, javascript:, data:
10
+ // and srcdoc frames are dropped, along with their fallback content
11
+ // 3. the restrictive sandbox is forced, but an author-supplied sandbox is honored
12
+ // 4. sanitize_user_html itself still strips iframes (the boundary between the two functions)
13
+ //
14
+ // Pure-function test — no Session needed. Runs as part of the main suite and standalone:
15
+ // ./build_cjs.sh && cd packages/public/sdk && node -r dotenv/config lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js
16
+
17
+ const fail = (msg: string) => { throw new Error(msg) }
18
+
19
+ // Same as has_no_executable_vector in the F-0013 test, minus the '<iframe' clause (iframes are
20
+ // expected in this function's output). Everything else must still be neutralized.
21
+ const has_no_executable_vector_allowing_iframe = (out: string) => {
22
+ const o = out.toLowerCase()
23
+ const withoutValues = o.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''")
24
+ return !/\son[a-z]+\s*=/.test(withoutValues) // no live on*= event-handler attribute
25
+ && !o.includes('javascript:')
26
+ && !o.includes('vbscript:')
27
+ && !o.includes('<script')
28
+ && !o.includes('<svg')
29
+ && !o.includes('<math')
30
+ && !o.includes('<object')
31
+ && !o.includes('<embed')
32
+ && !o.includes('<form')
33
+ && !o.includes('<noscript')
34
+ && !o.includes('<template')
35
+ && !o.includes('srcdoc') // an inline document is arbitrary markup
36
+ }
37
+
38
+ // Tokens that must NEVER appear in the forced default. allow-top-navigation is the important one:
39
+ // it is what stops a hostile embed from redirecting the patient's form page to a phishing site.
40
+ const FORBIDDEN_SANDBOX_TOKENS = [
41
+ 'allow-top-navigation',
42
+ 'allow-downloads',
43
+ 'allow-modals',
44
+ 'allow-pointer-lock',
45
+ 'allow-orientation-lock',
46
+ ]
47
+
48
+ export const sanitize_user_html_with_iframes_tests = async () => {
49
+ console.log("Running sanitize_user_html_with_iframes XSS regression tests")
50
+
51
+ // ---------------------------------------------------------------------------------------------
52
+ // 1. Re-run the full F-0013 payload set — permitting iframe must not be a general regression
53
+ // ---------------------------------------------------------------------------------------------
54
+ const xssPayloads: [string, string][] = [
55
+ ['img onerror', `<img src=x onerror="alert(document.domain)">`],
56
+ ['svg onload', `<svg onload="alert(1)"></svg>`],
57
+ ['svg animate onbegin', `<svg><animate onbegin="alert(1)" attributeName="x" dur="1s"></svg>`],
58
+ ['details ontoggle', `<details open ontoggle="alert(1)"></details>`],
59
+ ['input onfocus autofocus', `<input autofocus onfocus="alert(1)">`],
60
+ ['body onpageshow', `<body onpageshow="alert(1)">`],
61
+ ['a javascript scheme', `<a href="javascript:alert(1)">x</a>`],
62
+ ['a javascript entity-encoded', `<a href="jav&#x09;ascript:alert(1)">x</a>`],
63
+ ['script tag', `<script>alert(1)</script>`],
64
+ ['onerror newline before =', `<img src=x onerror\n="alert(1)">`],
65
+ ['onerror mixed case', `<IMG SRC=x OnErRoR="alert(1)">`],
66
+ ['marquee onstart', `<marquee onstart="alert(1)">x</marquee>`],
67
+ ['mathml mglyph style mxss', `<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>`],
68
+ ['svg foreignObject', `<svg><foreignObject><img src=x onerror=alert(1)></foreignObject></svg>`],
69
+ ['noscript context confusion', `<noscript><p title="</noscript><img src=x onerror=alert(1)>">`],
70
+ ['template content', `<template><img src=x onerror=alert(1)></template>`],
71
+ ['comment confusion', `<!--><img src=x onerror=alert(1)>-->`],
72
+ ['cdata confusion', `<![CDATA[<img src=x onerror=alert(1)>]]>`],
73
+ ['markup inside attr value', `<img src="x" alt="<script>alert(1)</script>">`],
74
+ ['vbscript scheme', `<a href="vbscript:msgbox(1)">x</a>`],
75
+ ['data text/html href', `<a href="data:text/html,<script>alert(1)</script>">x</a>`],
76
+ ['javascript decimal entity', `<a href="&#74;avascript:alert(1)">x</a>`],
77
+ ['javascript newline entity', `<a href="jav&#x0A;ascript:alert(1)">x</a>`],
78
+ ]
79
+ for (const [name, payload] of xssPayloads) {
80
+ const out = sanitize_user_html_with_iframes(payload)
81
+ if (!has_no_executable_vector_allowing_iframe(out)) fail(`XSS not neutralized [${name}] -> ${out}`)
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------------------------
85
+ // 2. Only absolute https iframes survive
86
+ // ---------------------------------------------------------------------------------------------
87
+ const badSrcs: [string, string][] = [
88
+ ['javascript scheme', `javascript:alert(1)`],
89
+ ['data text/html', `data:text/html,<script>alert(1)</script>`],
90
+ ['http (not https)', `http://evil.example`],
91
+ // sanitize-html does NOT reject protocol-relative iframe srcs on its own when
92
+ // allowProtocolRelative is set — this is the self-origin framing vector and the reason the
93
+ // sanitizer does its own absolute-https check.
94
+ ['protocol-relative', `//app.tellescope.com/`],
95
+ ['root-relative', `/portal/settings`],
96
+ ['bare relative', `settings`],
97
+ ['whitespace-obfuscated', ` https:/\\/\\evil`],
98
+ ['tab-entity javascript', `jav&#x09;ascript:alert(1)`],
99
+ ['scheme-only', `https://`],
100
+ ['empty', ``],
101
+ ]
102
+ for (const [name, src] of badSrcs) {
103
+ const out = sanitize_user_html_with_iframes(`<iframe src="${src}"></iframe>`)
104
+ if (out.toLowerCase().includes('<iframe')) fail(`bad iframe src survived [${name}] -> ${out}`)
105
+ }
106
+
107
+ // A dropped iframe must take its fallback content with it (exclusiveFilter, not a tagName swap)
108
+ const fallback = sanitize_user_html_with_iframes(`<iframe src="/x">FALLBACK-TEXT</iframe>`)
109
+ if (fallback.includes('FALLBACK-TEXT')) fail(`dropped iframe leaked its fallback text: ${fallback}`)
110
+ if (fallback.toLowerCase().includes('<iframe')) fail(`dropped iframe survived: ${fallback}`)
111
+
112
+ // srcdoc is not allowlisted, even alongside a valid src
113
+ const srcdoc = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" srcdoc="<img src=x onerror=alert(1)>"></iframe>`)
114
+ if (srcdoc.toLowerCase().includes('srcdoc')) fail(`srcdoc not stripped: ${srcdoc}`)
115
+ if (!srcdoc.toLowerCase().includes('<iframe')) fail(`valid iframe dropped alongside srcdoc: ${srcdoc}`)
116
+
117
+ // handlers / clobbering attributes stripped, frame itself kept
118
+ const handlers = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" onload="alert(1)" id="x" name="getElementById"></iframe>`)
119
+ if (!handlers.toLowerCase().includes('<iframe')) fail(`valid iframe dropped: ${handlers}`)
120
+ if (/\son[a-z]+\s*=/.test(handlers.toLowerCase())) fail(`iframe event handler survived: ${handlers}`)
121
+ if (/\b(id|name)\s*=/.test(handlers)) fail(`iframe id/name not stripped (DOM clobbering): ${handlers}`)
122
+
123
+ // ---------------------------------------------------------------------------------------------
124
+ // 3. Legitimate embeds survive, hardened
125
+ // ---------------------------------------------------------------------------------------------
126
+ // The exact shape draftjs-to-html emits for the WYSIWYG "embedded" button
127
+ const yt = sanitize_user_html_with_iframes(`<iframe width="560" height="315" src="https://www.youtube.com/embed/abc" frameBorder="0"></iframe>`)
128
+ if (!yt.includes('src="https://www.youtube.com/embed/abc"')) fail(`youtube embed src stripped: ${yt}`)
129
+ if (!(yt.includes('width="560"') && yt.includes('height="315"'))) fail(`iframe dimensions stripped: ${yt}`)
130
+ if (!yt.toLowerCase().includes('frameborder')) fail(`frameborder stripped: ${yt}`)
131
+
132
+ // forced containment
133
+ if (!yt.includes(`sandbox="${DEFAULT_IFRAME_SANDBOX}"`)) fail(`sandbox not forced: ${yt}`)
134
+ if (!yt.toLowerCase().includes('referrerpolicy=')) fail(`referrerpolicy not set: ${yt}`)
135
+ for (const token of FORBIDDEN_SANDBOX_TOKENS) {
136
+ if (DEFAULT_IFRAME_SANDBOX.includes(token)) fail(`forced sandbox must not grant ${token}: ${DEFAULT_IFRAME_SANDBOX}`)
137
+ }
138
+ // allow-same-origin IS granted — required for real embeds (a Loom embed renders blank without it)
139
+ // and harmless because the src is guaranteed cross-origin by the check asserted above.
140
+ if (!DEFAULT_IFRAME_SANDBOX.includes('allow-same-origin')) fail(`forced sandbox must grant allow-same-origin so real embeds render: ${DEFAULT_IFRAME_SANDBOX}`)
141
+
142
+ // author-supplied sandbox honored verbatim, so a surface can tighten (or loosen) per embed.
143
+ // Uses a value distinct from the default, so this can't pass by coincidence.
144
+ const authorSandbox = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" sandbox="allow-scripts"></iframe>`)
145
+ if (!authorSandbox.includes('sandbox="allow-scripts"')) fail(`author sandbox not honored: ${authorSandbox}`)
146
+
147
+ // sandbox="" is the MOST restrictive value and must not be replaced by the default
148
+ const emptySandbox = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" sandbox=""></iframe>`)
149
+ if (emptySandbox.includes('allow-')) fail(`empty sandbox was replaced with the permissive default: ${emptySandbox}`)
150
+ if (!/\ssandbox(\s|>)/.test(emptySandbox)) fail(`empty sandbox attribute lost entirely: ${emptySandbox}`)
151
+
152
+ // author referrerpolicy honored
153
+ const noref = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" referrerpolicy="no-referrer"></iframe>`)
154
+ if (!noref.includes('referrerpolicy="no-referrer"')) fail(`author referrerpolicy not honored: ${noref}`)
155
+
156
+ // `allow` (Permissions-Policy delegation, e.g. YouTube autoplay) preserved
157
+ const allowAttr = sanitize_user_html_with_iframes(`<iframe src="https://ok.example" allow="autoplay; encrypted-media" allowfullscreen></iframe>`)
158
+ if (!allowAttr.includes('allow="autoplay; encrypted-media"')) fail(`allow attribute stripped: ${allowAttr}`)
159
+
160
+ // ---------------------------------------------------------------------------------------------
161
+ // 4. Everything sanitize_user_html preserves must still be preserved
162
+ // ---------------------------------------------------------------------------------------------
163
+ const heading = sanitize_user_html_with_iframes(`<h1>Welcome</h1><h3 style="color:#333">Sub</h3>`)
164
+ if (!(heading.includes('<h1>') && heading.includes('<h3') && heading.toLowerCase().includes('color'))) fail(`headings/style stripped: ${heading}`)
165
+
166
+ const table = sanitize_user_html_with_iframes(`<table><thead><tr><th>H</th></tr></thead><tbody><tr><td style="padding:4px" colspan="2">cell</td></tr></tbody></table>`)
167
+ if (!(table.includes('<table') && table.includes('<td') && table.includes('colspan'))) fail(`table stripped: ${table}`)
168
+
169
+ const list = sanitize_user_html_with_iframes(`<ul><li>a</li></ul><ol start="3"><li>c</li></ol>`)
170
+ if (!(list.includes('<ul') && list.includes('<li') && list.includes('<ol'))) fail(`list stripped: ${list}`)
171
+
172
+ const link = sanitize_user_html_with_iframes(`<a href="https://example.com">link</a>`)
173
+ if (!link.includes('href="https://example.com"')) fail(`safe link stripped: ${link}`)
174
+ if (!link.toLowerCase().includes('noopener')) fail(`external link not hardened: ${link}`)
175
+
176
+ const img = sanitize_user_html_with_iframes(`<img src="https://cdn.example.com/a.png" alt="pic" width="200">`)
177
+ if (!(img.includes('src="https://cdn.example.com/a.png"') && img.includes('alt="pic"'))) fail(`image stripped: ${img}`)
178
+
179
+ const dataimg = sanitize_user_html_with_iframes(`<img src="data:image/png;base64,iVBORw0KGgo=">`)
180
+ if (!dataimg.includes('data:image/png')) fail(`data: image stripped: ${dataimg}`)
181
+
182
+ const fmt = sanitize_user_html_with_iframes(`<p><strong>b</strong> <em>i</em> <span style="font-size:14px">s</span></p><blockquote>q</blockquote>`)
183
+ if (!(fmt.includes('<strong>') && fmt.includes('<span') && fmt.toLowerCase().includes('font-size'))) fail(`formatting stripped: ${fmt}`)
184
+
185
+ // ---------------------------------------------------------------------------------------------
186
+ // 5. The boundary: sanitize_user_html must NOT have been loosened
187
+ // ---------------------------------------------------------------------------------------------
188
+ const shared = sanitize_user_html(`<iframe src="https://www.youtube.com/embed/abc"></iframe>`)
189
+ if (shared.toLowerCase().includes('<iframe')) fail(`sanitize_user_html must still strip iframes: ${shared}`)
190
+
191
+ // ---------------------------------------------------------------------------------------------
192
+ // 6. End to end: an admin's iframe survives templating, a patient-injected one does not
193
+ // ---------------------------------------------------------------------------------------------
194
+ const adminTemplate = `<p>Hi {{enduser.fname}}, watch this:</p><iframe src="https://www.youtube.com/embed/abc"></iframe>`
195
+ const templated = replace_form_field_template_values(adminTemplate, {
196
+ enduser: { fname: `<iframe src="https://evil.example/phish"></iframe>` } as any,
197
+ responses: [],
198
+ escapeNewlinesAsHTMLBreaks: true,
199
+ escapeHTMLValues: true,
200
+ })
201
+ const endToEnd = sanitize_user_html_with_iframes(templated)
202
+ if (!endToEnd.includes('src="https://www.youtube.com/embed/abc"')) fail(`admin's own iframe did not survive: ${endToEnd}`)
203
+ // The patient's markup must survive only as inert escaped text — so 'evil.example' may still
204
+ // appear as visible characters, but there must be exactly one LIVE frame (the admin's).
205
+ if (!endToEnd.includes('&lt;iframe')) fail(`patient markup not escaped to inert text: ${endToEnd}`)
206
+ if ((endToEnd.toLowerCase().match(/<iframe/g) || []).length !== 1) fail(`patient-injected iframe became a live frame: ${endToEnd}`)
207
+
208
+ console.log("✅ sanitize_user_html_with_iframes XSS regression tests passed")
209
+ }
210
+
211
+ if (require.main === module) {
212
+ sanitize_user_html_with_iframes_tests()
213
+ .then(() => { console.log("✅ suite completed"); process.exit(0) })
214
+ .catch((err) => { console.error("❌ suite failed:", err); process.exit(1) })
215
+ }
@@ -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
  }