@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.
Files changed (165) hide show
  1. package/lib/cjs/sdk.d.ts +7 -2
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  7. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  10. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  16. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  18. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
  19. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  21. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  22. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  23. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  24. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  25. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  26. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  27. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  28. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
  29. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  30. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  31. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
  32. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  33. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  34. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  36. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  37. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  38. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  39. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  40. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  41. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  42. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  43. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  44. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  45. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  46. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  47. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  48. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  49. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  50. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
  51. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  52. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  53. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  54. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
  55. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  56. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  57. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  58. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
  59. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  60. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  61. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  62. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
  63. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  64. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  65. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  66. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
  67. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  68. package/lib/cjs/tests/tests.d.ts.map +1 -1
  69. package/lib/cjs/tests/tests.js +489 -255
  70. package/lib/cjs/tests/tests.js.map +1 -1
  71. package/lib/esm/enduser.d.ts +0 -1
  72. package/lib/esm/enduser.d.ts.map +1 -1
  73. package/lib/esm/sdk.d.ts +9 -5
  74. package/lib/esm/sdk.d.ts.map +1 -1
  75. package/lib/esm/sdk.js +1 -0
  76. package/lib/esm/sdk.js.map +1 -1
  77. package/lib/esm/session.d.ts +0 -1
  78. package/lib/esm/session.d.ts.map +1 -1
  79. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  80. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  81. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  82. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  83. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  84. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  85. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  87. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  88. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  89. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  90. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  91. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  92. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
  93. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  94. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  95. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  96. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  97. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  98. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  99. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  100. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  101. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  102. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
  103. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  104. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  105. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
  106. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  107. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  108. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  109. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  110. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  111. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  112. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  113. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  114. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  115. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  116. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  117. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  118. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  119. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  120. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  121. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  122. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  123. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  124. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
  125. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  126. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  127. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  128. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
  129. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  130. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  131. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  132. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
  133. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  134. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  135. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  136. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
  137. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  138. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  139. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  140. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
  141. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  142. package/lib/esm/tests/tests.d.ts.map +1 -1
  143. package/lib/esm/tests/tests.js +489 -255
  144. package/lib/esm/tests/tests.js.map +1 -1
  145. package/lib/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +10 -10
  147. package/src/sdk.ts +4 -0
  148. package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
  149. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  150. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  151. package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
  152. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  153. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  154. package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
  155. package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
  156. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  157. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
  158. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
  159. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
  160. package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
  161. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
  162. package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
  163. package/src/tests/tests.ts +232 -2
  164. package/test_generated.pdf +0 -0
  165. package/.env +0 -13
