@userfrosting/sprinkle-core 6.0.0-alpha.4 → 6.0.0-alpha.5

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.
@@ -1,2 +1,2 @@
1
1
  export { useSprunjer } from './sprunjer'
2
- export { usePageMeta } from './usePageMeta'
2
+ export { useCsrf } from './useCsrf'
@@ -0,0 +1,120 @@
1
+ import { ref, watchEffect } from 'vue'
2
+ import { useConfigStore } from '../stores'
3
+ import axios from 'axios'
4
+
5
+ /**
6
+ * CSRF Protection Composable
7
+ *
8
+ * Automatically sets the CSRF token in the axios headers for all requests.
9
+ * The CSRF token is read from the meta tags in the HTML document.
10
+ * The CSRF token can updated when the server responds with a new token in the headers.
11
+ *
12
+ * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#axios
13
+ */
14
+ export const useCsrf = () => {
15
+ /**
16
+ * Public constant for the CSRF token name and value, plus respective keys.
17
+ */
18
+ const key_name = ref(getNameKey())
19
+ const key_value = ref(getValueKey())
20
+ const name = ref(readMetaTag(key_name.value))
21
+ const token = ref(readMetaTag(key_value.value))
22
+
23
+ /**
24
+ * Set the axios headers for CSRF protection
25
+ */
26
+ function setAxiosHeader() {
27
+ axios.defaults.headers.post[key_name.value] = name.value
28
+ axios.defaults.headers.post[key_value.value] = token.value
29
+ axios.defaults.headers.put[key_name.value] = name.value
30
+ axios.defaults.headers.put[key_value.value] = token.value
31
+ axios.defaults.headers.delete[key_name.value] = name.value
32
+ axios.defaults.headers.delete[key_value.value] = token.value
33
+ axios.defaults.headers.patch[key_name.value] = name.value
34
+ axios.defaults.headers.patch[key_value.value] = token.value
35
+ }
36
+
37
+ /**
38
+ * Get the CSRF token name and value keys from config.
39
+ */
40
+ function getNameKey(): string {
41
+ const config = useConfigStore()
42
+ return config.get('csrf.name', 'csrf') + '_name'
43
+ }
44
+ function getValueKey(): string {
45
+ const config = useConfigStore()
46
+ return config.get('csrf.name', 'csrf') + '_value'
47
+ }
48
+
49
+ /**
50
+ * Meta tag reader and writer
51
+ */
52
+ function readMetaTag(name: string): string {
53
+ return document.querySelector("meta[name='" + name + "']")?.getAttribute('content') ?? ''
54
+ }
55
+ function writeMetaTag(name: string, value: string) {
56
+ const metaTag = document.querySelector("meta[name='" + name + "']")
57
+ if (metaTag) {
58
+ metaTag.setAttribute('content', value)
59
+ } else {
60
+ const newMetaTag = document.createElement('meta')
61
+ newMetaTag.setAttribute('name', name)
62
+ newMetaTag.setAttribute('content', value)
63
+ document.head.appendChild(newMetaTag)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Update the CSRF token with the values from the request headers.
69
+ *
70
+ * N.B.: CSRF keys are hardcoded with '{name}_name' and '{name}_value' in
71
+ * PHP. However, the headers doesn't allows underscores that are replaced
72
+ * with dashes automatically.
73
+ */
74
+ function updateFromHeaders(headers: any) {
75
+ const config = useConfigStore()
76
+ const nameKey = config.get('csrf.name', 'csrf') + '-name'
77
+ const valueKey = config.get('csrf.name', 'csrf') + '-value'
78
+
79
+ // Update both value only if the headers are present
80
+ // This is to avoid overwriting the CSRF token with empty values
81
+ if (nameKey in headers) {
82
+ name.value = headers[nameKey]
83
+ }
84
+ if (valueKey in headers) {
85
+ token.value = headers[valueKey]
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Return if CSRF is enabled
91
+ */
92
+ function isEnabled(): boolean {
93
+ const config = useConfigStore()
94
+ return config.get('csrf.enabled', true)
95
+ }
96
+
97
+ /**
98
+ * Watchers - Watch for changes in the CSRF token and update the axios
99
+ * headers + meta tags
100
+ */
101
+ watchEffect(() => {
102
+ if (isEnabled() && name.value !== '' && token.value !== '') {
103
+ writeMetaTag(key_name.value, name.value)
104
+ writeMetaTag(key_value.value, token.value)
105
+ setAxiosHeader()
106
+ }
107
+ })
108
+
109
+ /**
110
+ * Export functions and managed states
111
+ */
112
+ return {
113
+ key_name,
114
+ key_value,
115
+ name,
116
+ token,
117
+ isEnabled,
118
+ updateFromHeaders
119
+ }
120
+ }
@@ -1,12 +1,13 @@
1
1
  import type { App } from 'vue'
2
2
  import { useConfigStore, useTranslator } from './stores'
3
+ import { useCsrf } from './composables/useCsrf'
3
4
 
4
5
  /**
5
6
  * Core Sprinkle initialization recipe.
6
7
  *
7
8
  * This recipe is responsible for loading the configuration from the api,
8
- * loading the translations and register the translator as $t and $tdate global
9
- * properties.
9
+ * loading the translations, register the translator as $t and $tdate global
10
+ * properties and setting up the axios CSRF headers.
10
11
  */
11
12
  export default {
12
13
  install: (app: App) => {
@@ -22,5 +23,10 @@ export default {
22
23
  translator.load()
23
24
  app.config.globalProperties.$t = translator.translate
24
25
  app.config.globalProperties.$tdate = translator.translateDate
26
+
27
+ /**
28
+ * Setup CSRF Protection.
29
+ */
30
+ useCsrf()
25
31
  }
26
32
  }
@@ -1,2 +1,3 @@
1
1
  export { useConfigStore } from './config'
2
+ export { usePageMeta } from './usePageMeta'
2
3
  export { useTranslator } from './useTranslator'
@@ -0,0 +1,212 @@
1
+ import { describe, expect, beforeEach, afterEach, test, vi } from 'vitest'
2
+ import axios from 'axios'
3
+ import { useConfigStore } from '../../stores/config'
4
+ import { useCsrf } from '../../composables/useCsrf'
5
+ import { nextTick } from 'vue'
6
+
7
+ // Mock the config store
8
+ vi.mock('../../stores/config')
9
+ const mockUseConfigStore = {
10
+ get: vi.fn()
11
+ }
12
+
13
+ describe('Csrf Composable', () => {
14
+ afterEach(() => {
15
+ vi.clearAllMocks()
16
+ vi.resetAllMocks()
17
+ })
18
+
19
+ beforeEach(() => {
20
+ // Mock the config store
21
+ mockUseConfigStore.get.mockImplementation((key, defaultValue) => {
22
+ if (key === 'csrf.enabled') return true
23
+ if (key === 'csrf.name') return 'csrf'
24
+ return defaultValue
25
+ })
26
+ vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
27
+
28
+ // Reset axios defaults
29
+ axios.defaults.headers.post = {}
30
+ axios.defaults.headers.put = {}
31
+ axios.defaults.headers.delete = {}
32
+ axios.defaults.headers.patch = {}
33
+
34
+ // Reset the document head
35
+ document.head.innerHTML = ''
36
+ })
37
+
38
+ test('initializes CSRF token name and value from meta tags', () => {
39
+ document.head.innerHTML = `
40
+ <meta name="csrf_name" content="123456">
41
+ <meta name="csrf_value" content="7c4a8d09">
42
+ `
43
+
44
+ const { name, token } = useCsrf()
45
+ expect(name.value).toBe('123456')
46
+ expect(token.value).toBe('7c4a8d09')
47
+ })
48
+
49
+ test('sets axios headers correctly when CSRF is enabled', async () => {
50
+ const csrf = useCsrf()
51
+
52
+ // Expect axios default headers
53
+ expect(axios.defaults.headers.post).toEqual({})
54
+ expect(axios.defaults.headers.put).toEqual({})
55
+ expect(axios.defaults.headers.delete).toEqual({})
56
+ expect(axios.defaults.headers.patch).toEqual({})
57
+
58
+ // Set CSRF token values - Will trigger the WatchEffect
59
+ csrf.name.value = '654321'
60
+ csrf.token.value = 'abcdef'
61
+
62
+ // Wait for the next tick to ensure watchEffect is triggered
63
+ await nextTick()
64
+
65
+ // Expect axios headers to be set correctly
66
+ expect(csrf.isEnabled()).toBe(true)
67
+ expect(csrf.name.value).toBe('654321')
68
+ expect(csrf.token.value).toBe('abcdef')
69
+ expect(csrf.key_name.value).toBe('csrf_name')
70
+ expect(csrf.key_value.value).toBe('csrf_value')
71
+ expect(axios.defaults.headers.post['csrf_name']).toBe('654321')
72
+ expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
73
+ expect(axios.defaults.headers.put['csrf_name']).toBe('654321')
74
+ expect(axios.defaults.headers.put['csrf_value']).toBe('abcdef')
75
+ expect(axios.defaults.headers.delete['csrf_name']).toBe('654321')
76
+ expect(axios.defaults.headers.delete['csrf_value']).toBe('abcdef')
77
+ expect(axios.defaults.headers.patch['csrf_name']).toBe('654321')
78
+ expect(axios.defaults.headers.patch['csrf_value']).toBe('abcdef')
79
+ })
80
+
81
+ test('does not set axios headers when CSRF is disabled', () => {
82
+ // Change the mock implementation to simulate CSRF being disabled
83
+ mockUseConfigStore.get.mockImplementation((key, defaultValue) => {
84
+ if (key === 'csrf.enabled') return false // CSRF is disabled
85
+ if (key === 'csrf.name') return 'csrf'
86
+ return defaultValue
87
+ })
88
+ vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
89
+
90
+ // Get the CSRF composable
91
+ const csrf = useCsrf()
92
+
93
+ // Assert everything is empty
94
+ expect(csrf.isEnabled()).toBe(false)
95
+ expect(csrf.name.value).toBe('')
96
+ expect(csrf.token.value).toBe('')
97
+ expect(axios.defaults.headers.post).toEqual({})
98
+ expect(axios.defaults.headers.put).toEqual({})
99
+ expect(axios.defaults.headers.delete).toEqual({})
100
+ expect(axios.defaults.headers.patch).toEqual({})
101
+ })
102
+
103
+ test('updates CSRF token updates meta tags', async () => {
104
+ document.head.innerHTML = `
105
+ <meta name="csrf_name" content="old_name">
106
+ <meta name="csrf_value" content="old_value">
107
+ `
108
+
109
+ // Assert initial state
110
+ const csrf = useCsrf()
111
+ expect(csrf.name.value).toBe('old_name')
112
+ expect(csrf.token.value).toBe('old_value')
113
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
114
+ 'old_name'
115
+ )
116
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
117
+ 'old_value'
118
+ )
119
+
120
+ // Update CSRF tokens manually
121
+ csrf.name.value = 'new_name'
122
+ csrf.token.value = 'new_value'
123
+
124
+ // Wait for the next tick to ensure watchEffect is triggered
125
+ await nextTick()
126
+
127
+ // Assert new state
128
+ expect(csrf.name.value).toBe('new_name')
129
+ expect(csrf.token.value).toBe('new_value')
130
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
131
+ 'new_name'
132
+ )
133
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
134
+ 'new_value'
135
+ )
136
+ })
137
+
138
+ test('CSRF token can be updated from headers', async () => {
139
+ const csrf = useCsrf()
140
+
141
+ // Assert initial state
142
+ expect(csrf.name.value).toBe('')
143
+ expect(csrf.token.value).toBe('')
144
+
145
+ const headers = {
146
+ 'csrf-name': 'new_name',
147
+ 'csrf-value': 'new_value'
148
+ }
149
+ csrf.updateFromHeaders(headers)
150
+
151
+ // Wait for the next tick to ensure watchEffect is triggered
152
+ await nextTick()
153
+
154
+ expect(csrf.name.value).toBe('new_name')
155
+ expect(csrf.token.value).toBe('new_value')
156
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
157
+ 'new_name'
158
+ )
159
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
160
+ 'new_value'
161
+ )
162
+ expect(axios.defaults.headers.post['csrf_name']).toBe('new_name')
163
+ expect(axios.defaults.headers.post['csrf_value']).toBe('new_value')
164
+ expect(axios.defaults.headers.put['csrf_name']).toBe('new_name')
165
+ expect(axios.defaults.headers.put['csrf_value']).toBe('new_value')
166
+ expect(axios.defaults.headers.delete['csrf_name']).toBe('new_name')
167
+ expect(axios.defaults.headers.delete['csrf_value']).toBe('new_value')
168
+ expect(axios.defaults.headers.patch['csrf_name']).toBe('new_name')
169
+ expect(axios.defaults.headers.patch['csrf_value']).toBe('new_value')
170
+ })
171
+
172
+ test('CSRF token can handle empty headers', async () => {
173
+ document.head.innerHTML = `
174
+ <meta name="csrf_name" content="123456">
175
+ <meta name="csrf_value" content="abcdef">
176
+ `
177
+
178
+ // Assert initial state
179
+ const csrf = useCsrf()
180
+ expect(csrf.name.value).toBe('123456')
181
+ expect(csrf.token.value).toBe('abcdef')
182
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
183
+ '123456'
184
+ )
185
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
186
+ 'abcdef'
187
+ )
188
+ expect(axios.defaults.headers.post['csrf_name']).toBe('123456')
189
+ expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
190
+
191
+ // Call updateFromHeaders with empty headers
192
+ const headers = {
193
+ foo: 'bar'
194
+ }
195
+ csrf.updateFromHeaders(headers)
196
+
197
+ // Wait for the next tick to ensure watchEffect is triggered
198
+ await nextTick()
199
+
200
+ // Assert state remains unchanged
201
+ expect(csrf.name.value).toBe('123456')
202
+ expect(csrf.token.value).toBe('abcdef')
203
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
204
+ '123456'
205
+ )
206
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
207
+ 'abcdef'
208
+ )
209
+ expect(axios.defaults.headers.post['csrf_name']).toBe('123456')
210
+ expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
211
+ })
212
+ })
@@ -6,7 +6,8 @@ import * as Config from '../stores/config'
6
6
  import * as Translator from '../stores/useTranslator'
