@pequity/squirrel 4.0.0 → 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.
Files changed (43) hide show
  1. package/dist/cjs/chunks/p-link.js +37 -0
  2. package/dist/cjs/index.js +4 -0
  3. package/dist/cjs/link.js +25 -0
  4. package/dist/cjs/p-btn.js +8 -5
  5. package/dist/cjs/p-info-icon.js +6 -3
  6. package/dist/cjs/p-link.js +3 -0
  7. package/dist/cjs/p-modal.js +5 -5
  8. package/dist/cjs/sanitization.js +13 -0
  9. package/dist/es/chunks/p-link.js +38 -0
  10. package/dist/es/index.js +18 -14
  11. package/dist/es/link.js +25 -0
  12. package/dist/es/p-btn.js +8 -5
  13. package/dist/es/p-info-icon.js +6 -3
  14. package/dist/es/p-link.js +4 -0
  15. package/dist/es/p-modal.js +5 -5
  16. package/dist/es/sanitization.js +13 -0
  17. package/dist/squirrel/components/index.d.ts +2 -1
  18. package/dist/squirrel/components/p-btn/p-btn.vue.d.ts +4 -2
  19. package/dist/squirrel/components/p-link/p-link.vue.d.ts +22 -0
  20. package/dist/squirrel/components/p-table/usePTableRowVirtualizer.d.ts +1 -1
  21. package/dist/squirrel/utils/index.d.ts +2 -1
  22. package/dist/squirrel/utils/link.d.ts +1 -0
  23. package/dist/squirrel/utils/sanitization.d.ts +10 -0
  24. package/dist/style.css +15 -15
  25. package/package.json +6 -6
  26. package/squirrel/components/index.ts +2 -0
  27. package/squirrel/components/p-btn/p-btn.spec.js +29 -1
  28. package/squirrel/components/p-btn/p-btn.vue +13 -4
  29. package/squirrel/components/p-close-btn/p-close-btn.spec.js +60 -0
  30. package/squirrel/components/p-info-icon/p-info-icon.spec.js +21 -0
  31. package/squirrel/components/p-info-icon/p-info-icon.stories.js +32 -0
  32. package/squirrel/components/p-info-icon/p-info-icon.vue +1 -1
  33. package/squirrel/components/p-link/p-link.spec.js +62 -0
  34. package/squirrel/components/p-link/p-link.stories.js +38 -0
  35. package/squirrel/components/p-link/p-link.vue +20 -0
  36. package/squirrel/components/p-modal/p-modal-features.spec.js +23 -1
  37. package/squirrel/components/p-modal/p-modal.vue +6 -1
  38. package/squirrel/components/p-table-header-cell/p-table-header-cell.stories.js +2 -2
  39. package/squirrel/utils/index.ts +2 -0
  40. package/squirrel/utils/link.spec.js +24 -0
  41. package/squirrel/utils/link.ts +36 -0
  42. package/squirrel/utils/sanitization.spec.js +57 -0
  43. package/squirrel/utils/sanitization.ts +55 -0
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.0",
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.1",
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",
@@ -83,7 +83,7 @@
83
83
  "autoprefixer": "^10.4.20",
84
84
  "babel-jest": "^29.7.0",
85
85
  "concurrently": "^8.2.2",
86
- "dayjs": "1.11.12",
86
+ "dayjs": "1.11.13",
87
87
  "eslint": "^8.57.0",
88
88
  "eslint-plugin-storybook": "^0.8.0",
89
89
  "floating-vue": "5.2.2",
@@ -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
+ });
@@ -55,4 +55,25 @@ describe('PInfoIcon.vue', () => {
55
55
 
56
56
  expect(wrapper.text()).toBe('Lorem ipsum dolor sit amet.');
57
57
  });
58
+
59
+ it('tooltip should stay visible on hover', async () => {
60
+ const wrapper = createWrapperFor(PInfoIcon, {
61
+ props: {
62
+ text: '',
63
+ },
64
+ slots: {
65
+ default: 'Lorem ipsum dolor sit amet.',
66
+ },
67
+ global: {
68
+ stubs: {
69
+ VTooltip: true,
70
+ },
71
+ },
72
+ });
73
+
74
+ const tooltip = wrapper.findComponent('v-tooltip-stub');
75
+
76
+ expect(tooltip.props().popperTriggers).toEqual(['hover']);
77
+ expect(tooltip.props().delay).toEqual({ show: 750, hide: 0 });
78
+ });
58
79
  });