@@ -0,0 +1,1707 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session, EnduserSession } from "../../../sdk"
5
+ import {
6
+ assert,
7
+ log_header,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests, authenticate_enduser_via_token } from "../../setup"
10
+ import { Enduser } from "@tellescope/types-client"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ /**
15
+ * Regression test for: email / phone are locked against writes from public session-start endpoints.
16
+ *
17
+ * Two public, unauthenticated endpoints match an existing patient on a loose $or
18
+ * ({ email } OR { fname, lname, dateOfBirth }) and then update the matched record:
19
+ *
20
+ * A POST /v1/session-for-public-form
21
+ * B POST /v1/session-for-public-appointment-booking
22
+ *
23
+ * Both used to overwrite `email`, `phone` and `state` on the matched patient. `email` and `phone`
24
+ * are authentication channels, so that overwrite chains to account takeover:
25
+ *
26
+ * - email-less patients are common and cheap to create (sms_messages.leave_message is an
27
+ * unauthenticated enduser-creation primitive that writes only phone/fname/lname)
28
+ * - attacker submits a public form matching on fname + lname + dateOfBirth with their own email
29
+ * - POST /v1/request-enduser-password-reset resolves purely by findOne({ email }) -> mints a
30
+ * passwordReset token and mails the reset link to the attacker
31
+ * - reset_password sets hashedPassword; the attacker logs into the portal as the patient
32
+ *
33
+ * phone is equally load-bearing: begin_login_flow accepts email OR phone, and send_otp delivers
34
+ * login codes to enduser.phone. A "fill only if blank" fix does NOT close this — the email-less
35
+ * patient is precisely the common case, which is why the UNSET shapes below are the load-bearing
36
+ * rows of G1/G2.
37
+ *
38
+ * The rule under test: `email` and `phone` may be written only on the INSERT path. On a matched
39
+ * existing patient they are never written — not even when the stored value is blank.
40
+ *
41
+ * `state` deliberately still overwrites on match (G6 pins the status quo): it is not an auth
42
+ * channel, and moving it behind "if unset" would break a relocated patient booking with a
43
+ * provider licensed in their new state.
44
+ *
45
+ * Scenario groups (pre-fix expectation in parens):
46
+ * G1 (RED) A — overwrite blocked on match, 4 victim shapes (both set / email unset / phone unset / both unset)
47
+ * G2 (RED) B — same 4 shapes
48
+ * G3 (RED) A — overwrite blocked when the victim is targeted directly via the enduserId param
49
+ * G4 (RED) takeover chain: email-less victim -> overwrite via A -> password-reset resolvability
50
+ * G5 (GREEN) blank fname/lname/dateOfBirth/gender still filled; blank phone still NOT filled
51
+ * G6 (GREEN) state still overwrites on match (status-quo pin)
52
+ * G7 (GREEN) brand-new visitor still created WITH submitted email + phone
53
+ * G8 (GREEN) form submission completes end-to-end under the returned token
54
+ * G9 (GREEN) appointment availability / booking works under the returned session
55
+ * G10 (GREEN) skipMatch still inserts, and skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge
56
+ *
57
+ * SECOND WRITE PATH (G11-G18). Locking the endpoints is not sufficient on its own. `email` and
58
+ * `phone` are also UI-selectable form intakeField mappings, and save_enduser_updates_for_form_response
59
+ * (api/modules/forms.ts) copies those answers onto the enduser named by the form response — with no
60
+ * session argument and no only-if-blank guard. Two of its callers sit in the 'public-form' session scope
61
+ * (submit_form_response, and save_field_response for fields with options.saveIntakeOnPartial), so the
62
+ * whole chain above is reachable with the token the hardened endpoint itself mints: match the victim,
63
+ * get a session scoped to their id, then answer the form's Email question with the attacker address.
64
+ *
65
+ * The lock is scoped to "a public session that matched a PRE-EXISTING patient", not to every public
66
+ * session, because intakeEmailHidden / intakePhone: 'hidden' are supported configurations in which
67
+ * the intake question is the only source of a brand-new patient's contact info (G16).
68
+ *
69
+ * G11 (RED) intake answers do not overwrite a matched patient (submit path), 4 victim shapes
70
+ * G12 (RED) same via save_field_response + saveIntakeOnPartial, without ever submitting
71
+ * G13 (RED) full takeover chain driven through an intake answer
72
+ * G14 (RED) pivot: public session -> prepare_form_response for a NON-public form -> submit
73
+ * G15 (RED) save_field_response rejects another patient's formResponseId (accessCode path stays open)
74
+ * G16 (GREEN) brand-new visitor still gets email/phone from intake answers
75
+ * G17 (GREEN) authenticated (non-public) portal session can still self-update via intake
76
+ * G18 (GREEN) staff submitting on a patient's behalf can still set email/phone
77
+ *
78
+ * NULL-MATCH / COLD START (G19-G22). In both handlers' $or, `email` and `dateOfBirth` get `|| "never"`
79
+ * sentinels but fname/lname are interpolated RAW. `ignoreUndefined` is not set on the Mongo client and
80
+ * findOne does not strip undefined keys, so an absent fname serializes to `{fname: null}` — which
81
+ * matches every document where the field is null OR MISSING. A request supplying ONLY dateOfBirth can
82
+ * therefore match another patient and receive a session scoped to THEIR record. The cold-start pattern
83
+ * (a public form started with no intake info, which just creates an Enduser with an ID) is what
84
+ * manufactures those nameless endusers.
85
+ *
86
+ * G19 (RED) DOB-only request does not hijack a nameless patient — form endpoint
87
+ * G20 (RED) same — booking endpoint (no skipMatch, no phone guard, so reached unconditionally)
88
+ * G21 (GREEN) the match sentinel never leaks into a stored fname/lname
89
+ * G22 (GREEN) cold start still lets intake answers populate email/phone on the fresh record
90
+ *
91
+ * TENANT SCOPING (G23). addPublicEndpoint overwrites the schema's `required: true` for businessId with
92
+ * mongoIdStringOptional, and a falsy businessId leaves restrictAccess as the IDENTITY function in the
93
+ * query builders — the tenant clause is never appended, so the handler queries every tenant. It fails
94
+ * OPEN. session_for_public_appointment_booking already guards this; session_for_public_form did not.
95
+ *
96
+ * G23 (RED) session_for_public_form rejects a missing or malformed businessId
97
+ *
98
+ * LOCK BYPASSES (G24-G26). The lock is defeated two ways, each needing more than one hop — which is
99
+ * why the single-hop groups above pass while the chain is still open:
100
+ * - prepare_form_response mints a token that preserves allowedPaths but DROPS fromPublicSession, so
101
+ * a second hop through the same endpoint produces an unlocked response. That flag also gates PHI
102
+ * on refresh_session, so the chained token additionally reads the victim's whole record.
103
+ * - the lock is a snapshot taken at session start; a later repoint of the response at a pre-existing
104
+ * patient leaves a stale `false` behind unless it also writes the flag. Two callers repoint:
105
+ * autoMergeOnSubmission (G26) and the staff endusers.merge handler (G27).
106
+ *
107
+ * G24 (RED) chained prepare_form_response session stays locked
108
+ * G25 (RED) chained session does not expose the enduser record on refresh
109
+ * G26 (RED) auto-merge does not let an intake answer repoint the merged patient
110
+ * G27 (RED) the staff endusers.merge repoint applies the lock too
111
+ *
112
+ * SCOPE BOUNDARY (G28). The formsort / form-ingestion webhook re-opens a blank email/phone for one
113
+ * hour after the Stripe integration creates a contact, because that endpoint is API-key
114
+ * authenticated. This anonymous path gets no such exemption.
115
+ *
116
+ * G28 a fresh Stripe-created patient gets no grace here
117
+ *
118
+ * STRUCTURE: record -> report -> cleanup -> assert.
119
+ * EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
120
+ * first failure. Two consequences shape this file:
121
+ * 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
122
+ * single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
123
+ * instead of dying on the first probe.
124
+ * 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
125
+ * loop; otherwise a by-design-failing run would leak every fixture into the next run.
126
+ */
127
+
128
+ const post = async (path: string, body: any) => {
129
+ try {
130
+ const res = await axios.post(`${host}${path}`, body, { validateStatus: () => true })
131
+ return { status: res.status, data: res.data as any }
132
+ } catch (err: any) {
133
+ return { status: err?.response?.status as number, data: err?.response?.data as any }
134
+ }
135
+ }
136
+
137
+ type ScenarioResult = { group: string, name: string, ok: boolean, skipped?: boolean, detail: string }
138
+
139
+ // Endpoint A's update is fire-and-forget (.catch(console.error), not awaited); B's is awaited.
140
+ // Reading the victim immediately after calling A would frequently show the un-overwritten value on
141
+ // a VULNERABLE server -> false pass. So poll for the attack landing: fails fast against vulnerable
142
+ // code, burns the full window against fixed code.
143
+ const OVERWRITE_WINDOW_MS = 3000
144
+ const AWAITED_WINDOW_MS = 1200
145
+ const POLL_INTERVAL_MS = 150
146
+
147
+ const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
148
+
149
+ export const public_endpoint_auth_field_lock_tests = async (
150
+ { sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
151
+ ) => {
152
+ log_header("Public Endpoint Auth Field Lock (email / phone)")
153
+
154
+ const businessId = sdk.userInfo.businessId
155
+ // Every identity carries a per-run token so the handlers' $or can only match our own records,
156
+ // and re-runs never collide.
157
+ const RUN = Date.now().toString(36)
158
+
159
+ const results: ScenarioResult[] = []
160
+ const record = (group: string, name: string, ok: boolean, detail = '') => {
161
+ results.push({ group, name, ok, detail })
162
+ console.log(` ${ok ? '✓' : '✗'} [${group}] ${name}${detail ? ` — ${detail}` : ''}`)
163
+ }
164
+ const skip = (group: string, name: string, detail: string) => {
165
+ results.push({ group, name, ok: true, skipped: true, detail })
166
+ console.log(` ⚠ SKIP [${group}] ${name} — ${detail}`)
167
+ }
168
+ // Converts a thrown exception into a recorded failure so one surprise doesn't abort the table.
169
+ const runScenario = async (group: string, name: string, fn: () => Promise<void>) => {
170
+ try {
171
+ await fn()
172
+ } catch (err: any) {
173
+ const message = err?.message ?? err?.error ?? JSON.stringify(err ?? {}).slice(0, 300)
174
+ record(group, name, false, `threw: ${message}`)
175
+ }
176
+ }
177
+
178
+ // phoneValidator normalizes to E.164, so generate +1555555xxxx values that are byte-comparable
179
+ // against what gets stored. 555-555-xxxx is the only 555 exchange libphonenumber accepts.
180
+ let phoneOffset = 0
181
+ const phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0')
182
+ const nextPhone = () => `+1555555${String((phoneSeed + (phoneOffset += 1)) % 10_000).padStart(4, '0')}`
183
+
184
+ // The API lowercases stored emails, so normalize here too — the overwrite predicates compare
185
+ // submitted and stored values directly.
186
+ const emailFor = (label: string) => `${label}-${RUN}@tellescope.com`.toLowerCase()
187
+
188
+ const createdEnduserIds: string[] = []
189
+ const createdFormIds: string[] = []
190
+ const createdFormFieldIds: string[] = []
191
+ const createdUserIds: string[] = []
192
+ const createdEventIds: string[] = []
193
+ const createdTemplateIds: string[] = []
194
+
195
+ // matchEmailAndNames makes the email branch of the $or additionally require fname + lname, which
196
+ // changes which victim shapes are reachable in G5 (a fully blank victim can't be matched at all).
197
+ const organization: any = await sdk.api.organizations.getOne(businessId).catch(() => null)
198
+ const MATCH_EMAIL_AND_NAMES = !!organization?.settings?.endusers?.matchEmailAndNames
199
+
200
+ // ---------------------------------------------------------------------------------------------
201
+ // Fixtures
202
+ // ---------------------------------------------------------------------------------------------
203
+ // The public form has exactly one question with NO intakeField, so submitting it does not
204
+ // legitimately write email/phone and therefore cannot confound G8.
205
+ const form = await sdk.api.forms.createOne({ title: `auth field lock ${RUN}`, allowPublicURL: true })
206
+ createdFormIds.push(form.id)
207
+ const plainField = await sdk.api.form_fields.createOne({
208
+ formId: form.id,
209
+ title: 'Anything',
210
+ type: 'string',
211
+ previousFields: [{ type: 'root', info: {} }],
212
+ })
213
+ createdFormFieldIds.push(plainField.id)
214
+
215
+ const autoMergeForm = await sdk.api.forms.createOne({
216
+ title: `auth field lock automerge ${RUN}`,
217
+ allowPublicURL: true,
218
+ autoMergeOnSubmission: true,
219
+ })
220
+ createdFormIds.push(autoMergeForm.id)
221
+ const autoMergeField = await sdk.api.form_fields.createOne({
222
+ formId: autoMergeForm.id,
223
+ title: 'Anything',
224
+ type: 'string',
225
+ previousFields: [{ type: 'root', info: {} }],
226
+ })
227
+ createdFormFieldIds.push(autoMergeField.id)
228
+
229
+ // Intake-mapped forms, for G11-G18.
230
+ //
231
+ // `email` and `phone` are first-class, UI-selectable intakeField mappings (both are keys of
232
+ // ENDUSER_FIELD_TYPES), and save_enduser_updates_for_form_response (api/modules/forms.ts) copies
233
+ // those answers onto the enduser named by the form response. That is a SECOND write path, reached
234
+ // with the very token session_for_public_form mints — so the endpoint-level lock covered by
235
+ // G1-G4 is worthless unless this path is guarded too.
236
+ //
237
+ // intakePhone: 'hidden' removes the endpoint's "Phone number is required" check, which lets these
238
+ // scenarios call the endpoint with NO email/phone at all. That isolates the intake write path
239
+ // completely: any change to the victim's email/phone can only have come from an intake answer.
240
+ // It also mirrors the real intakeEmailHidden/intakePhone:'hidden' configuration in which the
241
+ // intake question is the ONLY source of a new patient's contact info (see G16).
242
+ const makeIntakeForm = async (label: string, opts: { public?: boolean, saveIntakeOnPartial?: boolean } = {}) => {
243
+ const intakeForm = await sdk.api.forms.createOne({
244
+ title: `auth field lock ${label} ${RUN}`,
245
+ intakePhone: 'hidden',
246
+ ...(opts.public === false ? {} : { allowPublicURL: true }),
247
+ } as any)
248
+ createdFormIds.push(intakeForm.id)
249
+
250
+ const fieldOptions = opts.saveIntakeOnPartial ? { options: { saveIntakeOnPartial: true } } : {}
251
+
252
+ const emailField = await sdk.api.form_fields.createOne({
253
+ formId: intakeForm.id,
254
+ title: 'Email',
255
+ type: 'email',
256
+ intakeField: 'email',
257
+ previousFields: [{ type: 'root', info: {} }],
258
+ ...fieldOptions,
259
+ } as any)
260
+ createdFormFieldIds.push(emailField.id)
261
+
262
+ const phoneField = await sdk.api.form_fields.createOne({
263
+ formId: intakeForm.id,
264
+ title: 'Phone Number',
265
+ type: 'phone',
266
+ intakeField: 'phone',
267
+ previousFields: [{ type: 'after', info: { fieldId: emailField.id } }],
268
+ ...fieldOptions,
269
+ } as any)
270
+ createdFormFieldIds.push(phoneField.id)
271
+
272
+ return { form: intakeForm, emailField, phoneField }
273
+ }
274
+
275
+ const intake = await makeIntakeForm('intake')
276
+ const partialIntake = await makeIntakeForm('partial', { saveIntakeOnPartial: true })
277
+ // Deliberately NOT allowPublicURL. prepare_form_response does not require it, so a public session
278
+ // can pivot from any public form to any form in the org carrying an email/phone intake mapping.
279
+ const pivotIntake = await makeIntakeForm('pivot', { public: false })
280
+
281
+ // Auto-merge fixture for G26. Needs real intakeField mappings, because resolveAutoMergeBeforeSubmission
282
+ // derives its match values from the submitted answers via those mappings — and the custom field is the
283
+ // anti-vacuity control (it proves the intake write ran at all).
284
+ const MERGE_CUSTOM_KEY = `aflMerge${RUN}`
285
+ const autoMergeIntake = await (async () => {
286
+ const form = await sdk.api.forms.createOne({
287
+ title: `auth field lock automerge intake ${RUN}`,
288
+ allowPublicURL: true,
289
+ autoMergeOnSubmission: true,
290
+ intakePhone: 'hidden',
291
+ } as any)
292
+ createdFormIds.push(form.id)
293
+
294
+ const spec = [
295
+ { title: 'First Name', type: 'string', intakeField: 'fname' },
296
+ { title: 'Last Name', type: 'string', intakeField: 'lname' },
297
+ { title: 'Date of Birth', type: 'dateString', intakeField: 'dateOfBirth' },
298
+ { title: 'Email', type: 'email', intakeField: 'email' },
299
+ { title: 'Custom', type: 'string', intakeField: MERGE_CUSTOM_KEY },
300
+ ]
301
+ const made: any[] = []
302
+ for (const s of spec) {
303
+ const field = await sdk.api.form_fields.createOne({
304
+ formId: form.id,
305
+ title: s.title,
306
+ type: s.type,
307
+ intakeField: s.intakeField,
308
+ previousFields: made.length
309
+ ? [{ type: 'after', info: { fieldId: made[made.length - 1].id } }]
310
+ : [{ type: 'root', info: {} }],
311
+ } as any)
312
+ createdFormFieldIds.push(field.id)
313
+ made.push(field)
314
+ }
315
+ const [fname, lname, dateOfBirth, email, custom] = made
316
+ return { form, fname, lname, dateOfBirth, email, custom }
317
+ })()
318
+
319
+ const template = await sdk.api.calendar_event_templates.createOne({
320
+ title: `auth field lock ${RUN}`,
321
+ durationInMinutes: 60,
322
+ confirmationEmailDisabled: true,
323
+ confirmationSMSDisabled: true,
324
+ reminders: [],
325
+ })
326
+ createdTemplateIds.push(template.id)
327
+
328
+ // Throwaway host user with all-7-day availability, so G9 has slots to find without depending on
329
+ // (or mutating) the shared test user's calendar configuration.
330
+ const hostUser = await sdk.api.users.createOne({
331
+ email: emailFor('lock-host'),
332
+ verifiedEmail: true,
333
+ fname: 'Lock',
334
+ lname: `Host${RUN}`,
335
+ })
336
+ createdUserIds.push(hostUser.id)
337
+ await sdk.api.users.updateOne(hostUser.id, {
338
+ weeklyAvailabilities: [0, 1, 2, 3, 4, 5, 6].map(dayOfWeekStartingSundayIndexedByZero => ({
339
+ dayOfWeekStartingSundayIndexedByZero,
340
+ startTimeInMinutes: 9 * 60,
341
+ endTimeInMinutes: 17 * 60,
342
+ intervalInMinutes: 60,
343
+ })),
344
+ calendarEventLimits: [],
345
+ }, { replaceObjectFields: true })
346
+
347
+ await sleep(250) // let form.numFields settle before the public endpoints validate it
348
+
349
+ // ---------------------------------------------------------------------------------------------
350
+ // Helpers
351
+ // ---------------------------------------------------------------------------------------------
352
+ const publicSDK = () => new EnduserSession({ host, businessId })
353
+
354
+ const startForm = (args: any) => publicSDK().api.form_responses.session_for_public_form({
355
+ formId: form.id, businessId, ...args,
356
+ })
357
+ const startBooking = (args: any) => publicSDK().api.calendar_events.session_for_public_appointment_booking({
358
+ calendarEventTemplateId: template.id, businessId, ...args,
359
+ })
360
+ const startFormOn = (formId: string, args: any) => publicSDK().api.form_responses.session_for_public_form({
361
+ formId, businessId, ...args,
362
+ })
363
+
364
+ const asEnduser = (authToken: string) => new EnduserSession({ host, businessId, authToken })
365
+
366
+ // Raw POST with a Bearer token, so G25 can inspect exactly which fields the response body carries.
367
+ const postAuthed = async (path: string, body: any, authToken: string) => {
368
+ try {
369
+ const res = await axios.post(`${host}${path}`, body, {
370
+ headers: { Authorization: `Bearer ${authToken}` },
371
+ validateStatus: () => true,
372
+ })
373
+ return { status: res.status, data: res.data as any }
374
+ } catch (err: any) {
375
+ return { status: err?.response?.status as number, data: err?.response?.data as any }
376
+ }
377
+ }
378
+
379
+ // prepare_form_response returns the token it mints inside fullURL (?token=...). Pulling it out is
380
+ // the whole point of G24/G25 — that token is what a caller actually receives.
381
+ const tokenFromURL = (fullURL?: string) => /[?&]token=([^&]+)/.exec(fullURL ?? '')?.[1]
382
+
383
+ // Raw PATCH so G15 can assert on the HTTP status; the SDK's parseError discards it.
384
+ const patchAuthed = async (path: string, body: any, authToken: string) => {
385
+ try {
386
+ const res = await axios.patch(`${host}${path}`, body, {
387
+ headers: { Authorization: `Bearer ${authToken}` },
388
+ validateStatus: () => true,
389
+ })
390
+ return { status: res.status, data: res.data as any }
391
+ } catch (err: any) {
392
+ return { status: err?.response?.status as number, data: err?.response?.data as any }
393
+ }
394
+ }
395
+
396
+ type IntakeFields = { emailField: { id: string, title: string }, phoneField: { id: string, title: string } }
397
+ const intakeAnswers = (f: IntakeFields, email: string, phone: string) => ([
398
+ { fieldId: f.emailField.id, fieldTitle: f.emailField.title, answer: { type: 'email' as const, value: email } },
399
+ { fieldId: f.phoneField.id, fieldTitle: f.phoneField.title, answer: { type: 'phone' as const, value: phone } },
400
+ ])
401
+
402
+ const pollEnduser = async (id: string, landed: (e: Enduser) => boolean, windowMs: number) => {
403
+ const deadline = Date.now() + windowMs
404
+ let latest: Enduser | null = null
405
+ while (true) {
406
+ latest = await sdk.api.endusers.getOne(id).catch(() => null)
407
+ if (latest && landed(latest)) return { landed: true, enduser: latest }
408
+ if (Date.now() >= deadline) return { landed: false, enduser: latest }
409
+ await sleep(POLL_INTERVAL_MS)
410
+ }
411
+ }
412
+
413
+ const describe = (e: Enduser | null) => `email=${e?.email || '(unset)'} phone=${e?.phone || '(unset)'}`
414
+
415
+ const makeVictim = async (tag: string, fields: Partial<Enduser> = {}) => {
416
+ const victim = await sdk.api.endusers.createOne({
417
+ fname: `Lock${RUN}`,
418
+ lname: `Victim${RUN}${tag}`,
419
+ dateOfBirth: '01-02-1990',
420
+ ...fields,
421
+ } as any)
422
+ createdEnduserIds.push(victim.id)
423
+ return victim
424
+ }
425
+
426
+ const VICTIM_SHAPES = [
427
+ { tag: 'A1', label: 'victim has email + phone', withEmail: true, withPhone: true },
428
+ { tag: 'A2', label: 'victim email UNSET (load-bearing)', withEmail: false, withPhone: true },
429
+ { tag: 'A3', label: 'victim phone UNSET', withEmail: true, withPhone: false },
430
+ { tag: 'A4', label: 'victim email + phone UNSET (load-bearing)', withEmail: false, withPhone: false },
431
+ ]
432
+
433
+ // ---------------------------------------------------------------------------------------------
434
+ // G1 — Endpoint A: email/phone never written on a matched patient
435
+ // ---------------------------------------------------------------------------------------------
436
+ for (const shape of VICTIM_SHAPES) {
437
+ const name = `A blocks email/phone overwrite — ${shape.label}`
438
+ await runScenario('G1', name, async () => {
439
+ const victim = await makeVictim(shape.tag, {
440
+ ...(shape.withEmail ? { email: emailFor(`lock-v-${shape.tag}`) } : {}),
441
+ ...(shape.withPhone ? { phone: nextPhone() } : {}),
442
+ })
443
+ const attackerEmail = emailFor(`atk-${shape.tag}`)
444
+ const attackerPhone = nextPhone()
445
+
446
+ const session = await startForm({
447
+ fname: victim.fname,
448
+ lname: victim.lname,
449
+ dateOfBirth: victim.dateOfBirth,
450
+ email: attackerEmail,
451
+ phone: attackerPhone,
452
+ })
453
+
454
+ // Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
455
+ // unchanged and the test would pass for the wrong reason.
456
+ if (session.enduserId !== victim.id) {
457
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
458
+ record('G1', name, false, `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
459
+ return
460
+ }
461
+
462
+ const { landed, enduser } = await pollEnduser(
463
+ victim.id,
464
+ e => e.email === attackerEmail || e.phone === attackerPhone,
465
+ OVERWRITE_WINDOW_MS,
466
+ )
467
+ record('G1', name, !landed, landed
468
+ ? `EXPLOIT: attacker values landed on victim (${describe(enduser)})`
469
+ : `unchanged (${describe(enduser)})`
470
+ )
471
+ })
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------------------------
475
+ // G2 — Endpoint B: same rule. B returns only { authToken }, so probe validity is established by
476
+ // a run-unique lname that must resolve to exactly the victim.
477
+ // ---------------------------------------------------------------------------------------------
478
+ for (const shape of VICTIM_SHAPES) {
479
+ const name = `B blocks email/phone overwrite — ${shape.label}`
480
+ await runScenario('G2', name, async () => {
481
+ const victim = await makeVictim(`B${shape.tag}`, {
482
+ ...(shape.withEmail ? { email: emailFor(`lock-vb-${shape.tag}`) } : {}),
483
+ ...(shape.withPhone ? { phone: nextPhone() } : {}),
484
+ })
485
+ const attackerEmail = emailFor(`atkb-${shape.tag}`)
486
+ const attackerPhone = nextPhone()
487
+
488
+ await startBooking({
489
+ fname: victim.fname,
490
+ lname: victim.lname,
491
+ dateOfBirth: victim.dateOfBirth,
492
+ email: attackerEmail,
493
+ phone: attackerPhone,
494
+ })
495
+
496
+ const matching = await sdk.api.endusers.getSome({ filter: { lname: victim.lname } })
497
+ if (matching.length !== 1 || matching[0].id !== victim.id) {
498
+ for (const m of matching) if (m.id !== victim.id) createdEnduserIds.push(m.id)
499
+ record('G2', name, false, `probe invalid — ${matching.length} records match lname ${victim.lname}; endpoint inserted instead of matching`)
500
+ return
501
+ }
502
+
503
+ const { landed, enduser } = await pollEnduser(
504
+ victim.id,
505
+ e => e.email === attackerEmail || e.phone === attackerPhone,
506
+ AWAITED_WINDOW_MS,
507
+ )
508
+ record('G2', name, !landed, landed
509
+ ? `EXPLOIT: attacker values landed on victim (${describe(enduser)})`
510
+ : `unchanged (${describe(enduser)})`
511
+ )
512
+ })
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------------------------
516
+ // G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
517
+ // as an attack through the first-party frontend, but trivially reachable by POSTing directly.
518
+ // The field lock must hold on this path too.
519
+ // ---------------------------------------------------------------------------------------------
520
+ await runScenario('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', async () => {
521
+ const victim = await makeVictim('C1') // no email, no phone — the takeover-ready shape
522
+ const attackerEmail = emailFor('atk-c1')
523
+ const attackerPhone = nextPhone()
524
+
525
+ const session = await startForm({ enduserId: victim.id, email: attackerEmail, phone: attackerPhone })
526
+
527
+ if (session.enduserId !== victim.id) {
528
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
529
+ record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', false,
530
+ `probe invalid — endpoint returned ${session.enduserId}, expected victim ${victim.id}`)
531
+ return
532
+ }
533
+
534
+ const { landed, enduser } = await pollEnduser(
535
+ victim.id,
536
+ e => e.email === attackerEmail || e.phone === attackerPhone,
537
+ OVERWRITE_WINDOW_MS,
538
+ )
539
+ record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', !landed, landed
540
+ ? `EXPLOIT: attacker values landed on victim (${describe(enduser)})`
541
+ : `unchanged (${describe(enduser)})`
542
+ )
543
+ })
544
+
545
+ // ---------------------------------------------------------------------------------------------
546
+ // G4 — The takeover chain itself.
547
+ //
548
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
549
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
550
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
551
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
552
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
553
+ // minting anything, so an empty set proves non-issuance.
554
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
555
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
556
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
557
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
558
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
559
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
560
+ // this file runs as part of tests.ts).
561
+ // ---------------------------------------------------------------------------------------------
562
+ await runScenario('G4', 'takeover chain: email-less victim is not made resolvable by A', async () => {
563
+ const victim = await makeVictim('D1', { phone: nextPhone() }) // email-less: the common, cheap case
564
+ const attackerEmail = emailFor('atk-d1')
565
+
566
+ const baseline = await post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId })
567
+ const baselineResult = baseline.data?.result
568
+ if (baseline.status === 429) {
569
+ skip('G4', 'begin_login_flow oracle baseline', 'rate limited (429) — oracle unavailable this run')
570
+ } else {
571
+ record('G4', 'begin_login_flow oracle baseline is continue-with-password',
572
+ baselineResult === 'continue-with-password', `result=${baselineResult} status=${baseline.status}`)
573
+ }
574
+
575
+ const session = await startForm({
576
+ fname: victim.fname,
577
+ lname: victim.lname,
578
+ dateOfBirth: victim.dateOfBirth,
579
+ email: attackerEmail,
580
+ phone: nextPhone(),
581
+ })
582
+ if (session.enduserId !== victim.id) {
583
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
584
+ record('G4', 'takeover chain: email-less victim is not made resolvable by A', false,
585
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
586
+ return
587
+ }
588
+
589
+ // Burn the fire-and-forget window so a vulnerable server has every chance to land the write.
590
+ const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
591
+
592
+ // Informational only: 204 regardless of outcome.
593
+ const resetRes = await post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId })
594
+ console.log(` (info) request-enduser-password-reset -> HTTP ${resetRes.status} — unconditional, carries no signal`)
595
+
596
+ const resolvable = await sdk.api.endusers.getSome({ filter: { email: attackerEmail } })
597
+ record('G4', 'attacker email resolves to no enduser (password reset cannot be issued)',
598
+ resolvable.length === 0,
599
+ resolvable.length === 0
600
+ ? `no enduser carries ${attackerEmail} (victim still ${describe(enduser)})`
601
+ : `EXPLOIT: ${resolvable.length} enduser(s) now resolve for ${attackerEmail} — reset token would be minted and mailed to the attacker`
602
+ )
603
+
604
+ const after = await post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId })
605
+ const afterResult = after.data?.result
606
+ if (after.status === 429) {
607
+ skip('G4', 'begin_login_flow oracle after attack', 'rate limited (429) — oracle unavailable this run')
608
+ } else {
609
+ record('G4', 'begin_login_flow still returns continue-with-password after attack',
610
+ afterResult === 'continue-with-password',
611
+ afterResult === 'continue-with-password'
612
+ ? `result=${afterResult}`
613
+ : `EXPLOIT: result=${afterResult} — an OTP / magic link was dispatched for the attacker's address`
614
+ )
615
+ }
616
+ })
617
+
618
+ // ---------------------------------------------------------------------------------------------
619
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
620
+ // ---------------------------------------------------------------------------------------------
621
+ await runScenario('G5', 'blank match keys filled on match, blank phone NOT filled', async () => {
622
+ const victimEmail = emailFor('lock-v-e1')
623
+ // Matched via the email branch, so the record can start with blank names. Under
624
+ // matchEmailAndNames that branch also requires fname + lname, so fname/lname must be present
625
+ // for the match to happen at all — coverage narrows to dateOfBirth/gender in that config.
626
+ const victim = await sdk.api.endusers.createOne({
627
+ email: victimEmail,
628
+ ...(MATCH_EMAIL_AND_NAMES ? { fname: `Lock${RUN}`, lname: `Victim${RUN}E1` } : {}),
629
+ } as any)
630
+ createdEnduserIds.push(victim.id)
631
+ if (MATCH_EMAIL_AND_NAMES) {
632
+ skip('G5', 'blank fname/lname fill', 'organization has settings.endusers.matchEmailAndNames — a name-less record is unmatchable')
633
+ }
634
+
635
+ const submittedPhone = nextPhone()
636
+ const session = await startForm({
637
+ email: victimEmail,
638
+ fname: `Lock${RUN}`,
639
+ lname: `Victim${RUN}E1`,
640
+ dateOfBirth: '03-04-1991',
641
+ gender: 'Female',
642
+ phone: submittedPhone,
643
+ })
644
+ if (session.enduserId !== victim.id) {
645
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
646
+ record('G5', 'blank match keys filled on match, blank phone NOT filled', false,
647
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
648
+ return
649
+ }
650
+
651
+ const filled = await pollEnduser(
652
+ victim.id,
653
+ e => !!e.dateOfBirth && !!e.gender && (MATCH_EMAIL_AND_NAMES || (!!e.fname && !!e.lname)),
654
+ OVERWRITE_WINDOW_MS,
655
+ )
656
+ record('G5', 'blank fname/lname/dateOfBirth/gender still filled on match', filled.landed,
657
+ `fname=${filled.enduser?.fname || '(unset)'} lname=${filled.enduser?.lname || '(unset)'} `
658
+ + `dateOfBirth=${filled.enduser?.dateOfBirth || '(unset)'} gender=${filled.enduser?.gender || '(unset)'}`)
659
+
660
+ const phoneLanded = await pollEnduser(victim.id, e => !!e.phone, AWAITED_WINDOW_MS)
661
+ record('G5', 'blank phone still NOT filled on match', !phoneLanded.landed,
662
+ phoneLanded.landed
663
+ ? `EXPLOIT: phone=${phoneLanded.enduser?.phone} written into a blank field (submitted ${submittedPhone})`
664
+ : 'phone remains unset')
665
+ })
666
+
667
+ // ---------------------------------------------------------------------------------------------
668
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
669
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
670
+ // provider licensed in their new state.
671
+ // ---------------------------------------------------------------------------------------------
672
+ await runScenario('G6', 'state still overwrites on match (status-quo pin)', async () => {
673
+ const victim = await makeVictim('F1', { email: emailFor('lock-v-f1'), state: 'NY' })
674
+
675
+ const session = await startForm({
676
+ fname: victim.fname,
677
+ lname: victim.lname,
678
+ dateOfBirth: victim.dateOfBirth,
679
+ phone: nextPhone(),
680
+ state: 'CA',
681
+ })
682
+ if (session.enduserId !== victim.id) {
683
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
684
+ record('G6', 'state still overwrites on match (status-quo pin)', false,
685
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
686
+ return
687
+ }
688
+
689
+ const { landed, enduser } = await pollEnduser(victim.id, e => e.state === 'CA', OVERWRITE_WINDOW_MS)
690
+ record('G6', 'state still overwrites on match (status-quo pin)', landed, `state=${enduser?.state || '(unset)'}`)
691
+ })
692
+
693
+ // ---------------------------------------------------------------------------------------------
694
+ // G7 — Insert path unchanged: a brand-new visitor is still created with the submitted email/phone.
695
+ // ---------------------------------------------------------------------------------------------
696
+ // Accumulator arrays rather than `let ... | null`: values are assigned inside runScenario's
697
+ // callback, and TS's control-flow narrowing does not account for closure assignments.
698
+ const formSessions: { accessCode: string, authToken: string, enduserId: string }[] = []
699
+ await runScenario('G7', 'A creates a new visitor with submitted email + phone', async () => {
700
+ const email = emailFor('lock-new-a')
701
+ const phone = nextPhone()
702
+ const session = await startForm({
703
+ fname: `Lock${RUN}`, lname: `NewA${RUN}`, dateOfBirth: '05-06-1992', email, phone,
704
+ })
705
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
706
+
707
+ const created = await sdk.api.endusers.getOne(session.enduserId)
708
+ const ok = created.email === email && created.phone === phone
709
+ && !!session.accessCode && !!session.authToken && !!session.enduserId
710
+ record('G7', 'A creates a new visitor with submitted email + phone', ok,
711
+ `${describe(created)}; accessCode=${!!session.accessCode} authToken=${!!session.authToken} enduserId=${!!session.enduserId}`)
712
+
713
+ if (ok) formSessions.push(session)
714
+ })
715
+
716
+ const bookingTokens: string[] = []
717
+ await runScenario('G7', 'B creates a new visitor with submitted email + phone', async () => {
718
+ const email = emailFor('lock-new-b')
719
+ const phone = nextPhone()
720
+ const lname = `NewB${RUN}`
721
+ const session = await startBooking({
722
+ fname: `Lock${RUN}`, lname, dateOfBirth: '07-08-1993', email, phone,
723
+ })
724
+
725
+ const matching = await sdk.api.endusers.getSome({ filter: { lname } })
726
+ for (const m of matching) createdEnduserIds.push(m.id)
727
+
728
+ const created = matching.length === 1 ? matching[0] : null
729
+ const ok = !!session.authToken && !!created && created.email === email && created.phone === phone
730
+ record('G7', 'B creates a new visitor with submitted email + phone', ok,
731
+ `authToken=${!!session.authToken}; ${matching.length} record(s); ${describe(created)}`)
732
+
733
+ if (session.authToken) bookingTokens.push(session.authToken)
734
+ })
735
+
736
+ // ---------------------------------------------------------------------------------------------
737
+ // G8 — The returned public-form session still works end-to-end.
738
+ // ---------------------------------------------------------------------------------------------
739
+ await runScenario('G8', 'form submission completes under the returned token', async () => {
740
+ const session = formSessions[0]
741
+ if (!session) {
742
+ record('G8', 'form submission completes under the returned token', false, 'no session available from G7')
743
+ return
744
+ }
745
+ const authed = new EnduserSession({ host, businessId, authToken: session.authToken })
746
+ const { formResponse } = await authed.api.form_responses.submit_form_response({
747
+ accessCode: session.accessCode,
748
+ responses: [{
749
+ fieldId: plainField.id,
750
+ fieldTitle: plainField.title,
751
+ answer: { type: 'string' as const, value: `lock test ${RUN}` },
752
+ }],
753
+ })
754
+ record('G8', 'form submission completes under the returned token', !!formResponse?.submittedAt,
755
+ `formResponse=${formResponse?.id} submittedAt=${formResponse?.submittedAt ? 'set' : '(unset)'}`)
756
+ })
757
+
758
+ // ---------------------------------------------------------------------------------------------
759
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
760
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
761
+ // regression.
762
+ // ---------------------------------------------------------------------------------------------
763
+ await runScenario('G9', 'availability + booking work under the returned session', async () => {
764
+ const bookingAuthToken = bookingTokens[0]
765
+ if (!bookingAuthToken) {
766
+ record('G9', 'availability + booking work under the returned session', false, 'no booking session available from G7')
767
+ return
768
+ }
769
+ const booking = new EnduserSession({ host, businessId, authToken: bookingAuthToken })
770
+ const DAY_IN_MS = 24 * 60 * 60 * 1000
771
+ const from = new Date(Date.now() + DAY_IN_MS)
772
+ const to = new Date(from.getTime() + (7 * DAY_IN_MS))
773
+
774
+ const { availabilityBlocks } = await booking.api.calendar_events.get_appointment_availability({
775
+ calendarEventTemplateId: template.id,
776
+ userIds: [hostUser.id],
777
+ restrictedByState: false, // the public booking enduser has no state
778
+ from,
779
+ to,
780
+ })
781
+ const slots = availabilityBlocks.filter(b => b.userId === hostUser.id && b.startTimeInMS > Date.now())
782
+ record('G9', 'get_appointment_availability succeeds under the confined booking token', true,
783
+ `${availabilityBlocks.length} block(s), ${slots.length} future slot(s) for the throwaway host`)
784
+
785
+ if (slots.length === 0) {
786
+ skip('G9', 'book_appointment under the confined booking token', 'no future availability slot in the 7-day window')
787
+ return
788
+ }
789
+
790
+ const { createdEvent } = await booking.api.calendar_events.book_appointment({
791
+ userId: hostUser.id,
792
+ calendarEventTemplateId: template.id,
793
+ startTime: new Date(slots[0].startTimeInMS),
794
+ timezone: 'America/New_York',
795
+ })
796
+ if (createdEvent?.id) createdEventIds.push(createdEvent.id)
797
+ record('G9', 'book_appointment succeeds under the confined booking token', !!createdEvent?.id,
798
+ `createdEvent=${createdEvent?.id}`)
799
+ })
800
+
801
+ // ---------------------------------------------------------------------------------------------
802
+ // G10 — skipMatch behavior unchanged.
803
+ // ---------------------------------------------------------------------------------------------
804
+ await runScenario('G10', 'skipMatch=true still inserts a new record', async () => {
805
+ const victim = await makeVictim('H1', { email: emailFor('lock-v-h1'), phone: nextPhone() })
806
+
807
+ const session = await startForm({
808
+ fname: victim.fname,
809
+ lname: victim.lname,
810
+ dateOfBirth: victim.dateOfBirth, // would otherwise match the victim
811
+ email: emailFor('lock-skip'),
812
+ phone: nextPhone(),
813
+ skipMatch: true,
814
+ })
815
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
816
+ record('G10', 'skipMatch=true still inserts a new record', session.enduserId !== victim.id,
817
+ `inserted=${session.enduserId} victim=${victim.id}`)
818
+ })
819
+
820
+ await runScenario('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', async () => {
821
+ const session = await publicSDK().api.form_responses.session_for_public_form({
822
+ formId: autoMergeForm.id,
823
+ businessId,
824
+ skipMatch: true,
825
+ fname: `Lock${RUN}`,
826
+ lname: `Merge${RUN}`,
827
+ dateOfBirth: '09-10-1994',
828
+ email: emailFor('lock-merge'),
829
+ phone: nextPhone(),
830
+ })
831
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
832
+
833
+ const created = await sdk.api.endusers.getOne(session.enduserId)
834
+ record('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge',
835
+ created.eligibleForAutoMerge === true, `eligibleForAutoMerge=${created.eligibleForAutoMerge}`)
836
+ })
837
+
838
+ // ---------------------------------------------------------------------------------------------
839
+ // G11 — Intake answers must not overwrite email/phone on a matched patient (submit path).
840
+ // ---------------------------------------------------------------------------------------------
841
+ for (const shape of VICTIM_SHAPES) {
842
+ const name = `intake answers do not overwrite a matched patient — ${shape.label}`
843
+ await runScenario('G11', name, async () => {
844
+ const victimEmail = shape.withEmail ? emailFor(`lock-i-${shape.tag}`) : undefined
845
+ const victimPhone = shape.withPhone ? nextPhone() : undefined
846
+ const victim = await makeVictim(`I${shape.tag}`, {
847
+ ...(victimEmail ? { email: victimEmail } : {}),
848
+ ...(victimPhone ? { phone: victimPhone } : {}),
849
+ })
850
+ const attackerEmail = emailFor(`atk-i-${shape.tag}`)
851
+ const attackerPhone = nextPhone()
852
+
853
+ // No email/phone passed to the endpoint at all, so the intake answers below are the only
854
+ // possible source of a change to the victim's contact info.
855
+ const session = await startFormOn(intake.form.id, {
856
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
857
+ })
858
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
859
+ if (session.enduserId !== victim.id) {
860
+ record('G11', name, false,
861
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
862
+ return
863
+ }
864
+
865
+ await asEnduser(session.authToken).api.form_responses.submit_form_response({
866
+ accessCode: session.accessCode,
867
+ responses: intakeAnswers(intake, attackerEmail, attackerPhone),
868
+ })
869
+
870
+ const { enduser } = await pollEnduser(
871
+ victim.id,
872
+ e => e.email === attackerEmail || e.phone === attackerPhone,
873
+ OVERWRITE_WINDOW_MS,
874
+ )
875
+ const emailHeld = (enduser?.email || undefined) === victimEmail
876
+ const phoneHeld = (enduser?.phone || undefined) === victimPhone
877
+ record('G11', `${name} — email`, emailHeld,
878
+ emailHeld
879
+ ? `email still ${victimEmail || '(unset)'}`
880
+ : `EXPLOIT: intake answer wrote ${enduser?.email} (was ${victimEmail || '(unset)'})`)
881
+ record('G11', `${name} — phone`, phoneHeld,
882
+ phoneHeld
883
+ ? `phone still ${victimPhone || '(unset)'}`
884
+ : `EXPLOIT: intake answer wrote ${enduser?.phone} (was ${victimPhone || '(unset)'})`)
885
+ })
886
+ }
887
+
888
+ // ---------------------------------------------------------------------------------------------
889
+ // G12 — Same bypass via save_field_response, without ever submitting the form.
890
+ // ---------------------------------------------------------------------------------------------
891
+ const G12_NAME = 'saveIntakeOnPartial answers do not overwrite a matched patient'
892
+ await runScenario('G12', G12_NAME, async () => {
893
+ const victim = await makeVictim('J1', { phone: nextPhone() }) // email-less: the load-bearing shape
894
+ const attackerEmail = emailFor('atk-j1')
895
+
896
+ const session = await startFormOn(partialIntake.form.id, {
897
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
898
+ })
899
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
900
+ if (session.enduserId !== victim.id) {
901
+ record('G12', G12_NAME, false,
902
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
903
+ return
904
+ }
905
+
906
+ // No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
907
+ // triggers the intake write.
908
+ await asEnduser(session.authToken).api.form_responses.save_field_response({
909
+ accessCode: session.accessCode,
910
+ response: {
911
+ fieldId: partialIntake.emailField.id,
912
+ fieldTitle: partialIntake.emailField.title,
913
+ answer: { type: 'email' as const, value: attackerEmail },
914
+ },
915
+ })
916
+
917
+ const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
918
+ record('G12', G12_NAME, !enduser?.email,
919
+ !enduser?.email
920
+ ? 'email still (unset) after partial save'
921
+ : `EXPLOIT: partial save wrote ${enduser?.email} without ever submitting the form`)
922
+ })
923
+
924
+ // ---------------------------------------------------------------------------------------------
925
+ // G13 — The full takeover chain, driven through an intake answer instead of the endpoint.
926
+ // ---------------------------------------------------------------------------------------------
927
+ const G13_NAME = 'takeover chain via intake answer: victim not made resolvable'
928
+ await runScenario('G13', G13_NAME, async () => {
929
+ const victim = await makeVictim('K1', { phone: nextPhone() })
930
+ const attackerEmail = emailFor('atk-k1')
931
+
932
+ const session = await startFormOn(intake.form.id, {
933
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
934
+ })
935
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
936
+ if (session.enduserId !== victim.id) {
937
+ record('G13', G13_NAME, false,
938
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
939
+ return
940
+ }
941
+
942
+ await asEnduser(session.authToken).api.form_responses.submit_form_response({
943
+ accessCode: session.accessCode,
944
+ responses: intakeAnswers(intake, attackerEmail, nextPhone()),
945
+ })
946
+ const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
947
+
948
+ // Informational only: 204 regardless of outcome.
949
+ const resetRes = await post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId })
950
+ console.log(` (info) request-enduser-password-reset -> HTTP ${resetRes.status} — unconditional, carries no signal`)
951
+
952
+ const resolvable = await sdk.api.endusers.getSome({ filter: { email: attackerEmail } })
953
+ record('G13', 'attacker email resolves to no enduser after intake submit',
954
+ resolvable.length === 0,
955
+ resolvable.length === 0
956
+ ? `no enduser carries ${attackerEmail} (victim still ${describe(enduser)})`
957
+ : `EXPLOIT: ${resolvable.length} enduser(s) now resolve for ${attackerEmail} — reset token would be mailed to the attacker`)
958
+
959
+ const after = await post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId })
960
+ const afterResult = after.data?.result
961
+ if (after.status === 429) {
962
+ skip('G13', 'begin_login_flow oracle after intake attack', 'rate limited (429) — oracle unavailable this run')
963
+ } else {
964
+ record('G13', 'begin_login_flow still returns continue-with-password after intake attack',
965
+ afterResult === 'continue-with-password',
966
+ afterResult === 'continue-with-password'
967
+ ? `result=${afterResult}`
968
+ : `EXPLOIT: result=${afterResult} — an OTP / magic link was dispatched for the attacker's address`)
969
+ }
970
+ })
971
+
972
+ // ---------------------------------------------------------------------------------------------
973
+ // G14 — Pivot: a public session can prepare a response for a NON-public form and submit that.
974
+ // ---------------------------------------------------------------------------------------------
975
+ const G14_NAME = 'pivot via prepare_form_response to a non-public form is also blocked'
976
+ await runScenario('G14', G14_NAME, async () => {
977
+ const victimPhone = nextPhone()
978
+ const victim = await makeVictim('L1', { phone: victimPhone })
979
+ const attackerEmail = emailFor('atk-l1')
980
+ const attackerPhone = nextPhone()
981
+
982
+ const session = await startFormOn(intake.form.id, {
983
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
984
+ })
985
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
986
+ if (session.enduserId !== victim.id) {
987
+ record('G14', G14_NAME, false,
988
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
989
+ return
990
+ }
991
+
992
+ const authed = asEnduser(session.authToken)
993
+ // prepare_form_response is in the 'public-form' session scope and does not require allowPublicURL.
994
+ // enduserId === session id, so its ownership check passes.
995
+ const prepared = await authed.api.form_responses.prepare_form_response({
996
+ formId: pivotIntake.form.id,
997
+ enduserId: victim.id,
998
+ })
999
+ await authed.api.form_responses.submit_form_response({
1000
+ accessCode: prepared.accessCode,
1001
+ responses: intakeAnswers(pivotIntake, attackerEmail, attackerPhone),
1002
+ })
1003
+
1004
+ const { enduser } = await pollEnduser(
1005
+ victim.id,
1006
+ e => e.email === attackerEmail || e.phone === attackerPhone,
1007
+ OVERWRITE_WINDOW_MS,
1008
+ )
1009
+ record('G14', `${G14_NAME} — email`, !enduser?.email,
1010
+ !enduser?.email
1011
+ ? 'email still (unset) after pivot submit'
1012
+ : `EXPLOIT: pivot to a non-public form wrote ${enduser?.email}`)
1013
+ record('G14', `${G14_NAME} — phone`, enduser?.phone === victimPhone,
1014
+ enduser?.phone === victimPhone
1015
+ ? `phone still ${victimPhone}`
1016
+ : `EXPLOIT: pivot to a non-public form wrote ${enduser?.phone} (was ${victimPhone})`)
1017
+ })
1018
+
1019
+ // ---------------------------------------------------------------------------------------------
1020
+ // G15 — save_field_response ownership: a guessable formResponseId must not reach another patient.
1021
+ // ---------------------------------------------------------------------------------------------
1022
+ const G15_NAME = "save_field_response rejects another patient's formResponseId"
1023
+ await runScenario('G15', G15_NAME, async () => {
1024
+ const patientA = await makeVictim('M1', { phone: nextPhone() })
1025
+ const patientB = await makeVictim('M2', { phone: nextPhone() })
1026
+
1027
+ const sessionA = await startFormOn(partialIntake.form.id, {
1028
+ fname: patientA.fname, lname: patientA.lname, dateOfBirth: patientA.dateOfBirth,
1029
+ })
1030
+ if (sessionA.enduserId) createdEnduserIds.push(sessionA.enduserId)
1031
+ if (sessionA.enduserId !== patientA.id) {
1032
+ record('G15', G15_NAME, false,
1033
+ `probe invalid — endpoint inserted ${sessionA.enduserId} instead of matching patient A ${patientA.id}`)
1034
+ return
1035
+ }
1036
+
1037
+ // Patient B's in-progress response, created by staff.
1038
+ const bPrepared = await sdk.api.form_responses.prepare_form_response({
1039
+ formId: partialIntake.form.id, enduserId: patientB.id,
1040
+ })
1041
+ const bResponseId = (bPrepared.response as any)?.id ?? (bPrepared.response as any)?._id
1042
+
1043
+ const answer = {
1044
+ fieldId: partialIntake.emailField.id,
1045
+ fieldTitle: partialIntake.emailField.title,
1046
+ answer: { type: 'email' as const, value: emailFor('atk-m1') },
1047
+ }
1048
+
1049
+ const byId = await patchAuthed(
1050
+ '/v1/save-field-response',
1051
+ { formResponseId: bResponseId, response: answer },
1052
+ sessionA.authToken,
1053
+ )
1054
+ record('G15', G15_NAME, byId.status === 403,
1055
+ byId.status === 403
1056
+ ? 'HTTP 403 as expected'
1057
+ : `EXPLOIT: HTTP ${byId.status} — patient A wrote into patient B's in-progress response`)
1058
+
1059
+ // The accessCode path stays open by design: the code is a 32-byte secret, so holding it IS the
1060
+ // authorization (same model as submit_form_response). Pinned so the distinction is deliberate.
1061
+ const byCode = await patchAuthed(
1062
+ '/v1/save-field-response',
1063
+ { accessCode: bPrepared.accessCode, response: answer },
1064
+ sessionA.authToken,
1065
+ )
1066
+ record('G15', 'save_field_response still accepts a known accessCode', byCode.status === 200,
1067
+ `HTTP ${byCode.status}`)
1068
+
1069
+ // A guessed formResponseId paired with a WRONG accessCode must still be rejected — otherwise
1070
+ // supplying any junk code would defeat the ownership check.
1071
+ const byIdWrongCode = await patchAuthed(
1072
+ '/v1/save-field-response',
1073
+ { formResponseId: bResponseId, accessCode: 'not-a-real-access-code', response: answer },
1074
+ sessionA.authToken,
1075
+ )
1076
+ record('G15', "another patient's formResponseId + wrong accessCode is rejected",
1077
+ byIdWrongCode.status === 403, `HTTP ${byIdWrongCode.status}`)
1078
+
1079
+ // THE REAL UI SHAPE: the form hooks always send accessCode AND formResponseId together
1080
+ // (react/components/src/Forms/hooks.tsx), and formResponseId wins the server-side lookup. Patient
1081
+ // A saving their OWN in-progress response this way must keep working — a naive
1082
+ // "reject whenever formResponseId is present" guard would break every live form.
1083
+ const [aResponse] = await sdk.api.form_responses.getSome({ filter: { accessCode: sessionA.accessCode } })
1084
+ const ownBoth = await patchAuthed(
1085
+ '/v1/save-field-response',
1086
+ {
1087
+ formResponseId: aResponse?.id,
1088
+ accessCode: sessionA.accessCode,
1089
+ response: {
1090
+ fieldId: partialIntake.emailField.id,
1091
+ fieldTitle: partialIntake.emailField.title,
1092
+ answer: { type: 'email' as const, value: emailFor('own-both') },
1093
+ },
1094
+ },
1095
+ sessionA.authToken,
1096
+ )
1097
+ record('G15', 'own response with accessCode + formResponseId still succeeds (real UI shape)',
1098
+ ownBoth.status === 200,
1099
+ ownBoth.status === 200 ? 'HTTP 200' : `REGRESSION: HTTP ${ownBoth.status} — this is how the form UI calls it`)
1100
+ })
1101
+
1102
+ // ---------------------------------------------------------------------------------------------
1103
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1104
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1105
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1106
+ // ---------------------------------------------------------------------------------------------
1107
+ await runScenario('G16', 'brand-new visitor still gets email/phone from intake answers', async () => {
1108
+ const submittedEmail = emailFor('lock-new-intake')
1109
+ const submittedPhone = nextPhone()
1110
+
1111
+ const session = await startFormOn(intake.form.id, {
1112
+ fname: `Lock${RUN}`, lname: `NewIntake${RUN}`, dateOfBirth: '05-06-1992',
1113
+ })
1114
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1115
+
1116
+ const created = await sdk.api.endusers.getOne(session.enduserId)
1117
+ record('G16', 'new visitor starts with no contact info from the endpoint',
1118
+ !created.email && !created.phone, describe(created))
1119
+
1120
+ await asEnduser(session.authToken).api.form_responses.submit_form_response({
1121
+ accessCode: session.accessCode,
1122
+ responses: intakeAnswers(intake, submittedEmail, submittedPhone),
1123
+ })
1124
+
1125
+ const { enduser } = await pollEnduser(session.enduserId, e => !!e.email && !!e.phone, AWAITED_WINDOW_MS)
1126
+ record('G16', 'brand-new visitor still gets email from intake answers',
1127
+ enduser?.email === submittedEmail, `email=${enduser?.email || '(unset)'} expected=${submittedEmail}`)
1128
+ record('G16', 'brand-new visitor still gets phone from intake answers',
1129
+ enduser?.phone === submittedPhone, `phone=${enduser?.phone || '(unset)'} expected=${submittedPhone}`)
1130
+ })
1131
+
1132
+ // ---------------------------------------------------------------------------------------------
1133
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1134
+ // ---------------------------------------------------------------------------------------------
1135
+ await runScenario('G17', 'authenticated portal enduser can still self-update via intake', async () => {
1136
+ const patient = await makeVictim('N1', { email: emailFor('lock-n1'), phone: nextPhone() })
1137
+ const newEmail = emailFor('lock-n1-new')
1138
+ const newPhone = nextPhone()
1139
+
1140
+ // generate_auth_token rather than .authenticate — repeated logins hit the rate limiter.
1141
+ const portalSDK = new EnduserSession({ host, businessId })
1142
+ await authenticate_enduser_via_token(sdk, portalSDK, { id: patient.id })
1143
+
1144
+ const prepared = await sdk.api.form_responses.prepare_form_response({
1145
+ formId: intake.form.id, enduserId: patient.id,
1146
+ })
1147
+ await portalSDK.api.form_responses.submit_form_response({
1148
+ accessCode: prepared.accessCode,
1149
+ responses: intakeAnswers(intake, newEmail, newPhone),
1150
+ })
1151
+
1152
+ const { enduser } = await pollEnduser(patient.id, e => e.email === newEmail, AWAITED_WINDOW_MS)
1153
+ record('G17', 'authenticated portal intake still updates email', enduser?.email === newEmail,
1154
+ `email=${enduser?.email || '(unset)'} expected=${newEmail}`)
1155
+ record('G17', 'authenticated portal intake still updates phone', enduser?.phone === newPhone,
1156
+ `phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
1157
+ })
1158
+
1159
+ // ---------------------------------------------------------------------------------------------
1160
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1161
+ // ---------------------------------------------------------------------------------------------
1162
+ await runScenario('G18', 'staff intake submission still updates email/phone', async () => {
1163
+ const patient = await makeVictim('O1', { email: emailFor('lock-o1'), phone: nextPhone() })
1164
+ const newEmail = emailFor('lock-o1-new')
1165
+ const newPhone = nextPhone()
1166
+
1167
+ const prepared = await sdk.api.form_responses.prepare_form_response({
1168
+ formId: intake.form.id, enduserId: patient.id,
1169
+ })
1170
+ await sdk.api.form_responses.submit_form_response({
1171
+ accessCode: prepared.accessCode,
1172
+ responses: intakeAnswers(intake, newEmail, newPhone),
1173
+ })
1174
+
1175
+ const { enduser } = await pollEnduser(patient.id, e => e.email === newEmail, AWAITED_WINDOW_MS)
1176
+ record('G18', 'staff intake submission still updates email', enduser?.email === newEmail,
1177
+ `email=${enduser?.email || '(unset)'} expected=${newEmail}`)
1178
+ record('G18', 'staff intake submission still updates phone', enduser?.phone === newPhone,
1179
+ `phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
1180
+ })
1181
+
1182
+ // ---------------------------------------------------------------------------------------------
1183
+ // G19 / G20 — Cross-patient session via a null fname/lname match.
1184
+ //
1185
+ // In both handlers' $or, `email` and `dateOfBirth` get `|| "never"` sentinels but fname/lname are
1186
+ // interpolated RAW. `ignoreUndefined` is not set on the Mongo client (database.ts connect options;
1187
+ // driver 5.7.0 defaults it to false), and findOne does not strip undefined keys — so an absent
1188
+ // fname serializes to `{fname: null}`, which matches every document where the field is null OR
1189
+ // MISSING. fname/lname have no schema initializer, so a cold-started enduser has them absent.
1190
+ //
1191
+ // Net: a request supplying ONLY dateOfBirth queries {fname: null, lname: null, dateOfBirth: X} and
1192
+ // can match another patient, returning a session scoped to THEIR record. The cold-start pattern is
1193
+ // what manufactures nameless endusers, and a later intakeField: 'dateOfBirth' question fills the DOB.
1194
+ // ---------------------------------------------------------------------------------------------
1195
+
1196
+ // dateOfBirth is only string-validated and the handlers merely trim it, so a run-tokenized value is
1197
+ // accepted. Deliberately opaque rather than date-shaped, so match-vs-insert is unambiguous even when
1198
+ // this file runs inside the full suite (no reset_db between files).
1199
+ const namelessDob = `01-02-1990-${RUN}`
1200
+
1201
+ const makeNamelessVictim = async (tag: string) => {
1202
+ // No fname/lname at all — those fields have no initializer, so the keys stay ABSENT (not ''),
1203
+ // which is precisely what {fname: null} null-matches.
1204
+ const victim = await sdk.api.endusers.createOne({ dateOfBirth: `${namelessDob}-${tag}` } as any)
1205
+ createdEnduserIds.push(victim.id)
1206
+ const stored = await sdk.api.endusers.getOne(victim.id)
1207
+ return { victim, stored }
1208
+ }
1209
+
1210
+ const G19_NAME = 'DOB-only request does not hijack a nameless patient (form endpoint)'
1211
+ await runScenario('G19', G19_NAME, async () => {
1212
+ const { victim, stored } = await makeNamelessVictim('f')
1213
+
1214
+ // Probe validity: a stored '' would not be null-matched and would make this test vacuous.
1215
+ record('G19', 'nameless victim really has absent fname/lname (probe validity)',
1216
+ stored.fname === undefined && stored.lname === undefined,
1217
+ `fname=${JSON.stringify(stored.fname)} lname=${JSON.stringify(stored.lname)}`)
1218
+
1219
+ // Only dateOfBirth. No names, no email/phone, no skipMatch. Reachable because the intake fixture
1220
+ // sets intakePhone: 'hidden', which satisfies the endpoint's phone guard.
1221
+ const session = await startFormOn(intake.form.id, { dateOfBirth: stored.dateOfBirth })
1222
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1223
+
1224
+ record('G19', G19_NAME, session.enduserId !== victim.id,
1225
+ session.enduserId !== victim.id
1226
+ ? `inserted a new record (${session.enduserId}) instead of matching the victim`
1227
+ : `EXPLOIT: returned a session for the victim's record ${victim.id} from a DOB-only request`)
1228
+ })
1229
+
1230
+ const G20_NAME = 'DOB-only request does not hijack a nameless patient (booking endpoint)'
1231
+ await runScenario('G20', G20_NAME, async () => {
1232
+ const { victim, stored } = await makeNamelessVictim('b')
1233
+ const dob = stored.dateOfBirth!
1234
+
1235
+ // The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
1236
+ // reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
1237
+ // records carrying this run-unique DOB.
1238
+ await startBooking({ dateOfBirth: dob })
1239
+
1240
+ const withDob = await sdk.api.endusers.getSome({ filter: { dateOfBirth: dob } })
1241
+ withDob.forEach(e => { if (e.id !== victim.id) createdEnduserIds.push(e.id) })
1242
+
1243
+ record('G20', G20_NAME, withDob.length > 1,
1244
+ withDob.length > 1
1245
+ ? `inserted a new record (${withDob.length} total with this DOB) instead of matching the victim`
1246
+ : `EXPLOIT: matched the victim's record ${victim.id} from a DOB-only request — a booking session was issued for it`)
1247
+ })
1248
+
1249
+ // ---------------------------------------------------------------------------------------------
1250
+ // G21 — The match sentinel must never be stored as a patient's name.
1251
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1252
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1253
+ // ---------------------------------------------------------------------------------------------
1254
+ await runScenario('G21', 'match sentinel never leaks into stored fname/lname', async () => {
1255
+ const session = await startFormOn(intake.form.id, { dateOfBirth: `${namelessDob}-sentinel` })
1256
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1257
+
1258
+ const created = await sdk.api.endusers.getOne(session.enduserId)
1259
+ const leaked = (v: any) => v === '__never__' || v === 'never'
1260
+ record('G21', 'created enduser fname/lname are not the sentinel',
1261
+ !leaked(created.fname) && !leaked(created.lname),
1262
+ `fname=${JSON.stringify(created.fname)} lname=${JSON.stringify(created.lname)}`)
1263
+
1264
+ const [response] = await sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
1265
+ record('G21', 'form_responses fname/lname are not the sentinel',
1266
+ !leaked(response?.fname) && !leaked(response?.lname),
1267
+ `fname=${JSON.stringify(response?.fname)} lname=${JSON.stringify(response?.lname)}`)
1268
+ })
1269
+
1270
+ // ---------------------------------------------------------------------------------------------
1271
+ // G22 — The cold-start pattern: a public form started with NO intake info, which just creates an
1272
+ // Enduser with an ID. Intake answers (including the authentication fields email/phone) must still
1273
+ // populate that fresh record — the lock is scoped to "matched a pre-existing patient", not to
1274
+ // "any public session", precisely so this keeps working.
1275
+ // ---------------------------------------------------------------------------------------------
1276
+ const coldStartCase = async (label: string, args: any) => {
1277
+ const group = 'G22'
1278
+ const submittedEmail = emailFor(`cold-${label}`)
1279
+ const submittedPhone = nextPhone()
1280
+
1281
+ const session = await startFormOn(intake.form.id, args)
1282
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1283
+
1284
+ const created = await sdk.api.endusers.getOne(session.enduserId)
1285
+ record(group, `${label}: cold start creates a bare enduser`,
1286
+ !created.email && !created.phone && !!session.enduserId, describe(created))
1287
+
1288
+ await asEnduser(session.authToken).api.form_responses.submit_form_response({
1289
+ accessCode: session.accessCode,
1290
+ responses: intakeAnswers(intake, submittedEmail, submittedPhone),
1291
+ })
1292
+
1293
+ const { enduser } = await pollEnduser(session.enduserId, e => !!e.email && !!e.phone, AWAITED_WINDOW_MS)
1294
+ record(group, `${label}: intake email (an auth field) still populates the cold-started record`,
1295
+ enduser?.email === submittedEmail, `email=${enduser?.email || '(unset)'} expected=${submittedEmail}`)
1296
+ record(group, `${label}: intake phone (an auth field) still populates the cold-started record`,
1297
+ enduser?.phone === submittedPhone, `phone=${enduser?.phone || '(unset)'} expected=${submittedPhone}`)
1298
+ }
1299
+
1300
+ // The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
1301
+ await runScenario('G22', 'cold start with skipMatch (real webapp shape)',
1302
+ () => coldStartCase('skipMatch', { skipMatch: true }))
1303
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1304
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1305
+ await runScenario('G22', 'cold start without skipMatch',
1306
+ () => coldStartCase('no-skipMatch', {}))
1307
+
1308
+ // ---------------------------------------------------------------------------------------------
1309
+ // G23 — businessId must be required on session_for_public_form.
1310
+ //
1311
+ // addPublicEndpoint overwrites the schema's `required: true` for businessId with
1312
+ // mongoIdStringOptional (routing.ts, and its own "todo: make businessId required"), and TypeScript
1313
+ // cannot catch it because CustomAction declares businessId as a non-optional string.
1314
+ //
1315
+ // A falsy businessId then leaves restrictAccess as the IDENTITY function in
1316
+ // buildAllQueries/buildOrganizationWideQueries (database.ts): the tenant clause is not appended at
1317
+ // all, so every query in the handler runs across ALL tenants. It fails OPEN, which is worse than
1318
+ // matching nothing. login_enduser signs the JWT from enduser.businessId, not from the request, so
1319
+ // the returned token is a fully valid session on whatever record matched.
1320
+ //
1321
+ // session_for_public_appointment_booking already guards this (its own comment says the schema
1322
+ // cannot enforce it); this endpoint did not. Note the sentinel fix in G19/G20 does NOT cover this:
1323
+ // sentinels constrain which record shapes the filter can match, not which tenants it searches.
1324
+ // ---------------------------------------------------------------------------------------------
1325
+ const G23_NAME = 'session_for_public_form requires businessId'
1326
+ await runScenario('G23', G23_NAME, async () => {
1327
+ const victim = await makeVictim('P1', { email: emailFor('lock-bizid'), phone: nextPhone() })
1328
+
1329
+ // (a) THE ISSUE. Omit businessId entirely and name the patient directly via enduserId, which
1330
+ // reaches an untenanted findById — no name/DOB guessing required. Pre-fix this hands back a
1331
+ // working session for that patient; post-fix it must 400 before touching the database.
1332
+ const omitted = await post('/v1/session-for-public-form', {
1333
+ formId: intake.form.id,
1334
+ enduserId: victim.id,
1335
+ })
1336
+ if (omitted.data?.enduserId) createdEnduserIds.push(omitted.data.enduserId)
1337
+
1338
+ const blocked = omitted.status === 400 && omitted.data?.message === 'businessId is required'
1339
+ record('G23', `${G23_NAME} — omitted businessId is rejected`, blocked,
1340
+ blocked
1341
+ ? 'HTTP 400 businessId is required'
1342
+ : `EXPLOIT: HTTP ${omitted.status} — got enduserId=${omitted.data?.enduserId} authToken=${!!omitted.data?.authToken} for victim ${victim.id} with NO businessId, i.e. tenant filtering was disabled entirely`)
1343
+
1344
+ // (b) Contrast probe: a well-formed but WRONG businessId must not reach the patient. This is what
1345
+ // proves (a) is caused by the missing tenant clause rather than by the lookup always failing.
1346
+ const wrongBiz = await post('/v1/session-for-public-form', {
1347
+ formId: intake.form.id,
1348
+ enduserId: victim.id,
1349
+ businessId: '000000000000000000000001',
1350
+ })
1351
+ record('G23', `${G23_NAME} — a wrong businessId cannot reach the patient`,
1352
+ wrongBiz.status !== 200 && wrongBiz.data?.enduserId !== victim.id,
1353
+ `HTTP ${wrongBiz.status} ${JSON.stringify(wrongBiz.data?.message ?? wrongBiz.data?.enduserId ?? '')}`)
1354
+
1355
+ // (c) Malformed businessId. The router's own mongoIdStringOptional already rejects this, so this
1356
+ // pins that the guard's second clause does not regress the rejection.
1357
+ const badBiz = await post('/v1/session-for-public-form', {
1358
+ formId: intake.form.id,
1359
+ enduserId: victim.id,
1360
+ businessId: 'not-a-mongo-id',
1361
+ })
1362
+ record('G23', `${G23_NAME} — a malformed businessId is rejected`, badBiz.status === 400,
1363
+ `HTTP ${badBiz.status} ${JSON.stringify(badBiz.data?.message ?? '')}`)
1364
+ })
1365
+
1366
+ // ---------------------------------------------------------------------------------------------
1367
+ // G24 / G25 — The token prepare_form_response mints drops `fromPublicSession`.
1368
+ //
1369
+ // The lock in prepare_form_response keys off session.fromPublicSession, but the token it mints
1370
+ // preserved the session's confinement WITHOUT that flag and handed it back in fullURL (?token=...).
1371
+ // Since prepare_form_response is itself in the 'public-form' session scope, a caller can hop twice:
1372
+ // the first hop's response is locked, the second hop's is not — because by then the session no longer
1373
+ // looks like a public one. Submitting an intakeField: 'email' answer against that second response
1374
+ // then repoints the victim's login channel.
1375
+ //
1376
+ // The same flag also gates PHI on refresh_session (G25): when it is falsy the handler returns
1377
+ // redact_field(enduser, 'hashedPassword') — the whole record — instead of just assignedTo.
1378
+ // ---------------------------------------------------------------------------------------------
1379
+ const chainForVictim = async (tag: string, victim: Enduser) => {
1380
+ const t1 = await startFormOn(intake.form.id, {
1381
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1382
+ })
1383
+ if (t1.enduserId) createdEnduserIds.push(t1.enduserId)
1384
+ if (t1.enduserId !== victim.id) return { matched: false as const, t1, t2: undefined, hop1: undefined }
1385
+
1386
+ const hop1 = await asEnduser(t1.authToken).api.form_responses.prepare_form_response({
1387
+ formId: pivotIntake.form.id, enduserId: victim.id,
1388
+ } as any)
1389
+ return { matched: true as const, t1, hop1, t2: tokenFromURL((hop1 as any)?.fullURL) }
1390
+ }
1391
+
1392
+ const G24_NAME = 'chained prepare_form_response session stays locked'
1393
+ await runScenario('G24', G24_NAME, async () => {
1394
+ const victimEmail = emailFor('lock-q1')
1395
+ const victim = await makeVictim('Q1', { email: victimEmail, phone: nextPhone() })
1396
+ const attackerEmail = emailFor('atk-q1')
1397
+
1398
+ const { matched, t2 } = await chainForVictim('Q1', victim as Enduser)
1399
+ if (!matched) {
1400
+ record('G24', G24_NAME, false, `probe invalid — first hop did not match victim ${victim.id}`)
1401
+ return
1402
+ }
1403
+ record('G24', 'chained token is returned in fullURL (probe validity)', !!t2, `token extracted=${!!t2}`)
1404
+ if (!t2) return
1405
+
1406
+ // Second hop, using the token the first hop handed back.
1407
+ const hop2 = await asEnduser(t2).api.form_responses.prepare_form_response({
1408
+ formId: pivotIntake.form.id, enduserId: victim.id,
1409
+ } as any)
1410
+ record('G24', 'second-hop prepare succeeds (probe validity)', !!hop2?.accessCode,
1411
+ `accessCode=${!!hop2?.accessCode}`)
1412
+ if (!hop2?.accessCode) return
1413
+
1414
+ const [hop2Response] = await sdk.api.form_responses.getSome({ filter: { accessCode: hop2.accessCode } })
1415
+ record('G24', `${G24_NAME} — second-hop response carries the lock`,
1416
+ (hop2Response as any)?.authFieldIntakeDisabled === true,
1417
+ `authFieldIntakeDisabled=${JSON.stringify((hop2Response as any)?.authFieldIntakeDisabled)}`)
1418
+
1419
+ // The assertion that actually matters: does the victim's login channel move?
1420
+ await asEnduser(t2).api.form_responses.submit_form_response({
1421
+ accessCode: hop2.accessCode,
1422
+ responses: intakeAnswers(pivotIntake, attackerEmail, nextPhone()),
1423
+ })
1424
+ const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
1425
+ record('G24', `${G24_NAME} — victim email unchanged after chained submit`,
1426
+ enduser?.email === victimEmail,
1427
+ enduser?.email === victimEmail
1428
+ ? `email still ${victimEmail}`
1429
+ : `EXPLOIT: chained session wrote ${enduser?.email} via an intake answer`)
1430
+ })
1431
+
1432
+ const G25_NAME = 'chained session does not expose the enduser record on refresh'
1433
+ await runScenario('G25', G25_NAME, async () => {
1434
+ const victim = await makeVictim('R1', { email: emailFor('lock-r1'), phone: nextPhone() })
1435
+ const { matched, t1, t2 } = await chainForVictim('R1', victim as Enduser)
1436
+ if (!matched) {
1437
+ record('G25', G25_NAME, false, `probe invalid — first hop did not match victim ${victim.id}`)
1438
+ return
1439
+ }
1440
+
1441
+ const PHI_KEYS = ['email', 'phone', 'dateOfBirth', 'fname', 'lname'] as const
1442
+ const leakedIn = (body: any) => PHI_KEYS.filter(k => (body?.enduser ?? {})[k] !== undefined)
1443
+
1444
+ // Control: the ORIGINAL public token must already withhold. If this fails the test proves nothing.
1445
+ const base = await postAuthed('/v1/refresh-enduser-session', {}, t1.authToken)
1446
+ record('G25', 'baseline: original public token withholds the record (control)',
1447
+ base.status === 200 && leakedIn(base.data).length === 0,
1448
+ `HTTP ${base.status} leaked=[${leakedIn(base.data).join(', ')}]`)
1449
+
1450
+ if (!t2) { record('G25', G25_NAME, false, 'probe invalid — no chained token'); return }
1451
+ const chained = await postAuthed('/v1/refresh-enduser-session', {}, t2)
1452
+ const leaked = leakedIn(chained.data)
1453
+ record('G25', G25_NAME, chained.status === 200 && leaked.length === 0,
1454
+ leaked.length === 0
1455
+ ? `HTTP ${chained.status}, no PHI fields returned`
1456
+ : `EXPLOIT: refresh under the chained token returned [${leaked.join(', ')}] for victim ${victim.id}`)
1457
+ })
1458
+
1459
+ // ---------------------------------------------------------------------------------------------
1460
+ // G26 — Auto-merge re-points the response after the lock was computed.
1461
+ //
1462
+ // authFieldIntakeDisabled is a snapshot taken at session start (`!!existing`). With skipMatch it is
1463
+ // correctly false, because a fresh record really was inserted. But on submission
1464
+ // resolveAutoMergeBeforeSubmission repoints the response at a PRE-EXISTING patient and persists only
1465
+ // enduserId — so the intake write later reads a stale `false` and lands email/phone on the victim.
1466
+ // The merge matches on { fname, lname, dateOfBirth }, so the attacker never needs to know the
1467
+ // victim's email or phone.
1468
+ //
1469
+ // performAutoMerge copies no demographic fields itself, so the intake write is the only channel —
1470
+ // which is why the custom-field assertion below is essential: it proves the write RAN, so "email
1471
+ // unchanged" cannot pass just because nothing happened.
1472
+ // ---------------------------------------------------------------------------------------------
1473
+ const G26_NAME = 'auto-merge does not let an intake answer repoint the merged patient'
1474
+ await runScenario('G26', G26_NAME, async () => {
1475
+ const victimEmail = emailFor('lock-s1')
1476
+ const victimPhone = nextPhone()
1477
+ const victimDob = '07-08-1988'
1478
+ const victim = await sdk.api.endusers.createOne({
1479
+ fname: `Lock${RUN}`,
1480
+ lname: `Merge${RUN}S1`,
1481
+ dateOfBirth: victimDob,
1482
+ email: victimEmail,
1483
+ phone: victimPhone,
1484
+ } as any)
1485
+ createdEnduserIds.push(victim.id)
1486
+ const attackerEmail = emailFor('atk-s1')
1487
+
1488
+ // skipMatch inserts a fresh record and flags it eligibleForAutoMerge.
1489
+ const session = await startFormOn(autoMergeIntake.form.id, { skipMatch: true })
1490
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1491
+ const sourceId = session.enduserId
1492
+
1493
+ const f = autoMergeIntake
1494
+ await asEnduser(session.authToken).api.form_responses.submit_form_response({
1495
+ accessCode: session.accessCode,
1496
+ responses: [
1497
+ { fieldId: f.fname.id, fieldTitle: f.fname.title, answer: { type: 'string' as const, value: victim.fname } },
1498
+ { fieldId: f.lname.id, fieldTitle: f.lname.title, answer: { type: 'string' as const, value: victim.lname } },
1499
+ { fieldId: f.dateOfBirth.id, fieldTitle: f.dateOfBirth.title, answer: { type: 'dateString' as const, value: victimDob } },
1500
+ { fieldId: f.email.id, fieldTitle: f.email.title, answer: { type: 'email' as const, value: attackerEmail } },
1501
+ { fieldId: f.custom.id, fieldTitle: f.custom.title, answer: { type: 'string' as const, value: 'merged-ok' } },
1502
+ ] as any,
1503
+ })
1504
+
1505
+ const { enduser: after } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
1506
+
1507
+ const merged = (after?.mergedIds ?? []).includes(sourceId)
1508
+ record('G26', 'auto-merge actually resolved to the victim (probe validity)', merged,
1509
+ `mergedIds=${JSON.stringify(after?.mergedIds)} source=${sourceId}`)
1510
+
1511
+ const customLanded = (after as any)?.fields?.[MERGE_CUSTOM_KEY] === 'merged-ok'
1512
+ record('G26', 'non-auth intake fields still reconcile onto the merged patient (anti-vacuity)',
1513
+ customLanded, `fields.${MERGE_CUSTOM_KEY}=${JSON.stringify((after as any)?.fields?.[MERGE_CUSTOM_KEY])}`)
1514
+
1515
+ record('G26', `${G26_NAME} — email`, after?.email === victimEmail,
1516
+ after?.email === victimEmail
1517
+ ? `email still ${victimEmail}`
1518
+ : `EXPLOIT: auto-merge intake write set ${after?.email} on the merged-into victim`)
1519
+ record('G26', `${G26_NAME} — phone`, after?.phone === victimPhone,
1520
+ after?.phone === victimPhone ? `phone still ${victimPhone}` : `EXPLOIT: phone became ${after?.phone}`)
1521
+ })
1522
+
1523
+ // ---------------------------------------------------------------------------------------------
1524
+ // G27 — The STAFF merge re-points responses too, and must also apply the lock.
1525
+ //
1526
+ // Same shape as G26 but through endusers.merge rather than autoMergeOnSubmission: the response is
1527
+ // created by a skipMatch public session (so the flag is correctly false — a fresh record really was
1528
+ // inserted), then staff resolve that ephemeral duplicate into a real patient. The merge loop copies
1529
+ // enduserId onto every model that has one, so without the fix the response ends up attached to a
1530
+ // patient who pre-existed it while still carrying a stale `false`.
1531
+ //
1532
+ // Note the source enduser is DELETED by the merge, which kills the original public token — so the
1533
+ // stored flag is the load-bearing assertion here, not a follow-up submit.
1534
+ // ---------------------------------------------------------------------------------------------
1535
+ const G27_NAME = 'staff merge applies the lock when it repoints a response'
1536
+ await runScenario('G27', G27_NAME, async () => {
1537
+ const victimEmail = emailFor('lock-t1')
1538
+ const victim = await makeVictim('T1', { email: victimEmail, phone: nextPhone() })
1539
+
1540
+ const session = await startFormOn(intake.form.id, { skipMatch: true })
1541
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1542
+
1543
+ const [beforeMerge] = await sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
1544
+ // Probe validity: if this were already true the test could not distinguish the fix from the
1545
+ // pre-existing state.
1546
+ record('G27', 'skipMatch response starts unlocked (probe validity)',
1547
+ (beforeMerge as any)?.authFieldIntakeDisabled === false,
1548
+ `authFieldIntakeDisabled=${JSON.stringify((beforeMerge as any)?.authFieldIntakeDisabled)}`)
1549
+
1550
+ await sdk.api.endusers.merge({
1551
+ sourceEnduserId: session.enduserId,
1552
+ destinationEnduserId: victim.id,
1553
+ })
1554
+
1555
+ const [afterMerge] = await sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
1556
+ const repointed = afterMerge?.enduserId === victim.id
1557
+ record('G27', 'merge repointed the response at the pre-existing patient (probe validity)', repointed,
1558
+ `enduserId=${afterMerge?.enduserId} expected=${victim.id}`)
1559
+
1560
+ record('G27', G27_NAME, (afterMerge as any)?.authFieldIntakeDisabled === true,
1561
+ (afterMerge as any)?.authFieldIntakeDisabled === true
1562
+ ? 'authFieldIntakeDisabled=true'
1563
+ : `EXPLOIT: response now points at pre-existing patient ${victim.id} with `
1564
+ + `authFieldIntakeDisabled=${JSON.stringify((afterMerge as any)?.authFieldIntakeDisabled)} — `
1565
+ + `an intake email/phone answer would repoint their login channel`)
1566
+
1567
+ // The victim must be untouched by the merge itself, so a later failure can only come from an
1568
+ // intake write.
1569
+ const stillVictim = await sdk.api.endusers.getOne(victim.id).catch(() => null)
1570
+ record('G27', 'merge did not itself move the victim email', stillVictim?.email === victimEmail,
1571
+ `email=${stillVictim?.email || '(unset)'} expected=${victimEmail}`)
1572
+ })
1573
+
1574
+ // ---------------------------------------------------------------------------------------------
1575
+ // G28 — SCOPE BOUNDARY for the Stripe intake grace.
1576
+ //
1577
+ // The formsort / form-ingestion webhook re-opens a BLANK email/phone for one hour after the Stripe
1578
+ // integration creates a contact (enduser_is_provisional_stripe_record, exercised by G12/R12-R15 in
1579
+ // formsort_webhook_auth_field_lock.test.ts). That endpoint is API-key authenticated. THIS path is
1580
+ // anonymous, and its match only needs a name + DOB or an email, so the same exemption here would
1581
+ // hand an attacker an OTP login channel on a patient who just paid. It must not apply.
1582
+ //
1583
+ // This gate exists because the tempting refactor — moving the grace down into
1584
+ // save_enduser_updates_for_form_response, the single chokepoint — would silently extend it to every
1585
+ // caller including this one, with no other test noticing.
1586
+ // ---------------------------------------------------------------------------------------------
1587
+ const G28_NAME = 'a fresh Stripe-created patient gets no grace on the public form path'
1588
+ await runScenario('G28', G28_NAME, async () => {
1589
+ const victimEmail = emailFor('lock-stripe')
1590
+ // A real Stripe-created record has no dateOfBirth (Stripe never supplies one), so it is only
1591
+ // reachable by email. makeVictim's DOB is kept purely so this probe can match WITHOUT passing an
1592
+ // email to the endpoint, which isolates the intake answers as the only possible source of a write.
1593
+ const victim = await makeVictim('Stripe', {
1594
+ email: victimEmail,
1595
+ source: 'Stripe',
1596
+ stripeCustomerId: `cus_${RUN}g28`,
1597
+ } as any)
1598
+ const attackerEmail = emailFor('atk-stripe')
1599
+ const attackerPhone = nextPhone()
1600
+
1601
+ const session = await startFormOn(intake.form.id, {
1602
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1603
+ })
1604
+ if (session.enduserId) createdEnduserIds.push(session.enduserId)
1605
+ if (session.enduserId !== victim.id) {
1606
+ record('G28', G28_NAME, false,
1607
+ `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
1608
+ return
1609
+ }
1610
+
1611
+ await asEnduser(session.authToken).api.form_responses.submit_form_response({
1612
+ accessCode: session.accessCode,
1613
+ responses: intakeAnswers(intake, attackerEmail, attackerPhone),
1614
+ })
1615
+
1616
+ const { enduser } = await pollEnduser(
1617
+ victim.id,
1618
+ e => e.email === attackerEmail || e.phone === attackerPhone,
1619
+ OVERWRITE_WINDOW_MS,
1620
+ )
1621
+ // phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
1622
+ record('G28', `${G28_NAME} — phone`, !enduser?.phone,
1623
+ enduser?.phone
1624
+ ? `EXPLOIT: intake answer wrote phone ${enduser.phone} on a Stripe-created patient (was unset)`
1625
+ : 'phone still (unset)')
1626
+ record('G28', `${G28_NAME} — email`, enduser?.email === victimEmail,
1627
+ enduser?.email === victimEmail
1628
+ ? `email still ${victimEmail}`
1629
+ : `EXPLOIT: intake answer wrote ${enduser?.email} (was ${victimEmail})`)
1630
+ })
1631
+
1632
+ // ---------------------------------------------------------------------------------------------
1633
+ // Report
1634
+ // ---------------------------------------------------------------------------------------------
1635
+ const failures = results.filter(r => !r.ok)
1636
+ const skipped = results.filter(r => !!r.skipped)
1637
+ console.log('')
1638
+ console.log(' ┌─ Public endpoint auth-field lock — summary ────────────────────────────')
1639
+ for (const r of results) {
1640
+ const status = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'
1641
+ console.log(` │ ${status} ${r.group.padEnd(3)} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`)
1642
+ }
1643
+ console.log(` └─ ${results.length - failures.length}/${results.length} passed, ${skipped.length} skipped, ${failures.length} failed`)
1644
+ console.log('')
1645
+
1646
+ // ---------------------------------------------------------------------------------------------
1647
+ // Cleanup — MUST run before the assert loop (see the note at the top of this file).
1648
+ // ---------------------------------------------------------------------------------------------
1649
+ for (const id of createdEventIds) {
1650
+ await sdk.api.calendar_events.deleteOne(id).catch(() => null)
1651
+ }
1652
+ for (const formId of createdFormIds) {
1653
+ const responses = await sdk.api.form_responses.getSome({ filter: { formId } }).catch(() => [])
1654
+ for (const response of responses) {
1655
+ await sdk.api.form_responses.deleteOne(response.id).catch(() => null)
1656
+ }
1657
+ }
1658
+ for (const id of createdFormFieldIds) {
1659
+ await sdk.api.form_fields.deleteOne(id).catch(() => null)
1660
+ }
1661
+ for (const id of createdFormIds) {
1662
+ await sdk.api.forms.deleteOne(id).catch(() => null)
1663
+ }
1664
+ const uniqueEnduserIds = createdEnduserIds.filter((id, i) => !!id && createdEnduserIds.indexOf(id) === i)
1665
+ for (const id of uniqueEnduserIds) {
1666
+ await sdk.api.endusers.deleteOne(id).catch(() => null)
1667
+ }
1668
+ for (const id of createdTemplateIds) {
1669
+ await sdk.api.calendar_event_templates.deleteOne(id).catch(() => null)
1670
+ }
1671
+ for (const id of createdUserIds) {
1672
+ await sdk.api.users.deleteOne(id).catch(() => null)
1673
+ }
1674
+
1675
+ // ---------------------------------------------------------------------------------------------
1676
+ // Assert — single pass, last, so the full table above is always printed first.
1677
+ // ---------------------------------------------------------------------------------------------
1678
+ for (const r of results) {
1679
+ assert(
1680
+ r.ok,
1681
+ `❌ [${r.group}] ${r.name} — ${r.detail}`,
1682
+ `[${r.group}] ${r.name}${r.skipped ? ' (SKIPPED)' : ''}`,
1683
+ )
1684
+ }
1685
+ }
1686
+
1687
+ // Allow running this test file independently
1688
+ if (require.main === module) {
1689
+ console.log(`🌐 Using API URL: ${host}`)
1690
+ const sdk = new Session({ host })
1691
+ const sdkNonAdmin = new Session({ host })
1692
+
1693
+ const runTests = async () => {
1694
+ await setup_tests(sdk, sdkNonAdmin)
1695
+ await public_endpoint_auth_field_lock_tests({ sdk, sdkNonAdmin })
1696
+ }
1697
+
1698
+ runTests()
1699
+ .then(() => {
1700
+ console.log("✅ Public endpoint auth field lock test suite completed successfully")
1701
+ process.exit(0)
1702
+ })
1703
+ .catch((error) => {
1704
+ console.error("❌ Public endpoint auth field lock test suite failed:", error)
1705
+ process.exit(1)
1706
+ })
1707
+ }