@revenuecat/purchases-ui-js 4.7.2 → 4.7.4

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.
@@ -68,6 +68,53 @@
68
68
 
69
69
  <Story name="Default" args={{ name: "hello world!" }} />
70
70
 
71
+ <!--
72
+ Security regression: a localization value containing HTML/JS must render as
73
+ inert text, never execute. With the fix in place this story shows the literal
74
+ markup as text, the page is untouched, and `window.__XSS_FIRED` stays
75
+ undefined. If it ever executes, the page background turns red.
76
+ -->
77
+ <Story
78
+ name="XSS payload is escaped"
79
+ args={{
80
+ font_weight: "regular",
81
+ horizontal_alignment: "leading",
82
+ name: "hello world!",
83
+ text_lid,
84
+ }}
85
+ parameters={{
86
+ localizations: {
87
+ [defaultLocale]: {
88
+ [text_lid]:
89
+ 'Premium <img src=x onerror="window.__XSS_FIRED=true;document.body.style.background=\'red\'"> and <svg onload="window.__XSS_FIRED=true"></svg>',
90
+ },
91
+ } satisfies Localizations,
92
+ }}
93
+ />
94
+
95
+ <!--
96
+ Markdown links: safe http(s)/mailto/tel links are kept (and quoted), while
97
+ dangerous schemes (javascript:, data:, …) have their href dropped so the
98
+ label renders as plain, non-clickable text.
99
+ -->
100
+ <Story
101
+ name="Markdown links (safe kept, dangerous dropped)"
102
+ args={{
103
+ font_weight: "regular",
104
+ horizontal_alignment: "leading",
105
+ name: "hello world!",
106
+ text_lid,
107
+ }}
108
+ parameters={{
109
+ localizations: {
110
+ [defaultLocale]: {
111
+ [text_lid]:
112
+ "Safe: [RevenueCat](https://www.revenuecat.com) — Dangerous: [click me](javascript:window.__XSS_FIRED=true)",
113
+ },
114
+ } satisfies Localizations,
115
+ }}
116
+ />
117
+
71
118
  <Story
72
119
  name="Font Weight"
73
120
  args={{
@@ -58,11 +58,49 @@ export function getTextWrapperInlineStyles(colorMode, _restProps, size, backgrou
58
58
  ...mapBackground(colorMode, background_color, null),
59
59
  });
60
60
  }
61
+ const HTML_ESCAPE_MAP = {
62
+ "&": "&amp;",
63
+ "<": "&lt;",
64
+ ">": "&gt;",
65
+ '"': "&quot;",
66
+ "'": "&#39;",
67
+ };
68
+ /**
69
+ * Escapes the HTML-special characters in `text` so that user-controlled
70
+ * localization values cannot inject markup. None of the markdown control
71
+ * characters handled below are HTML-special, so escaping first is safe.
72
+ */
73
+ function escapeHtml(text) {
74
+ return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_MAP[character]);
75
+ }
76
+ const SAFE_URL_SCHEMES = ["http", "https", "mailto", "tel"];
77
+ /**
78
+ * Returns the URL if it is safe to use as a link `href`, otherwise `null`.
79
+ * Relative and protocol-relative URLs are allowed; absolute URLs are only
80
+ * allowed for an explicit scheme allowlist. Whitespace and control characters
81
+ * (code points <= 0x20) are stripped and the scheme is lowercased before
82
+ * comparison so obfuscated payloads such as "jAvA\tscript:" are still rejected.
83
+ */
84
+ function sanitizeUrl(url) {
85
+ const normalized = Array.from(url)
86
+ .filter((character) => character.charCodeAt(0) > 0x20)
87
+ .join("")
88
+ .toLowerCase();
89
+ const schemeMatch = normalized.match(/^([a-z][a-z0-9+.-]*):/);
90
+ if (schemeMatch && !SAFE_URL_SCHEMES.includes(schemeMatch[1])) {
91
+ return null;
92
+ }
93
+ return url;
94
+ }
61
95
  export function getHtmlFromMarkdown(text) {
62
96
  if (!text)
63
97
  return "";
98
+ // Escape HTML first so attacker-controlled markup (e.g. `<img onerror=...>`)
99
+ // can never reach the `{@html}` sink. After this, the only HTML in the output
100
+ // is the fixed tag allowlist produced by the rules below.
101
+ const escapedHtml = escapeHtml(text);
64
102
  const escapedMarkdownCharacters = [];
65
- const textWithEscapedMarkdownPlaceholders = text.replaceAll(/\\([\\`*_[\]{}()#+\-.!~])/g, (_, escapedCharacter) => {
103
+ const textWithEscapedMarkdownPlaceholders = escapedHtml.replaceAll(/\\([\\`*_[\]{}()#+\-.!~])/g, (_, escapedCharacter) => {
66
104
  escapedMarkdownCharacters.push(escapedCharacter);
67
105
  return `\0RC_ESCAPED_MARKDOWN_${escapedMarkdownCharacters.length - 1}\0`;
68
106
  });
@@ -77,11 +115,20 @@ export function getHtmlFromMarkdown(text) {
77
115
  },
78
116
  link: {
79
117
  regexp: /\[(.*?)\]\((.*?)\)/g,
80
- output: "<a href=$2 target='_blank' rel='noopener noreferrer'>$1</a>",
118
+ output: (_match, label, url) => {
119
+ const safeUrl = sanitizeUrl(url);
120
+ if (safeUrl === null) {
121
+ // Drop the unsafe href and render the (already escaped) label only.
122
+ return label;
123
+ }
124
+ return `<a href="${safeUrl}" target='_blank' rel='noopener noreferrer'>${label}</a>`;
125
+ },
81
126
  },
82
127
  };
83
128
  const parsedText = Object.values(regexpDictionary).reduce((parsedText, { regexp, output }) => {
84
- return parsedText.replaceAll(regexp, output);
129
+ return typeof output === "string"
130
+ ? parsedText.replaceAll(regexp, output)
131
+ : parsedText.replaceAll(regexp, output);
85
132
  }, textWithEscapedMarkdownPlaceholders);
86
133
  return escapedMarkdownCharacters.reduce((restoredText, escapedMarkdownCharacter, index) => restoredText.replaceAll(`\0RC_ESCAPED_MARKDOWN_${index}\0`, escapedMarkdownCharacter), parsedText);
87
134
  }