@@ -55,3 +55,35 @@ export const DifferentPlacements = {
55
55
  },
56
56
  },
57
57
  };
58
+
59
+ export const WithBigText = {
60
+ render: (args) => ({
61
+ components: { PInfoIcon },
62
+ setup() {
63
+ return { args };
64
+ },
65
+ template: `
66
+ <div class="w-full pt-60 pb-20 flex justify-center">
67
+ <PInfoIcon v-bind="args" />
68
+ </div>`,
69
+ }),
70
+ args: {
71
+ text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada convallis ultricies.
72
+ Pellentesque rhoncus felis et neque pellentesque pretium.
73
+
74
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada convallis ultricies.
75
+ Pellentesque rhoncus felis et neque pellentesque pretium.
76
+
77
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada convallis ultricies.
78
+ Pellentesque rhoncus felis et neque pellentesque pretium.
79
+ `,
80
+ placement: 'top',
81
+ },
82
+ parameters: {
83
+ docs: {
84
+ description: {
85
+ story: `The info tooltip allows for long text and the ability to scroll.`,
86
+ },
87
+ },
88
+ },
89
+ };
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <VTooltip>
2
+ <VTooltip :popper-triggers="['hover']" :delay="{ show: 750, hide: 0 }">
3
3
  <i class="bg-info-circle-icon block h-3 w-3"></i>
4
4
  <template #popper>
5
5
  <slot>{{ text }}</slot>
@@ -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>
@@ -9,7 +9,7 @@ const ModalParent = {
9
9
  template: `
10
10
  <div>
11
11
  <button type="button" class="open-modal-1" @click="showModal1 = true">Open modal 1</button>
12
- <Modal1 wrapperClass="modal-1" v-model="showModal1" :title="title" :live="live">
12
+ <Modal1 wrapperClass="modal-1" v-model="showModal1" :title="title" :live="live" :enable-close="enableClose">
13
13
  <div>
14
14
  <input v-if="showInput" type="text" value="" autofocus class="text-input" />
15
15
  <button type="button" class="open-modal-2" @click="showModal2 = true">Open modal 2</button>
@@ -52,6 +52,7 @@ const createModalsWrapper = (data) => {
52
52
  title: 'Test title',
53
53
  showModal1: false,
54
54
  showModal2: false,
55
+ enableClose: true,
55
56
  },
56
57
  ...data,
57
58
  };
@@ -321,4 +322,25 @@ describe('Modal features', () => {
321
322
 
322
323
  wrapper.unmount();
323
324
  });
325
+
326
+ it('makes the close button invisible instead of hidden, when enableClose is false', async () => {
327
+ const wrapper = createModalsWrapper({
328
+ enableClose: false,
329
+ });
330
+
331
+ await waitNT(wrapper.vm);
332
+
333
+ await wrapper.find('.open-modal-1').trigger('click');
334
+
335
+ await sleep(100);
336
+
337
+ const modal1 = wrapper.getComponent('.modal-1');
338
+
339
+ const closeBtn = modal1.find('button[aria-label="Close"]');
340
+
341
+ expect(closeBtn.isVisible()).toBe(true);
342
+ expect(closeBtn.classes()).toContain('invisible');
343
+
344
+ wrapper.unmount();
345
+ });
324
346
  });
@@ -36,7 +36,12 @@
36
36
  {{ title }}
37
37
  </h3>
38
38
  <div class="ml-auto">
39
- <PCloseBtn v-if="enableClose" :disabled="disabled" :aria-label="closeLabel" @click.prevent="close" />
39
+ <PCloseBtn
40
+ :disabled="disabled"
41
+ :class="{ invisible: !enableClose }"
42
+ :aria-label="closeLabel"
43
+ @click.prevent="close"
44
+ />
40
45
  </div>
41
46
  </div>
42
47
  </slot>
@@ -43,10 +43,10 @@ export const Active = {
43
43
  },
44
44
  };
45
45
 
46
- export const WithToolyip = {
46
+ export const WithTooltip = {
47
47
  args: {
48
48
  ...Default.args,
49
- tooltipText: 'This is a tooltip',
49
+ tooltipText: `Lorem ipsum dolor sit amet.`,
50
50
  },
51
51
  };
52
52
 
@@ -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
+ };