@tellescope/utilities 1.255.18 → 1.256.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.ts CHANGED
@@ -791,6 +791,158 @@ export const sanitize_user_html = (html: string) => {
791
791
  })
792
792
  }
793
793
 
794
+ /**
795
+ * Sandbox forced onto an `<iframe>` by {@link sanitize_user_html_with_iframes} when its author did
796
+ * not supply one.
797
+ *
798
+ * `allow-same-origin` is required in practice: without it the frame gets an opaque origin, where
799
+ * `localStorage` access throws and the embed's own CSP `'self'` matches nothing. Real embeds break —
800
+ * a Loom embed renders as a blank box, verified in Chrome. Since the sanitizer requires an absolute
801
+ * cross-origin `https` src, granting it gives the frame nothing an unsandboxed frame wouldn't have.
802
+ *
803
+ * What is deliberately withheld matters more than what is granted. Above all `allow-top-navigation`:
804
+ * a hostile embed cannot navigate the patient's page elsewhere. Also withheld: `allow-downloads`,
805
+ * `allow-modals`, `allow-pointer-lock`, `allow-orientation-lock`, `allow-top-navigation-by-user-activation`.
806
+ */
807
+ export const DEFAULT_IFRAME_SANDBOX =
808
+ 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation'
809
+
810
+ /**
811
+ * Same as {@link sanitize_user_html}, but additionally permits `<iframe>` so authored content can
812
+ * embed video, scheduling widgets, etc. `sanitize_user_html` remains the default — only reach for
813
+ * this variant on a surface that specifically needs framing.
814
+ *
815
+ * Preconditions for any new caller:
816
+ * - **Admin/staff-authored HTML only.** Never use this for patient-authored content (form answers
817
+ * where `answerIsHTML`, chat messages, community posts) or for inbound email.
818
+ * - **Any patient-derived substring templated into the HTML must be entity-escaped first** — see
819
+ * `escapeHTMLValues` on {@link replace_form_field_template_values}. Otherwise "admin-authored"
820
+ * isn't actually true and a patient can inject a frame into an admin's description.
821
+ *
822
+ * How the frame is contained — the two controls work together, do not weaken either alone:
823
+ *
824
+ * 1. `src` must be an absolute `https://host` URL. Relative, protocol-relative (`//host`),
825
+ * `javascript:`, `data:` and `srcdoc` frames are dropped, so the frame is always cross-origin
826
+ * and the same-origin policy alone already prevents it from touching this document. Note that
827
+ * sanitize-html does NOT reject protocol-relative iframe srcs on its own when
828
+ * `allowProtocolRelative` is set, so that check lives in `transformTags` below.
829
+ * 2. {@link DEFAULT_IFRAME_SANDBOX} is forced when the author supplied none. Critically it withholds
830
+ * `allow-top-navigation`, so a hostile or compromised embed cannot redirect the patient's page to
831
+ * a phishing site — the realistic threat for an embed in a patient-facing form. It also withholds
832
+ * downloads, modals, pointer-lock, plugins and orientation-lock. It is therefore strictly more
833
+ * restrictive than rendering the same cross-origin iframe with no sandbox at all.
834
+ *
835
+ * It DOES grant `allow-same-origin`, which for a cross-origin `src` grants nothing a normal
836
+ * unsandboxed frame wouldn't already have (its own origin, its own storage) — see
837
+ * DEFAULT_IFRAME_SANDBOX for why that is necessary in practice. `allow-same-origin` would only be
838
+ * dangerous for a *same-origin* `src`, since such a frame could script this document; control 1 is
839
+ * what rules that out, which is why the src check must not be relaxed.
840
+ *
841
+ * An author-supplied `sandbox` is honored verbatim as an escape hatch (including `sandbox=""`, the
842
+ * most restrictive value), so a surface can tighten or loosen per embed.
843
+ *
844
+ * Current callers: the live form's field description (`Forms/forms.tsx`, `Forms/forms.v2.tsx`).
845
+ * The read-only submitted-response views intentionally stay on `sanitize_user_html`.
846
+ */
847
+ export const sanitize_user_html_with_iframes = (html: string) => {
848
+ if (typeof html !== 'string' || !html) return ''
849
+ return sanitizeHtml(html, {
850
+ allowedTags: [
851
+ // text & inline
852
+ 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em', 'i', 'kbd',
853
+ 'mark', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup',
854
+ 'time', 'u', 'var', 'wbr', 'del', 'ins', 'abbr',
855
+ // block & structure
856
+ 'address', 'article', 'aside', 'blockquote', 'caption', 'details', 'summary', 'div',
857
+ 'figcaption', 'figure', 'footer', 'header', 'hgroup', 'hr', 'main', 'nav', 'p', 'pre',
858
+ 'section',
859
+ // headings
860
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
861
+ // lists
862
+ 'dd', 'dl', 'dt', 'li', 'ol', 'ul',
863
+ // tables
864
+ 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
865
+ // media (still no object/embed — those can execute arbitrary plugin content)
866
+ 'img', 'audio', 'video', 'source', 'picture', 'track',
867
+ 'iframe',
868
+ ],
869
+ allowedAttributes: {
870
+ // NOTE: `id`/`name` are intentionally omitted — caller-controlled id/name enable DOM
871
+ // clobbering (shadowing document/global properties) and duplicate-id breakage, with no
872
+ // legitimate need in rendered user content.
873
+ '*': [
874
+ 'style', 'class', 'title', 'dir', 'lang', 'align', 'valign', 'width', 'height',
875
+ 'color', 'bgcolor', 'aria-*', 'data-*', 'role',
876
+ ],
877
+ a: ['href', 'target', 'rel'],
878
+ img: ['src', 'srcset', 'sizes', 'alt', 'loading', 'decoding'],
879
+ audio: ['controls', 'src', 'preload', 'loop', 'muted'],
880
+ video: ['controls', 'src', 'poster', 'preload', 'loop', 'muted', 'playsinline'],
881
+ source: ['src', 'srcset', 'type', 'media', 'sizes'],
882
+ track: ['src', 'kind', 'srclang', 'label', 'default'],
883
+ // No `srcdoc` (an inline document is arbitrary markup) and no `name` (named-window
884
+ // clobbering). `sandbox`/`referrerpolicy` MUST be listed here: transformTags runs BEFORE
885
+ // attribute filtering, so the values forced below are filtered too and would be dropped.
886
+ iframe: [
887
+ 'src', 'sandbox', 'referrerpolicy', 'allow', 'allowfullscreen', 'frameborder', 'loading',
888
+ ],
889
+ col: ['span'],
890
+ colgroup: ['span'],
891
+ td: ['colspan', 'rowspan', 'headers'],
892
+ th: ['colspan', 'rowspan', 'headers', 'scope'],
893
+ ol: ['start', 'reversed', 'type'],
894
+ time: ['datetime'],
895
+ data: ['value'],
896
+ del: ['datetime'],
897
+ ins: ['datetime'],
898
+ },
899
+ // Only safe protocols. javascript:/vbscript: are not listed and are stripped.
900
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
901
+ allowedSchemesByTag: {
902
+ img: ['http', 'https', 'data'],
903
+ source: ['http', 'https', 'data'],
904
+ video: ['http', 'https', 'data'],
905
+ audio: ['http', 'https', 'data'],
906
+ iframe: ['https'],
907
+ },
908
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'srcset'],
909
+ allowProtocolRelative: true,
910
+ allowIframeRelativeUrls: false,
911
+ transformTags: {
912
+ // Harden external links against reverse-tabnabbing.
913
+ a: (tagName, attribs) => {
914
+ const href = attribs.href || ''
915
+ if (href.startsWith('http://') || href.startsWith('https://')) {
916
+ return { tagName, attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer' } }
917
+ }
918
+ return { tagName, attribs }
919
+ },
920
+ iframe: (tagName, attribs) => {
921
+ // Strip the characters browsers ignore in URLs, matching sanitize-html's own naughtyHref.
922
+ const src = (attribs.src || '').replace(/[\x00-\x20]+/g, '')
923
+ // Absolute https with a non-empty host, only. Dropping `src` hands the element to
924
+ // exclusiveFilter below, which removes it along with its fallback content.
925
+ if (!/^https:\/\/[^\s/?#\\]+/i.test(src)) return { tagName, attribs: {} }
926
+
927
+ return {
928
+ tagName,
929
+ attribs: {
930
+ ...attribs,
931
+ src,
932
+ // `??` rather than `||`: sandbox="" serializes as a bare `sandbox`, which is the MOST
933
+ // restrictive value, so an explicit empty string must be honored rather than replaced.
934
+ sandbox: attribs.sandbox ?? DEFAULT_IFRAME_SANDBOX,
935
+ referrerpolicy: attribs.referrerpolicy || 'strict-origin-when-cross-origin',
936
+ },
937
+ }
938
+ },
939
+ },
940
+ // Removes src-less iframes AND their fallback content. Returning a non-allowlisted tagName from
941
+ // transformTags does NOT do this — sanitize-html drops the tag but emits the text inside it.
942
+ exclusiveFilter: frame => frame.tag === 'iframe' && !frame.attribs.src,
943
+ })
944
+ }
945
+
794
946
  export const query_string_for_object = (query: Indexable) => {
795
947
  let queryString = ''
796
948
 
@@ -1655,7 +1807,7 @@ const URIReplacements = {
1655
1807
  "=28": "(",
1656
1808
  "=29": ")",
1657
1809
  "=2A": "*",
1658
- "=2B": "=+",
1810
+ "=2B": "+",
1659
1811
  "=2C": ",",
1660
1812
  "=2D": "-",
1661
1813
  "=2E": ".",
@@ -1995,6 +2147,8 @@ const URIReplacements = {
1995
2147
  "=C3=BF":"", // "ÿ"
1996
2148
  }
1997
2149
 
2150
+ // raw quoted-printable decoder - display code must go through decode_email_for_display instead,
2151
+ // which only applies this to inbound bodies (outbound bodies are never QP encoded)
1998
2152
  export const URIDecodeEmail = (content: string, verbose?: boolean) => (
1999
2153
  content
2000
2154
  .replace(/=\s/gi, '')
@@ -2020,6 +2174,18 @@ export const URIDecodeEmail = (content: string, verbose?: boolean) => (
2020
2174
  )
2021
2175
  )
2022
2176
 
2177
+ /* Outbound email bodies are stored exactly as handed to the mail provider (worker/app.js
2178
+ handleOutgoingEmail persists message.htmlmessage verbatim; SES/Gmail/Outlook/Paubox apply
2179
+ their own transfer encoding), so they are never quoted-printable encoded. Only inbound MIME
2180
+ bodies, parsed by worker/modules/email_parser.js, can be. Decoding an outbound body corrupts
2181
+ it: `?id=500044` -> `?idP0044`, `&sid=3El` -> `&sid>l`. */
2182
+ export const decode_email_for_display = (
2183
+ content: string,
2184
+ { inbound }: { inbound?: boolean },
2185
+ ) => (
2186
+ inbound === true ? URIDecodeEmail(content) : content
2187
+ )
2188
+
2023
2189
  export const mfa_is_enabled = (u: { mfa?: User['mfa'] }) => (
2024
2190
  !!u?.mfa?.email || !!u?.mfa?.authenticator
2025
2191
  )
@@ -3010,18 +3176,36 @@ export const replace_order_template_values = (s: string, order?: Omit<EnduserOrd
3010
3176
  return replaced
3011
3177
  }
3012
3178
 
3179
+ // Entity-escapes a value being spliced into an HTML string. `&` must be replaced first.
3180
+ const escape_html_text = (s: string) => s
3181
+ .replace(/&/g, '&amp;')
3182
+ .replace(/</g, '&lt;')
3183
+ .replace(/>/g, '&gt;')
3184
+ .replace(/"/g, '&quot;')
3185
+ .replace(/'/g, '&#39;')
3186
+
3013
3187
  export const replace_form_field_template_values = (
3014
3188
  s: string,
3015
3189
  options: {
3016
3190
  enduser?: Partial<Enduser>,
3017
3191
  responses?: FormResponseValue[],
3018
3192
  escapeNewlinesAsHTMLBreaks?: boolean,
3193
+ /**
3194
+ * Entity-escape the substituted `{{enduser.*}}` values. Set this whenever the result is
3195
+ * rendered through a sanitizer that permits iframes ({@link sanitize_user_html_with_iframes}):
3196
+ * these values come from the patient's own intake answers / enduser record, so without escaping
3197
+ * a patient could inject a frame into an otherwise admin-authored description.
3198
+ *
3199
+ * Off by default because most callers substitute into plain text rendered by React (which
3200
+ * escapes already), where escaping here would corrupt the output — e.g. a name containing `&`.
3201
+ */
3202
+ escapeHTMLValues?: boolean,
3019
3203
  }
3020
3204
  ) => {
3021
3205
  if (!s) return s
3022
3206
  if (typeof s !== 'string') return s
3023
3207
 
3024
- const { enduser, responses = [], escapeNewlinesAsHTMLBreaks } = options
3208
+ const { enduser, responses = [], escapeNewlinesAsHTMLBreaks, escapeHTMLValues } = options
3025
3209
 
3026
3210
  let i = 0
3027
3211
  let start = 0
@@ -3115,6 +3299,11 @@ export const replace_form_field_template_values = (
3115
3299
  }
3116
3300
  }
3117
3301
 
3302
+ // Must run before the newline conversion below, or the injected breaks are escaped too
3303
+ // and render as literal '&lt;br /&gt;'.
3304
+ if (escapeHTMLValues) {
3305
+ replacement = escape_html_text(replacement)
3306
+ }
3118
3307
  if (escapeNewlinesAsHTMLBreaks) {
3119
3308
  replacement = replacement.replace(/\r\n|\r|\n|\\n/g, '<br />')
3120
3309
  }