@@ -5,7 +5,7 @@
5
5
  import type { InitialInputSelections } from "../../stores/inputValidation";
6
6
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
7
7
  import type { WorkflowScreen } from "../../types/workflow";
8
- import type { VariableDictionary } from "../../types/variables";
8
+ import type { PackageInfo, VariableDictionary } from "../../types/variables";
9
9
  import type { WalletButtonRender } from "../../types/wallet";
10
10
  import type { UIConfig } from "../../types/ui-config";
11
11
  import type { ReservedAttribute } from "../../types/components/input-text";
@@ -26,6 +26,7 @@
26
26
  containerId?: string;
27
27
  maxContentWidth?: string;
28
28
  variablesPerPackage?: Record<string, VariableDictionary>;
29
+ infoPerPackage?: Record<string, PackageInfo>;
29
30
  initialInputSelections?: InitialInputSelections;
30
31
  onInputChanged?: (
31
32
  fieldId: string,
@@ -59,6 +60,7 @@
59
60
  containerId = "screen-container",
60
61
  maxContentWidth,
61
62
  variablesPerPackage,
63
+ infoPerPackage,
62
64
  initialInputSelections = {},
63
65
  onInputChanged,
64
66
  onReservedAttributeChanged,
@@ -78,6 +80,7 @@
78
80
  {selectedLocale}
79
81
  {uiConfig}
80
82
  {variablesPerPackage}
83
+ {infoPerPackage}
81
84
  {globalVariables}
82
85
  {maxContentWidth}
83
86
  {initialInputSelections}
@@ -3,7 +3,7 @@ import type { ColorScheme } from "../../types/colors";
3
3
  import type { InitialInputSelections } from "../../stores/inputValidation";
4
4
  import type { OnComponentInteraction } from "../../types/paywall-component-interaction";
5
5
  import type { WorkflowScreen } from "../../types/workflow";
6
- import type { VariableDictionary } from "../../types/variables";
6
+ import type { PackageInfo, VariableDictionary } from "../../types/variables";
7
7
  import type { WalletButtonRender } from "../../types/wallet";
8
8
  import type { UIConfig } from "../../types/ui-config";
9
9
  import type { ReservedAttribute } from "../../types/components/input-text";
@@ -21,6 +21,7 @@ interface Props {
21
21
  containerId?: string;
22
22
  maxContentWidth?: string;
23
23
  variablesPerPackage?: Record<string, VariableDictionary>;
24
+ infoPerPackage?: Record<string, PackageInfo>;
24
25
  initialInputSelections?: InitialInputSelections;
25
26
  onInputChanged?: (fieldId: string, value: string, actionId?: string) => void;
26
27
  onReservedAttributeChanged?: (reservedAttribute: ReservedAttribute, value: string) => void;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@revenuecat/purchases-ui-js",
3
3
  "description": "Web components for Paywalls. Powered by RevenueCat",
4
4
  "private": false,
5
- "version": "4.7.2",
5
+ "version": "4.7.4",
6
6
  "author": {
7
7
  "name": "RevenueCat, Inc."
8
8
  },