@pequity/squirrel 4.0.1 → 4.1.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.
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ const vue = require("vue");
3
+ const vueRouter = require("vue-router");
4
+ const link = require("../link.js");
5
+ const sanitization = require("../sanitization.js");
6
+ const _hoisted_1 = ["href"];
7
+ const _sfc_main = /* @__PURE__ */ vue.defineComponent({
8
+ ...{
9
+ name: "PLink"
10
+ },
11
+ __name: "p-link",
12
+ props: {
13
+ custom: { type: Boolean },
14
+ activeClass: {},
15
+ exactActiveClass: {},
16
+ ariaCurrentValue: {},
17
+ to: {},
18
+ replace: { type: Boolean }
19
+ },
20
+ setup(__props) {
21
+ return (_ctx, _cache) => {
22
+ return typeof _ctx.to === "string" && vue.unref(link.isExternalLink)(_ctx.to) ? (vue.openBlock(), vue.createElementBlock("a", {
23
+ key: 0,
24
+ href: vue.unref(sanitization.sanitizeUrl)(_ctx.to),
25
+ target: "_blank"
26
+ }, [
27
+ vue.renderSlot(_ctx.$slots, "default")
28
+ ], 8, _hoisted_1)) : (vue.openBlock(), vue.createBlock(vue.unref(vueRouter.RouterLink), vue.normalizeProps(vue.mergeProps({ key: 1 }, _ctx.$props)), {
29
+ default: vue.withCtx(() => [
30
+ vue.renderSlot(_ctx.$slots, "default")
31
+ ]),
32
+ _: 3
33
+ }, 16));
34
+ };
35
+ }
36
+ });
37
+ exports._sfc_main = _sfc_main;
package/dist/cjs/index.js CHANGED
@@ -25,6 +25,7 @@ const pInput = require("./p-input.js");
25
25
  const pInputNumber = require("./p-input-number.js");
26
26
  const pInputPercent_vue_vue_type_script_setup_true_lang = require("./chunks/p-input-percent.js");
27
27
  const pInputSearch = require("./p-input-search.js");
28
+ const pLink_vue_vue_type_script_setup_true_lang = require("./chunks/p-link.js");
28
29
  const pLoading = require("./p-loading.js");
29
30
  const pModal = require("./p-modal.js");
30
31
  const pPagination_vue_vue_type_script_setup_true_lang = require("./chunks/p-pagination.js");
@@ -58,6 +59,7 @@ const inputClassesShared = require("./inputClassesShared.js");
58
59
  const pagination = require("./pagination.js");
59
60
  const dom = require("./dom.js");
60
61
  const object = require("./object.js");
62
+ const sanitization = require("./sanitization.js");
61
63
  const listKeyboardNavigation = require("./listKeyboardNavigation.js");
62
64
  const number = require("./number.js");
63
65
  const _hoisted_1$4 = { class: "flex h-12 w-max select-none items-center rounded-lg bg-p-purple-60 px-2 text-sm font-medium text-white" };
@@ -1060,6 +1062,7 @@ exports.PInput = pInput;
1060
1062
  exports.PInputNumber = pInputNumber;
1061
1063
  exports.PInputPercent = pInputPercent_vue_vue_type_script_setup_true_lang._sfc_main;
1062
1064
  exports.PInputSearch = pInputSearch;
1065
+ exports.PLink = pLink_vue_vue_type_script_setup_true_lang._sfc_main;
1063
1066
  exports.PLoading = pLoading;
1064
1067
  exports.PModal = pModal;
1065
1068
  exports.PPagination = pPagination_vue_vue_type_script_setup_true_lang._sfc_main;
@@ -1115,6 +1118,7 @@ exports.getNextActiveElement = dom.getNextActiveElement;
1115
1118
  exports.isElement = dom.isElement;
1116
1119
  exports.isVisible = dom.isVisible;
1117
1120
  exports.isObject = object.isObject;
1121
+ exports.sanitizeUrl = sanitization.sanitizeUrl;
1118
1122
  exports.setupListKeyboardNavigation = listKeyboardNavigation.setupListKeyboardNavigation;
1119
1123
  exports.toNumberOrNull = number.toNumberOrNull;
