@shopgate/pwa-common 7.32.0-beta.20 → 7.32.0-beta.21

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.
@@ -6,6 +6,7 @@ import "core-js/modules/web.url-search-params.js";
6
6
  import { logger } from '@shopgate/pwa-core/helpers';
7
7
  import { DEEPLINK_CART_ADD_PRODUCT_PATTERN } from '@shopgate/pwa-common-commerce/cart/constants';
8
8
  import fetchProduct from '@shopgate/pwa-common-commerce/product/actions/fetchProduct';
9
+ import { sanitizeLink } from "../../helpers/router";
9
10
  import { historyPush, historyReset } from "../router";
10
11
  import { INDEX_PATH_DEEPLINK, INDEX_PATH } from "../../constants/RoutePaths";
11
12
 
@@ -24,8 +25,11 @@ export default function handleLink(payload, allowExternalLinks = false) {
24
25
  return;
25
26
  }
26
27
 
27
- // cast to string
28
- link = String(link);
28
+ // Cast to string and remove potentially malicious content from the external link
29
+ link = sanitizeLink(String(link));
30
+ if (!link) {
31
+ return;
32
+ }
29
33
  let pathname;
30
34
  if (link.startsWith('http')) {
31
35
  // Link is common URL schema.
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Unescape HTML entities.
3
+ * The input is assigned to a textarea element whose content is parsed as plain text, so markup
4
+ * within the input is never turned into DOM nodes and can't execute scripts.
3
5
  * @param {string} input The escaped HTML.
4
6
  * @returns {string} The unescaped HTML.
5
7
  */
6
8
  const decodeHTML = input => {
7
- const e = document.createElement('div');
8
- e.innerHTML = input;
9
- return e.childNodes.length === 0 ? '' : e.childNodes[0].nodeValue;
9
+ const textarea = document.createElement('textarea');
10
+ textarea.innerHTML = input;
11
+ return textarea.value;
10
12
  };
11
13
  export default decodeHTML;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Converts a string which might contain HTML markup or HTML entities into plain text.
3
+ * Tags are stripped and entities are decoded. The input is parsed within an inert document
4
+ * created by DOMParser, so scripts are not executed and no resources are loaded.
5
+ * @param {string} input The input string.
6
+ * @returns {string} The plain text.
7
+ */
8
+ const htmlToText = input => {
9
+ if (typeof input !== 'string' || input === '') {
10
+ return '';
11
+ }
12
+ const doc = new DOMParser().parseFromString(input, 'text/html');
13
+ return doc.body?.textContent ?? '';
14
+ };
15
+ export default htmlToText;
@@ -1,7 +1,9 @@
1
1
  import "core-js/modules/es.array.reduce.js";
2
+ import "core-js/modules/es.string.replace.js";
2
3
  import "core-js/modules/web.url-search-params.js";
3
4
  import { createBrowserHistory } from 'history';
4
5
  import { router } from '@virtuous/conductor';
6
+ import { logger } from '@shopgate/pwa-core/helpers';
5
7
  const match = /^(.*)index.html/.exec(window.location.pathname);
6
8
  const {
7
9
  getCurrentRoute
@@ -56,4 +58,87 @@ export const parseObjectToQueryString = (obj, includePrefix = true) => {
56
58
  return `?${urlParams.toString()}`;
57
59
  }
58
60
  return urlParams.toString();
61
+ };
62
+
63
+ // Angle brackets and their HTML entity representations.
64
+ const ANGLE_BRACKET_ENTITIES = '&(lt|gt|#0*6[02]|#x0*3[ce]);?';
65
+ const UNSAFE_CHARS_REGEX = new RegExp(`[<>]|${ANGLE_BRACKET_ENTITIES}`, 'i');
66
+ const UNSAFE_PROTOCOL_REGEX = /^\s*(javascript|data|vbscript):/i;
67
+
68
+ /**
69
+ * Removes HTML tags, angle brackets and their entity representations from a string.
70
+ * @param {string} value The (decoded) value.
71
+ * @returns {string}
72
+ */
73
+ const stripHtml = value => value.replace(new RegExp(ANGLE_BRACKET_ENTITIES, 'gi'), '').replace(/<[^>]*>/g, '').replace(/[<>]/g, '');
74
+
75
+ /**
76
+ * Removes HTML from a single URI component. The component is only modified when it contains
77
+ * suspicious characters, so regular components (e.g. encoded product ids) stay untouched.
78
+ * @param {string} component A single URI encoded component like a path segment.
79
+ * @returns {string}
80
+ */
81
+ const sanitizeUriComponent = component => {
82
+ let decoded;
83
+ try {
84
+ decoded = decodeURIComponent(component);
85
+ } catch (e) {
86
+ // Malformed encoding - fall back to the raw component.
87
+ decoded = component;
88
+ }
89
+ if (!UNSAFE_CHARS_REGEX.test(decoded)) {
90
+ return component;
91
+ }
92
+ return encodeURIComponent(stripHtml(decoded));
93
+ };
94
+
95
+ /**
96
+ * Removes HTML from a decoded query parameter key or value.
97
+ * @param {string} value The decoded value.
98
+ * @returns {string}
99
+ */
100
+ const sanitizeQueryValue = value => UNSAFE_CHARS_REGEX.test(value) ? stripHtml(value) : value;
101
+
102
+ /**
103
+ * Sanitizes a link which was received from an external source (e.g. deep links or push messages)
104
+ * before it's passed to the router. HTML markup is removed from the path, query and hash to prevent
105
+ * that it's rendered somewhere within the app. Links with a script protocol are rejected.
106
+ * Links without suspicious content are returned unchanged.
107
+ * @param {string} link The link to sanitize.
108
+ * @returns {string} The sanitized link or an empty string when the link was rejected.
109
+ */
110
+ export const sanitizeLink = link => {
111
+ if (typeof link !== 'string' || link === '') {
112
+ return '';
113
+ }
114
+ if (UNSAFE_PROTOCOL_REGEX.test(link)) {
115
+ logger.warn('sanitizeLink: Rejected link with unsafe protocol', link);
116
+ return '';
117
+ }
118
+ const hashIndex = link.indexOf('#');
119
+ const beforeHash = hashIndex === -1 ? link : link.slice(0, hashIndex);
120
+ const hash = hashIndex === -1 ? null : link.slice(hashIndex + 1);
121
+ const queryIndex = beforeHash.indexOf('?');
122
+ const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
123
+ const query = queryIndex === -1 ? null : beforeHash.slice(queryIndex + 1);
124
+ let sanitized = base.split('/').map(sanitizeUriComponent).join('/');
125
+ if (query !== null) {
126
+ const params = new URLSearchParams(query);
127
+ const sanitizedParams = new URLSearchParams();
128
+ let modified = false;
129
+ params.forEach((value, key) => {
130
+ const sanitizedKey = sanitizeQueryValue(key);
131
+ const sanitizedValue = sanitizeQueryValue(value);
132
+ modified = modified || sanitizedKey !== key || sanitizedValue !== value;
133
+ sanitizedParams.append(sanitizedKey, sanitizedValue);
134
+ });
135
+ sanitized += `?${modified ? sanitizedParams.toString() : query}`;
136
+ }
137
+ if (hash !== null) {
138
+ sanitized += `#${sanitizeUriComponent(hash)}`;
139
+ }
140
+ if (sanitized !== link) {
141
+ logger.warn('sanitizeLink: Removed unsafe content from link', link);
142
+ }
143
+ return sanitized;
59
144
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopgate/pwa-common",
3
- "version": "7.32.0-beta.20",
3
+ "version": "7.32.0-beta.21",
4
4
  "description": "Common library for the Shopgate Connect PWA.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Shopgate <support@shopgate.com>",
@@ -17,7 +17,7 @@
17
17
  "dependencies": {
18
18
  "@redux-devtools/extension": "^3.3.0",
19
19
  "@sentry/browser": "6.0.1",
20
- "@shopgate/pwa-benchmark": "7.32.0-beta.20",
20
+ "@shopgate/pwa-benchmark": "7.32.0-beta.21",
21
21
  "@virtuous/conductor": "~2.5.0",
22
22
  "@virtuous/react-conductor": "~2.5.0",
23
23
  "@virtuous/redux-persister": "1.1.0-beta.7",
@@ -40,7 +40,7 @@
40
40
  "swiper": "12.2.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@shopgate/pwa-core": "7.32.0-beta.20",
43
+ "@shopgate/pwa-core": "7.32.0-beta.21",
44
44
  "@types/lodash": "^4.17.24",
45
45
  "@types/react-portal": "^3.0.9",
46
46
  "lodash": "^4.17.23",
@@ -21,6 +21,7 @@ import { navigate$, userDidLogin$, appWillStart$, windowOpenOverride$ } from "..
21
21
  import { isUserLoggedIn } from "../selectors/user";
22
22
  import { getIsConnected } from "../selectors/client";
23
23
  import { INDEX_PATH } from "../constants/RoutePaths";
24
+ import { sanitizeLink } from "../helpers/router";
24
25
  import appConfig from "../helpers/config";
25
26
  import authRoutes from "../collections/AuthRoutes";
26
27
  import ToastProvider from "../providers/toast";
@@ -108,6 +109,10 @@ export default function routerSubscriptions(subscribe) {
108
109
  }
109
110
  case HISTORY_RESET_TO:
110
111
  {
112
+ const sanitizedResetToPathname = sanitizeLink(String(resetToPathname || ''));
113
+ if (!sanitizedResetToPathname) {
114
+ return;
115
+ }
111
116
  await router.pop({
112
117
  steps: historyLength - 1,
113
118
  state: routeState,
@@ -115,7 +120,7 @@ export default function routerSubscriptions(subscribe) {
115
120
  emitAfter: false
116
121
  });
117
122
  await router.replace({
118
- pathname: resetToPathname,
123
+ pathname: sanitizedResetToPathname,
119
124
  state: routeState
120
125
  });
121
126
  return;
@@ -124,6 +129,13 @@ export default function routerSubscriptions(subscribe) {
124
129
  break;
125
130
  }
126
131
 
132
+ // Remove HTML markup from the location (e.g. within query parameters of links from CMS content)
133
+ // to prevent that it's rendered within pages or sent within requests. Links with a script
134
+ // protocol are rejected.
135
+ if (location) {
136
+ location = sanitizeLink(String(location));
137
+ }
138
+
127
139
  // Remove trailing slashes from internal links, since they might break the routing mechanism.
128
140
  // External links are treated as valid, since we don't know about the requirements at the
129
141
  // 3rd party server (e.g. google maps links might require trailing slashes).
@@ -331,9 +343,10 @@ export default function routerSubscriptions(subscribe) {
331
343
  * so that the router can decide how to handle the URL.
332
344
  */
333
345
  Linking.addEventListener('windowOpenRequested', event => {
334
- const {
335
- targetUrl
336
- } = event.detail;
346
+ const targetUrl = sanitizeLink(String(event.detail?.targetUrl || ''));
347
+ if (!targetUrl) {
348
+ return;
349
+ }
337
350
  dispatch(historyPush({
338
351
  pathname: targetUrl,
339
352
  state: {}
@@ -371,9 +384,10 @@ export default function routerSubscriptions(subscribe) {
371
384
  action,
372
385
  dispatch
373
386
  }) => {
374
- if (action.pathname) {
387
+ const pathname = sanitizeLink(String(action.pathname || ''));
388
+ if (pathname) {
375
389
  dispatch(historyPush({
376
- pathname: action.pathname
390
+ pathname
377
391
  }));
378
392
  }
379
393
  });