7
7
 
8
8
  const mockConfigStore = {
9
- load: vi.fn()
9
+ load: vi.fn(),
10
+ get: vi.fn().mockReturnValue('csrf')
10
11
  }
11
12
 
12
13
  const mockTranslatorStore = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@userfrosting/sprinkle-core",
3
- "version": "6.0.0-alpha.4",
3
+ "version": "6.0.0-alpha.5",
4
4
  "type": "module",
5
5
  "description": "Core Sprinkle for UserFrosting",
6
6
  "funding": "https://opencollective.com/userfrosting",
@@ -49,7 +49,7 @@
49
49
  "@types/luxon": "^3.4.2",
50
50
  "@types/node": "^20.12.5",
51
51
  "@vitejs/plugin-vue": "^5.0.4",
52
- "@vitest/coverage-v8": "^1.6.0",
52
+ "@vitest/coverage-v8": "^3.1.1",
53
53
  "@vue/eslint-config-prettier": "^9.0.0",
54
54
  "@vue/eslint-config-typescript": "^13.0.0",
55
55
  "@vue/test-utils": "^2.4.6",
@@ -60,9 +60,9 @@
60
60
  "less": "^4.2.0",
61
61
  "npm-run-all2": "^6.1.2",
62
62
  "prettier": "^3.2.5",
63
- "vite": "^5.2.8",
63
+ "vite": "^6.2",
64
64
  "vite-plugin-dts": "^4.0.0",
65
- "vitest": "^1.6.0",
65
+ "vitest": "^3.1.1",
66
66
  "vue": "^3.4.21",
67
67
  "vue-router": "^4.2.4",
68
68
  "vue-tsc": "^2.0.11"