1120
1124
  exports.PActionBar = _sfc_main$4;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const normalizeUrl = (url) => {
4
+ if (url.indexOf("//") === 0) {
5
+ url = location.protocol + url;
6
+ }
7
+ return url;
8
+ };
9
+ const isValidUrl = (url) => {
10
+ url = normalizeUrl(url);
11
+ try {
12
+ return Boolean(new URL(url));
13
+ } catch (e) {
14
+ return false;
15
+ }
16
+ };
17
+ const checkDomain = function(url) {
18
+ url = normalizeUrl(url);
19
+ return url.toLowerCase().replace(/([a-z])?:\/\//, "$1").split("/")[0];
20
+ };
21
+ const isExternalLink = function(url) {
22
+ url = String(url);
23
+ return isValidUrl(url) && (url.indexOf(":") > -1 || url.indexOf("//") > -1) && checkDomain(location.href) !== checkDomain(url);
24
+ };
25
+ exports.isExternalLink = isExternalLink;
package/dist/cjs/p-btn.js CHANGED
@@ -3,6 +3,8 @@ const pRingLoader_vue_vue_type_script_setup_true_lang = require("./chunks/p-ring
3
3
  const tailwind = require("./tailwind.js");
4
4
  const vue = require("vue");
5
5
  const vueRouter = require("vue-router");
6
+ const link = require("./link.js");
7
+ const sanitization = require("./sanitization.js");
6
8
  const _pluginVue_exportHelper = require("./chunks/_plugin-vue_export-helper.js");
7
9
  const BUTTON_TYPES = {
8
10
  PRIMARY: "primary",
@@ -128,17 +130,18 @@ const _sfc_main = vue.defineComponent({
128
130
  loaderColor() {
129
131
  const type = LOADER_COLORS[this.type];
130
132
  return tailwind.getColorDeep(type);
131
- },
132
- isExternalLink() {
133
- return typeof this.to === "string" && this.to.startsWith("http");
134
133
  }
134
+ },
135
+ methods: {
136
+ isExternalLink: link.isExternalLink,
137
+ sanitizeUrl: sanitization.sanitizeUrl
135
138
  }
136
139
  });
137
140
  const _hoisted_1 = ["href"];
138
141
  function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
139
142
  const _component_PRingLoader = vue.resolveComponent("PRingLoader");
140
- return _ctx.isExternalLink ? (vue.openBlock(), vue.createElementBlock("a", vue.mergeProps({ key: 0 }, _ctx.$attrs, {
141
- href: _ctx.to,
143
+ return typeof _ctx.to === "string" && _ctx.isExternalLink(_ctx.to) ? (vue.openBlock(), vue.createElementBlock("a", vue.mergeProps({ key: 0 }, _ctx.$attrs, {
144
+ href: _ctx.sanitizeUrl(_ctx.to),
142
145
  target: "_blank",
143
146
  class: _ctx.classes
144
147
  }), [
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ const pLink_vue_vue_type_script_setup_true_lang = require("./chunks/p-link.js");
3
+ module.exports = pLink_vue_vue_type_script_setup_true_lang._sfc_main;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const XSS_SECURITY_URL = "https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html";
4
+ const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
5
+ const sanitizeUrl = (url) => {
6
+ url = String(url);
7
+ if (url.match(SAFE_URL_PATTERN)) {
8
+ return url;
9
+ }
10
+ console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
11
+ return "unsafe:" + url;
12
+ };
13
+ exports.sanitizeUrl = sanitizeUrl;
@@ -0,0 +1,38 @@
1
+ import { defineComponent, unref, openBlock, createElementBlock, renderSlot, createBlock, normalizeProps, mergeProps, withCtx } from "vue";
2
+ import { RouterLink } from "vue-router";
3
+ import { isExternalLink } from "../link.js";
4
+ import { sanitizeUrl } from "../sanitization.js";
5
+ const _hoisted_1 = ["href"];
6
+ const _sfc_main = /* @__PURE__ */ defineComponent({
7
+ ...{
8
+ name: "PLink"
9
+ },
10
+ __name: "p-link",
11
+ props: {
12
+ custom: { type: Boolean },
13
+ activeClass: {},
14
+ exactActiveClass: {},
15
+ ariaCurrentValue: {},
16
+ to: {},
17
+ replace: { type: Boolean }
18
+ },
19
+ setup(__props) {
20
+ return (_ctx, _cache) => {
21
+ return typeof _ctx.to === "string" && unref(isExternalLink)(_ctx.to) ? (openBlock(), createElementBlock("a", {
22
+ key: 0,
23
+ href: unref(sanitizeUrl)(_ctx.to),
24
+ target: "_blank"
25
+ }, [
26
+ renderSlot(_ctx.$slots, "default")
27
+ ], 8, _hoisted_1)) : (openBlock(), createBlock(unref(RouterLink), normalizeProps(mergeProps({ key: 1 }, _ctx.$props)), {
28
+ default: withCtx(() => [
29
+ renderSlot(_ctx.$slots, "default")
30
+ ]),
31
+ _: 3
32
+ }, 16));
33
+ };
34
+ }
35
+ });
36
+ export {
37
+ _sfc_main as _
38
+ };
package/dist/es/index.js CHANGED
@@ -24,14 +24,15 @@ import { default as default11 } from "./p-input.js";
24
24
  import { default as default12 } from "./p-input-number.js";
25
25
  import { _ as _3 } from "./chunks/p-input-percent.js";
26
26
  import PInputSearch from "./p-input-search.js";
27
+ import { _ as _4 } from "./chunks/p-link.js";
27
28
  import { default as default13 } from "./p-loading.js";
28
29
  import { default as default14 } from "./p-modal.js";
29
- import { _ as _4 } from "./chunks/p-pagination.js";
30
- import { _ as _5 } from "./chunks/p-pagination-info.js";
30
+ import { _ as _5 } from "./chunks/p-pagination.js";
31
+ import { _ as _6 } from "./chunks/p-pagination-info.js";
31
32
  import { default as default15 } from "./p-progress-bar.js";
32
- import { _ as _6 } from "./chunks/p-ring-loader.js";
33
- import { _ as _7 } from "./chunks/p-select.js";
34
- import { _ as _8 } from "./chunks/p-select-btn.js";
33
+ import { _ as _7 } from "./chunks/p-ring-loader.js";
34
+ import { _ as _8 } from "./chunks/p-select.js";
35
+ import { _ as _9 } from "./chunks/p-select-btn.js";
35
36
  import { SIZES } from "./p-select-list.js";
36
37
  import { splitStringForHighlight } from "./text.js";
37
38
  import { toString } from "./string.js";
@@ -42,10 +43,10 @@ import PTableHeaderCell from "./p-table-header-cell.js";
42
43
  import { colsInjectionKey, isFirstColFixedInjectionKey, isLastColFixedInjectionKey, isColsResizableInjectionKey } from "./p-table.js";
43
44
  import { MIN_WIDTH_COL_RESIZE } from "./p-table.js";
44
45
  import { usePTableColResize } from "./usePTableColResize.js";
45
- import { _ as _9 } from "./chunks/p-table-loader.js";
46
+ import { _ as _10 } from "./chunks/p-table-loader.js";
46
47
  import { SORTING_TYPES } from "./p-table-sort.js";
47
48
  import { default as default18 } from "./p-table-td.js";
48
- import { _ as _10 } from "./chunks/p-tabs.js";
49
+ import { _ as _11 } from "./chunks/p-tabs.js";
49
50
  import { default as default19 } from "./p-textarea.js";
50
51
  import { default as default20 } from "./p-toggle.js";
51
52
  import { usePLoading } from "./usePLoading.js";
@@ -58,6 +59,7 @@ import { ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, LABEL_BA
58
59
  import { createPagingRange } from "./pagination.js";
59
60
  import { getNextActiveElement, isElement, isVisible } from "./dom.js";
60
61
  import { isObject } from "./object.js";
62
+ import { sanitizeUrl } from "./sanitization.js";
61
63
  import { setupListKeyboardNavigation } from "./listKeyboardNavigation.js";
62
64
  import { toNumberOrNull } from "./number.js";
63
65
  const _hoisted_1$4 = { class: "flex h-12 w-max select-none items-center rounded-lg bg-p-purple-60 px-2 text-sm font-medium text-white" };
@@ -1071,23 +1073,24 @@ export {
1071
1073
  default12 as PInputNumber,
1072
1074
  _3 as PInputPercent,
1073
1075
  PInputSearch,
1076
+ _4 as PLink,
1074
1077
  default13 as PLoading,
1075
1078
  default14 as PModal,
1076
- _4 as PPagination,
1077
- _5 as PPaginationInfo,
1079
+ _5 as PPagination,
1080
+ _6 as PPaginationInfo,
1078
1081
  default15 as PProgressBar,
1079
- _6 as PRingLoader,
1080
- _7 as PSelect,
1081
- _8 as PSelectBtn,
1082
+ _7 as PRingLoader,
1083
+ _8 as PSelect,
1084
+ _9 as PSelectBtn,
1082
1085
  _sfc_main$2 as PSelectList,
1083
1086
  default16 as PSelectPill,
1084
1087
  default17 as PSkeletonLoader,
1085
1088
  pTable as PTable,
1086
1089
  PTableHeaderCell,
1087
- _9 as PTableLoader,
1090
+ _10 as PTableLoader,
1088
1091
  pTableSort as PTableSort,
1089
1092
  default18 as PTableTd,
1090
- _10 as PTabs,
1093
+ _11 as PTabs,
1091
1094
  default19 as PTextarea,
1092
1095
  default20 as PToggle,
1093
1096
  SELECT_ARROW,
@@ -1113,6 +1116,7 @@ export {
1113
1116
  isLastColFixedInjectionKey,
1114
1117
  isObject,
1115
1118
  isVisible,
1119
+ sanitizeUrl,
1116
1120
  setupListKeyboardNavigation,
1117
1121
  splitStringForHighlight,
1118
1122
  toNumberOrNull,
@@ -0,0 +1,25 @@
1
+ const normalizeUrl = (url) => {
2
+ if (url.indexOf("//") === 0) {
3
+ url = location.protocol + url;
4
+ }
5
+ return url;
6
+ };
7
+ const isValidUrl = (url) => {
8
+ url = normalizeUrl(url);
9
+ try {
10
+ return Boolean(new URL(url));
11
+ } catch (e) {
12
+ return false;
13
+ }
14
+ };
15
+ const checkDomain = function(url) {
16
+ url = normalizeUrl(url);
17
+ return url.toLowerCase().replace(/([a-z])?:\/\//, "$1").split("/")[0];
18
+ };
19
+ const isExternalLink = function(url) {
20
+ url = String(url);
21
+ return isValidUrl(url) && (url.indexOf(":") > -1 || url.indexOf("//") > -1) && checkDomain(location.href) !== checkDomain(url);
22
+ };
23
+ export {
24
+ isExternalLink
25
+ };
package/dist/es/p-btn.js CHANGED
@@ -2,6 +2,8 @@ import { _ as _sfc_main$1 } from "./chunks/p-ring-loader.js";
2
2
  import { getColorDeep } from "./tailwind.js";
3
3
  import { defineComponent, resolveComponent, openBlock, createElementBlock, mergeProps, renderSlot, createBlock, resolveDynamicComponent, withCtx, createElementVNode, normalizeClass, normalizeStyle, createCommentVNode } from "vue";
4
4
  import { RouterLink } from "vue-router";
5
+ import { isExternalLink } from "./link.js";
6
+ import { sanitizeUrl } from "./sanitization.js";
5
7
  import { _ as _export_sfc } from "./chunks/_plugin-vue_export-helper.js";
6
8
  const BUTTON_TYPES = {
7
9
  PRIMARY: "primary",
@@ -127,17 +129,18 @@ const _sfc_main = defineComponent({
127
129
  loaderColor() {
128
130
  const type = LOADER_COLORS[this.type];
129
131
  return getColorDeep(type);
130
- },
131
- isExternalLink() {
132
- return typeof this.to === "string" && this.to.startsWith("http");
133
132
  }
133
+ },
134
+ methods: {
135
+ isExternalLink,
136
+ sanitizeUrl
134
137
  }
135
138
  });
136
139
  const _hoisted_1 = ["href"];
137
140
  function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
138
141
  const _component_PRingLoader = resolveComponent("PRingLoader");
139
- return _ctx.isExternalLink ? (openBlock(), createElementBlock("a", mergeProps({ key: 0 }, _ctx.$attrs, {
140
- href: _ctx.to,
142
+ return typeof _ctx.to === "string" && _ctx.isExternalLink(_ctx.to) ? (openBlock(), createElementBlock("a", mergeProps({ key: 0 }, _ctx.$attrs, {
143
+ href: _ctx.sanitizeUrl(_ctx.to),
141
144
  target: "_blank",
142
145
  class: _ctx.classes
143
146
  }), [
@@ -0,0 +1,4 @@
1
+ import { _ as _sfc_main } from "./chunks/p-link.js";
2
+ export {
3
+ _sfc_main as default
4
+ };
@@ -0,0 +1,13 @@
1
+ const XSS_SECURITY_URL = "https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html";
2
+ const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
3
+ const sanitizeUrl = (url) => {
4
+ url = String(url);
5
+ if (url.match(SAFE_URL_PATTERN)) {
6
+ return url;
7
+ }
8
+ console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
9
+ return "unsafe:" + url;
10
+ };
11
+ export {
12
+ sanitizeUrl
13
+ };
@@ -18,6 +18,7 @@ import PInput from './p-input/p-input.vue';
18
18
  import PInputNumber from './p-input-number/p-input-number.vue';
19
19
  import PInputPercent from './p-input-percent/p-input-percent.vue';
20
20
  import PInputSearch from './p-input-search/p-input-search.vue';
21
+ import PLink from './p-link/p-link.vue';
21
22
  import PLoading from './p-loading/p-loading.vue';
22
23
  import PModal from './p-modal/p-modal.vue';
23
24
  import PPagination from './p-pagination/p-pagination.vue';
@@ -47,4 +48,4 @@ import { usePModal } from './p-modal/usePModal';
47
48
  import { usePTableColResize } from './p-table/usePTableColResize';
48
49
  import { usePTableRowVirtualizer } from './p-table/usePTableRowVirtualizer';
49
50
  import { useSelectList } from './p-select-list/useSelectList';
50
- export { PActionBar, PActionBarAction, PAlert, PAvatar, PBtn, PCard, PCheckbox, PChips, PCloseBtn, PDatePicker, PDrawer, PDropdown, PDropdownSelect, PFileUpload, PFilterIcon, PInfoIcon, PInlineDatePicker, PInput, PInputNumber, PInputPercent, PInputSearch, PLoading, PModal, PPagination, PPaginationInfo, PProgressBar, PRingLoader, PSelect, PSelectBtn, PSelectList, PSelectPill, PSkeletonLoader, PTable, PTableHeaderCell, PTableLoader, PTableSort, PTableTd, PTabs, PTextarea, PToggle, SORTING_TYPES, MIN_WIDTH_COL_RESIZE, colsInjectionKey, isColsResizableInjectionKey, isFirstColFixedInjectionKey, isLastColFixedInjectionKey, usePModal, usePTableColResize, usePTableRowVirtualizer, useSelectList, usePLoading, SortingType, SortingTypeWithoutNoSorting, Size, FileUploadFile, HeaderCellAttrs, TableCol, ThAttrs, };
51
+ export { PActionBar, PActionBarAction, PAlert, PAvatar, PBtn, PCard, PCheckbox, PChips, PCloseBtn, PDatePicker, PDrawer, PDropdown, PDropdownSelect, PFileUpload, PFilterIcon, PInfoIcon, PInlineDatePicker, PInput, PInputNumber, PInputPercent, PInputSearch, PLink, PLoading, PModal, PPagination, PPaginationInfo, PProgressBar, PRingLoader, PSelect, PSelectBtn, PSelectList, PSelectPill, PSkeletonLoader, PTable, PTableHeaderCell, PTableLoader, PTableSort, PTableTd, PTabs, PTextarea, PToggle, SORTING_TYPES, MIN_WIDTH_COL_RESIZE, colsInjectionKey, isColsResizableInjectionKey, isFirstColFixedInjectionKey, isLastColFixedInjectionKey, usePModal, usePTableColResize, usePTableRowVirtualizer, useSelectList, usePLoading, SortingType, SortingTypeWithoutNoSorting, Size, FileUploadFile, HeaderCellAttrs, TableCol, ThAttrs, };
@@ -72,8 +72,10 @@ declare const _default: import("vue").DefineComponent<{
72
72
  classes(): string;
73
73
  loaderSize(): number;
74
74
  loaderColor(): undefined;
75
- isExternalLink(): boolean;
76
- }, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
75
+ }, {
76
+ isExternalLink: (url: string) => boolean;
77
+ sanitizeUrl: (url: string) => string;
78
+ }, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
77
79
  /**
78
80
  * The button style e.g primary, secondary, primary-outline, secondary-outline, error, success, primary-link
79
81
  */
@@ -0,0 +1,22 @@
1
+ import { type RouterLinkProps } from 'vue-router';
2
+ declare function __VLS_template(): {
3
+ default?(_: {}): any;
4
+ default?(_: {}): any;
5
+ };
6
+ declare const __VLS_component: import("vue").DefineComponent<__VLS_TypePropsToOption<RouterLinkProps>, {}, unknown, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<__VLS_TypePropsToOption<RouterLinkProps>>>, {}, {}>;
7
+ declare const _default: __VLS_WithTemplateSlots<typeof __VLS_component, ReturnType<typeof __VLS_template>>;
8
+ export default _default;
9
+ type __VLS_WithTemplateSlots<T, S> = T & {
10
+ new (): {
11
+ $slots: S;
12
+ };
13
+ };
14
+ type __VLS_NonUndefinedable<T> = T extends undefined ? never : T;
15
+ type __VLS_TypePropsToOption<T> = {
16
+ [K in keyof T]-?: {} extends Pick<T, K> ? {
17
+ type: import('vue').PropType<__VLS_NonUndefinedable<T[K]>>;
18
+ } : {
19
+ type: import('vue').PropType<T[K]>;
20
+ required: true;
21
+ };
22
+ };
@@ -17,7 +17,7 @@ export declare const usePTableRowVirtualizer: (options: Options) => {
17
17
  measureElement: () => Ref<undefined>;
18
18
  } | {
19
19
  virtualizer: Ref<import("@tanstack/vue-virtual").Virtualizer<HTMLElement, Element>>;
20
- virtualRows: ComputedRef<import("@tanstack/vue-virtual").VirtualItem<Element>[]>;
20
+ virtualRows: ComputedRef<import("@tanstack/vue-virtual").VirtualItem[]>;
21
21
  paddingTop: ComputedRef<number>;
22
22
  paddingBottom: ComputedRef<number>;
23
23
  measureElement: (cmp: ComponentPublicInstance | Ref<HTMLElement>) => undefined;
@@ -5,8 +5,9 @@ import { ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, type Inp
5
5
  import { createPagingRange } from './pagination';
6
6
  import { getNextActiveElement, isElement, isVisible } from './dom';
7
7
  import { isObject } from './object';
8
+ import { sanitizeUrl } from './sanitization';
8
9
  import { setupListKeyboardNavigation } from './listKeyboardNavigation';
9
10
  import { splitStringForHighlight } from './text';
10
11
  import { toNumberOrNull } from './number';
11
12
  import { toString } from './string';
12
- export { inputClassesMixin, CURRENCY_INPUT_DEFAULTS, Color, getColor, getColorDeep, getScreen, ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, InputSize, LABEL_BASE, LABEL_REQUIRED, LABEL_SIZES, SELECT_ARROW, SELECT_BASE, SELECT_SIZES, SPACING_LEFT, SPACING_PREFIX, SPACING_RIGHT, SPACING_SUFFIX, TEXTAREA_BASE, createPagingRange, getNextActiveElement, isElement, isVisible, isObject, setupListKeyboardNavigation, splitStringForHighlight, toNumberOrNull, toString, };
13
+ export { inputClassesMixin, CURRENCY_INPUT_DEFAULTS, Color, getColor, getColorDeep, getScreen, ERROR_MSG, INPUT_BASE, INPUT_ERROR, INPUT_NORMAL, INPUT_SIZES, InputSize, LABEL_BASE, LABEL_REQUIRED, LABEL_SIZES, SELECT_ARROW, SELECT_BASE, SELECT_SIZES, SPACING_LEFT, SPACING_PREFIX, SPACING_RIGHT, SPACING_SUFFIX, TEXTAREA_BASE, createPagingRange, getNextActiveElement, isElement, isVisible, isObject, sanitizeUrl, setupListKeyboardNavigation, splitStringForHighlight, toNumberOrNull, toString, };
@@ -0,0 +1 @@
1
+ export declare const isExternalLink: (url: string) => boolean;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * This is a port of the Angular url_sanitizer module.
3
+ * https://github.com/angular/angular/blob/main/packages/core/src/sanitization/url_sanitizer.ts
4
+ *
5
+ * TL;DR
6
+ * The function sanitizeUrl is designed to ensure that a given URL is safe,
7
+ * by checking it against a regular expression pattern (SAFE_URL_PATTERN).
8
+ * If the URL is considered unsafe, it returns a version of the URL prefixed with "unsafe:".
9
+ */
10
+ export declare const sanitizeUrl: (url: string) => string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pequity/squirrel",
3
3
  "description": "Squirrel component library",
4
- "version": "4.0.1",
4
+ "version": "4.1.0",
5
5
  "packageManager": "pnpm@9.7.1",
6
6
  "type": "module",
7
7
  "scripts": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@babel/core": "^7.25.2",
54
- "@babel/preset-env": "^7.25.3",
54
+ "@babel/preset-env": "^7.25.4",
55
55
  "@commitlint/cli": "^19.4.0",
56
56
  "@commitlint/config-conventional": "^19.2.2",
57
57
  "@pequity/eslint-config": "^0.0.13",
@@ -71,11 +71,11 @@
71
71
  "@storybook/theming": "^8.2.9",
72
72
  "@storybook/vue3": "^8.2.9",
73
73
  "@storybook/vue3-vite": "^8.2.9",
74
- "@tanstack/vue-virtual": "3.10.1",
74
+ "@tanstack/vue-virtual": "3.10.4",
75
75
  "@types/jest": "^29.5.12",
76
76
  "@types/jsdom": "^21.1.7",
77
77
  "@types/lodash-es": "^4.17.12",
78
- "@types/node": "^22.4.2",
78
+ "@types/node": "^22.5.0",
79
79
  "@vitejs/plugin-vue": "^5.1.2",
80
80
  "@vue/compiler-sfc": "3.4.38",
81
81
  "@vue/test-utils": "^2.4.6",
@@ -105,7 +105,7 @@
105
105
  "storybook": "^8.2.9",
106
106
  "svgo": "^3.3.2",
107
107
  "tailwindcss": "^3.4.10",
108
- "ts-jest": "^29.2.4",
108
+ "ts-jest": "^29.2.5",
109
109
  "typescript": "5.5.4",
110
110
  "v-calendar": "3.1.2",
111
111
  "vite": "^5.4.2",
@@ -18,6 +18,7 @@ import PInput from '@squirrel/components/p-input/p-input.vue';
18
18
  import PInputNumber from '@squirrel/components/p-input-number/p-input-number.vue';
19
19
  import PInputPercent from '@squirrel/components/p-input-percent/p-input-percent.vue';
20
20
  import PInputSearch from '@squirrel/components/p-input-search/p-input-search.vue';
21
+ import PLink from '@squirrel/components/p-link/p-link.vue';
21
22
  import PLoading from '@squirrel/components/p-loading/p-loading.vue';
22
23
  import PModal from '@squirrel/components/p-modal/p-modal.vue';
23
24
  import PPagination from '@squirrel/components/p-pagination/p-pagination.vue';
@@ -83,6 +84,7 @@ export {
83
84
  PInputNumber,
84
85
  PInputPercent,
85
86
  PInputSearch,
87
+ PLink,
86
88
  PLoading,
87
89
  PModal,
88
90
  PPagination,
@@ -1,5 +1,12 @@
1
1
  import PBtn from '@squirrel/components/p-btn/p-btn.vue';
2
2
  import { createWrapperFor } from '@tests/jest.helpers';
3
+ import { sanitizeUrl } from '@squirrel/utils/sanitization';
4
+
5
+ jest.mock('@squirrel/utils/sanitization', () => {
6
+ return {
7
+ sanitizeUrl: jest.fn((str) => `sanitized-${str}`),
8
+ };
9
+ });
3
10
 
4
11
  const ELEMENTS_MAP = {
5
12
  button: undefined,
@@ -8,6 +15,10 @@ const ELEMENTS_MAP = {
8
15
  };
9
16
 
10
17
  describe('PBtn.vue', () => {
18
+ afterEach(() => {
19
+ jest.clearAllMocks();
20
+ });
21
+
11
22
  Object.keys(ELEMENTS_MAP).forEach((el) => {
12
23
  const to = ELEMENTS_MAP[el];
13
24
 
@@ -147,7 +158,8 @@ describe('PBtn.vue', () => {
147
158
  });
148
159
 
149
160
  const a = await wrapper.find('a');
150
- expect(a.attributes().href).toBe('https://pequity.com/');
161
+
162
+ expect(a.attributes().href).toBe(`sanitized-https://pequity.com/`);
151
163
  expect(a.attributes().target).toBe('_blank');
152
164
  expect(a.text()).toBe('This is a button');
153
165
  });
@@ -185,4 +197,20 @@ describe('PBtn.vue', () => {
185
197
 
186
198
  expect(button.attributes()['aria-selected']).toBe('true');
187
199
  });
200
+
201
+ it('it sanitizes an invalid link', async () => {
202
+ const wrapper = createWrapperFor(PBtn, {
203
+ props: {
204
+ to: 'javascript:evil()',
205
+ },
206
+ slots: {
207
+ default: `This is a button`,
208
+ },
209
+ });
210
+
211
+ const a = await wrapper.find('a');
212
+
213
+ expect(a.text()).toBe('This is a button');
214
+ expect(sanitizeUrl).toHaveBeenCalledTimes(1);
215
+ });
188
216
  });
@@ -1,5 +1,11 @@
1
1
  <template>
2
- <a v-if="isExternalLink" v-bind="$attrs" :href="to as string" target="_blank" :class="classes">
2
+ <a
3
+ v-if="typeof to === 'string' && isExternalLink(to)"
4
+ v-bind="$attrs"
5
+ :href="sanitizeUrl(to)"
6
+ target="_blank"
7
+ :class="classes"
8
+ >
3
9
  <slot></slot>
4
10
  </a>
5
11
  <Component
@@ -29,6 +35,8 @@ import { type Color, getColorDeep } from '@squirrel/utils/tailwind';
29
35
  import { type PropType, defineComponent } from 'vue';
30
36
  import { type RouteLocationRaw, RouterLink } from 'vue-router';
31
37
  import { type Size } from '@squirrel/components/p-btn/p-btn.types';
38
+ import { isExternalLink } from '@squirrel/utils/link';
39
+ import { sanitizeUrl } from '@squirrel/utils/sanitization';
32
40
 
33
41
  const BUTTON_TYPES = {
34
42
  PRIMARY: 'primary',
@@ -173,9 +181,10 @@ export default defineComponent({
173
181
 
174
182
  return getColorDeep(type);
175
183
  },
176
- isExternalLink() {
177
- return typeof this.to === 'string' && this.to.startsWith('http');
178
- },
184
+ },
185
+ methods: {
186
+ isExternalLink,
187
+ sanitizeUrl,
179
188
  },
180
189
  });
181
190
  </script>
@@ -0,0 +1,60 @@
1
+ import PCloseBtn from '@squirrel/components/p-close-btn/p-close-btn.vue';
2
+ import { createWrapperFor } from '@tests/jest.helpers';
3
+
4
+ const buttonClasses = [
5
+ 'inline-flex',
6
+ 'h-8',
7
+ 'w-8',
8
+ 'cursor-pointer',
9
+ 'items-center',
10
+ 'justify-center',
11
+ 'rounded',
12
+ 'focus:outline-none',
13
+ 'disabled:cursor-default',
14
+ 'disabled:opacity-30',
15
+ 'disabled:hover:bg-white',
16
+ ];
17
+
18
+ const iconClasses = ['block', 'h-3', 'w-3', 'bg-center', 'bg-no-repeat'];
19
+
20
+ describe('PCloseBtn.vue', () => {
21
+ it('renders correctly', () => {
22
+ const wrapper = createWrapperFor(PCloseBtn);
23
+
24
+ const button = wrapper.find('button');
25
+ const i = wrapper.find('i');
26
+
27
+ expect(buttonClasses.every((c) => button.classes().includes(c))).toBe(true);
28
+ expect(iconClasses.every((c) => i.classes().includes(c))).toBe(true);
29
+ });
30
+
31
+ it('button inherits attributes', async () => {
32
+ const wrapper = createWrapperFor(PCloseBtn, {
33
+ attrs: {
34
+ disabled: true,
35
+ 'data-test': 'test',
36
+ },
37
+ });
38
+
39
+ const button = await wrapper.find('button');
40
+
41
+ expect(button.attributes().disabled).toBeDefined();
42
+ expect(button.attributes('data-test')).toBe('test');
43
+ });
44
+
45
+ it.each([
46
+ ['transparent', ['bg-transparent', 'hover:bg-p-gray-20'], ['x-black-icon']],
47
+ ['gray', ['bg-p-gray-10', 'hover:bg-p-gray-20'], ['x-black-icon']],
48
+ ['dark', ['bg-transparent'], ['x-white-icon']],
49
+ ])('renders a PCloseBtn of variant %s', (variant, btnClasses, iClasses) => {
50
+ const wrapper = createWrapperFor(PCloseBtn, {
51
+ props: { variant },
52
+ });
53
+
54
+ const button = wrapper.find('button');
55
+ const i = wrapper.find('i');
56
+
57
+ expect(btnClasses.every((c) => button.classes().includes(c))).toBe(true);
58
+ expect(iClasses.every((c) => i.classes().includes(c))).toBe(true);
59
+ });
60
+ });
@@ -0,0 +1,62 @@
1
+ import PLink from '@squirrel/components/p-link/p-link.vue';
2
+ import { createWrapperFor } from '@tests/jest.helpers';
3
+ import { isExternalLink } from '@squirrel/utils/link';
4
+ import { sanitizeUrl } from '@squirrel/utils/sanitization';
5
+
6
+ jest.mock('@squirrel/utils/sanitization');
7
+
8
+ jest.mock('@squirrel/utils/link');
9
+
10
+ const createWrapper = (props, attrs) => {
11
+ return createWrapperFor(PLink, {
12
+ props,
13
+ attrs,
14
+ slots: {
15
+ default: 'Test link',
16
+ },
17
+ global: {
18
+ stubs: {
19
+ RouterLink: true,
20
+ },
21
+ },
22
+ });
23
+ };
24
+
25
+ describe('PLink.vue', () => {
26
+ afterEach(() => {
27
+ jest.clearAllMocks();
28
+ });
29
+
30
+ it('renders a router link when the link is internal', () => {
31
+ isExternalLink.mockReturnValue(false);
32
+
33
+ const wrapper = createWrapper({ to: '/home' }, { class: 'p-link', 'data-test': 'test' });
34
+
35
+ const routerLink = wrapper.findComponent({ name: 'RouterLink' });
36
+
37
+ expect(routerLink.element).toBe(wrapper.element);
38
+ expect(routerLink.text()).toBe('Test link');
39
+ expect(routerLink.props().to).toBe('/home');
40
+ expect(routerLink.classes()).toContain('p-link');
41
+ expect(routerLink.attributes()['data-test']).toBe('test');
42
+ expect(isExternalLink).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it('renders an a href link when the link is external', () => {
46
+ isExternalLink.mockReturnValue(true);
47
+ sanitizeUrl.mockReturnValue('https://www.pequity.com');
48
+
49
+ const wrapper = createWrapper({ to: 'https://www.pequity.com' }, { class: 'p-link', 'data-test': 'test' });
50
+
51
+ const aLink = wrapper.find('a');
52
+
53
+ expect(aLink.element).toBe(wrapper.element);
54
+ expect(aLink.text()).toBe('Test link');
55
+ expect(aLink.attributes().href).toBe('https://www.pequity.com');
56
+ expect(wrapper.classes()).toContain('p-link');
57
+ expect(wrapper.attributes()['data-test']).toBe('test');
58
+ expect(wrapper.attributes().target).toBe('_blank');
59
+ expect(isExternalLink).toHaveBeenCalledTimes(1);
60
+ expect(sanitizeUrl).toHaveBeenCalledTimes(1);
61
+ });
62
+ });
@@ -0,0 +1,38 @@
1
+ import PLink from '@squirrel/components/p-link/p-link.vue';
2
+
3
+ export default {
4
+ title: 'Components/PLink',
5
+ component: PLink,
6
+ tags: ['autodocs'],
7
+ parameters: {
8
+ docs: {
9
+ description: {
10
+ component: `The \`PLink\` component is a versatile link component designed to seamlessly handle both internal and external links.
11
+ It determines whether a given link is internal (for navigation within the app) or external (leading to a different website) and renders the appropriate link element accordingly.
12
+ In case of external links, it also sanitizes the URL to mitigate potential security risks like URL-based attacks.`,
13
+ },
14
+ },
15
+ },
16
+ };
17
+
18
+ export const InternalLink = {
19
+ render: (args) => ({
20
+ components: { PLink },
21
+ setup() {
22
+ return { args };
23
+ },
24
+ template: `<PLink v-bind="args" class="text-accent underline hover:text-accent">${args.default}</PLink>`,
25
+ }),
26
+ args: {
27
+ to: '/dummy',
28
+ default: 'Dummy internal link',
29
+ },
30
+ };
31
+
32
+ export const ExternalLink = {
33
+ ...InternalLink,
34
+ args: {
35
+ to: 'https://www.pequity.com',
36
+ default: 'Link to Pequity website',
37
+ },
38
+ };
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <a v-if="typeof to === 'string' && isExternalLink(to)" :href="sanitizeUrl(to)" target="_blank">
3
+ <slot></slot>
4
+ </a>
5
+ <RouterLink v-else v-bind="$props">
6
+ <slot></slot>
7
+ </RouterLink>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { RouterLink, type RouterLinkProps } from 'vue-router';
12
+ import { isExternalLink } from '@squirrel/utils/link';
13
+ import { sanitizeUrl } from '@squirrel/utils/sanitization';
14
+
15
+ defineOptions({
16
+ name: 'PLink',
17
+ });
18
+
19
+ defineProps<RouterLinkProps>();
20
+ </script>
@@ -23,6 +23,7 @@ import {
23
23
  import { createPagingRange } from '@squirrel/utils/pagination';
24
24
  import { getNextActiveElement, isElement, isVisible } from '@squirrel/utils/dom';
25
25
  import { isObject } from '@squirrel/utils/object';
26
+ import { sanitizeUrl } from '@squirrel/utils/sanitization';
26
27
  import { setupListKeyboardNavigation } from '@squirrel/utils/listKeyboardNavigation';
27
28
  import { splitStringForHighlight } from '@squirrel/utils/text';
28
29
  import { toNumberOrNull } from '@squirrel/utils/number';
@@ -57,6 +58,7 @@ export {
57
58
  isElement,
58
59
  isVisible,
59
60
  isObject,
61
+ sanitizeUrl,
60
62
  setupListKeyboardNavigation,
61
63
  splitStringForHighlight,
62
64
  toNumberOrNull,
@@ -0,0 +1,24 @@
1
+ import { isExternalLink } from '@squirrel/utils/link';
2
+
3
+ describe('isExternalLink', () => {
4
+ it.each(['https://www.example.com', 'http://www.example.com', '//www.example.com'])(
5
+ 'should return true for external links (%s)',
6
+ (val) => {
7
+ expect(isExternalLink(val)).toBe(true);
8
+ }
9
+ );
10
+
11
+ it.each(['/home', 'home', '#home', '/home//1/', '/home:1/', '#home:', { name: 'home' }])(
12
+ 'should return false for internal links (%s)',
13
+ (val) => {
14
+ expect(isExternalLink(val)).toBe(false);
15
+ }
16
+ );
17
+
18
+ it.each(['ftp://www.example.com', 'mailto:test@example.com', 'tel:+1234567890', 'sms:+1234567890'])(
19
+ 'should handle different protocols (%s)',
20
+ (val) => {
21
+ expect(isExternalLink(val)).toBe(true);
22
+ }
23
+ );
24
+ });
@@ -0,0 +1,36 @@
1
+ const normalizeUrl = (url: string) => {
2
+ if (url.indexOf('//') === 0) {
3
+ url = location.protocol + url;
4
+ }
5
+
6
+ return url;
7
+ };
8
+
9
+ const isValidUrl = (url: string) => {
10
+ url = normalizeUrl(url);
11
+
12
+ try {
13
+ return Boolean(new URL(url));
14
+ } catch (e) {
15
+ return false;
16
+ }
17
+ };
18
+
19
+ const checkDomain = function (url: string) {
20
+ url = normalizeUrl(url);
21
+
22
+ return url
23
+ .toLowerCase()
24
+ .replace(/([a-z])?:\/\//, '$1')
25
+ .split('/')[0];
26
+ };
27
+
28
+ export const isExternalLink = function (url: string) {
29
+ url = String(url);
30
+
31
+ return (
32
+ isValidUrl(url) &&
33
+ (url.indexOf(':') > -1 || url.indexOf('//') > -1) &&
34
+ checkDomain(location.href) !== checkDomain(url)
35
+ );
36
+ };
@@ -0,0 +1,57 @@
1
+ import { sanitizeUrl } from '@squirrel/utils/sanitization';
2
+
3
+ describe('sanitizeUrl', () => {
4
+ const consoleMock = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
5
+
6
+ afterAll(() => {
7
+ consoleMock.mockReset();
8
+ });
9
+
10
+ it('reports unsafe URLs', () => {
11
+ const unsafeUrl = 'javascript:evil()';
12
+
13
+ expect(sanitizeUrl(unsafeUrl)).toBe(`unsafe:${unsafeUrl}`);
14
+ expect(consoleMock).toHaveBeenCalledWith(
15
+ expect.stringContaining(`WARNING: sanitizing unsafe URL value ${unsafeUrl}`)
16
+ );
17
+ });
18
+
19
+ it.each([
20
+ '',
21
+ 'http://abc',
22
+ 'HTTP://abc',
23
+ 'https://abc',
24
+ 'HTTPS://abc',
25
+ 'ftp://abc',
26
+ 'FTP://abc',
27
+ 'mailto:me@example.com',
28
+ 'MAILTO:me@example.com',
29
+ 'tel:123-123-1234',
30
+ 'TEL:123-123-1234',
31
+ '#anchor',
32
+ '/page1.md',
33
+ 'http://JavaScript/my.js',
34
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', // Truncated.
35
+ 'data:video/webm;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
36
+ 'data:audio/opus;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
37
+ 'unknown-scheme:abc',
38
+ ])('returns the URL if it is valid (%s)', (urlVal) => {
39
+ expect(sanitizeUrl(urlVal)).toBe(urlVal);
40
+ });
41
+
42
+ it.each([
43
+ 'javascript:evil()',
44
+ 'JavaScript:abc',
45
+ ' javascript:abc',
46
+ ' \n Java\n Script:abc',
47
+ '&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;',
48
+ '&#106&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;',
49
+ '&#106 &#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;',
50
+ '&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058',
51
+ '&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A;',
52
+ 'jav&#x09;ascript:alert();',
53
+ 'jav\u0000ascript:alert();',
54
+ ])('it adds an "unsafe:" prefix if the URL is invalid (%s)', (urlVal) => {
55
+ expect(sanitizeUrl(urlVal)).toMatch(/^unsafe:/);
56
+ });
57
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * This is a port of the Angular url_sanitizer module.
3
+ * https://github.com/angular/angular/blob/main/packages/core/src/sanitization/url_sanitizer.ts
4
+ *
5
+ * TL;DR
6
+ * The function sanitizeUrl is designed to ensure that a given URL is safe,
7
+ * by checking it against a regular expression pattern (SAFE_URL_PATTERN).
8
+ * If the URL is considered unsafe, it returns a version of the URL prefixed with "unsafe:".
9
+ */
10
+
11
+ /**
12
+ * A pattern that recognizes URLs that are safe wrt. XSS in URL navigation
13
+ * contexts.
14
+ *
15
+ * This regular expression matches a subset of URLs that will not cause script
16
+ * execution if used in URL context within a HTML document. Specifically, this
17
+ * regular expression matches if:
18
+ * (1) Either a protocol that is not javascript:, and that has valid characters
19
+ * (alphanumeric or [+-.]).
20
+ * (2) or no protocol. A protocol must be followed by a colon. The below
21
+ * allows that by allowing colons only after one of the characters [/?#].
22
+ * A colon after a hash (#) must be in the fragment.
23
+ * Otherwise, a colon after a (?) must be in a query.
24
+ * Otherwise, a colon after a single solidus (/) must be in a path.
25
+ * Otherwise, a colon after a double solidus (//) must be in the authority
26
+ * (before port).
27
+ *
28
+ * The pattern disallows &, used in HTML entity declarations before
29
+ * one of the characters in [/?#]. This disallows HTML entities used in the
30
+ * protocol name, which should never happen, e.g. "h&#116;tp" for "http".
31
+ * It also disallows HTML entities in the first path part of a relative path,
32
+ * e.g. "foo&lt;bar/baz". Our existing escaping functions should not produce
33
+ * that. More importantly, it disallows masking of a colon,
34
+ * e.g. "javascript&#58;...".
35
+ *
36
+ * This regular expression was taken from the Closure sanitization library.
37
+ */
38
+
39
+ const XSS_SECURITY_URL =
40
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html';
41
+
42
+ // eslint-disable-next-line no-useless-escape
43
+ const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;
44
+
45
+ export const sanitizeUrl = (url: string) => {
46
+ url = String(url);
47
+
48
+ if (url.match(SAFE_URL_PATTERN)) {
49
+ return url;
50
+ }
51
+
52
+ console.warn(`WARNING: sanitizing unsafe URL value ${url} (see ${XSS_SECURITY_URL})`);
53
+
54
+ return 'unsafe:' + url;
55
+ };