@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.
- package/app/assets/composables/index.ts +1 -1
- package/app/assets/composables/useCsrf.ts +120 -0
- package/app/assets/index.ts +8 -2
- package/app/assets/stores/index.ts +1 -0
- package/app/assets/tests/composables/useCsrf.test.ts +212 -0
- package/app/assets/tests/plugin.test.ts +2 -1
- package/package.json +4 -4
- /package/app/assets/{composables → stores}/usePageMeta.ts +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { useSprunjer } from './sprunjer'
|
|
2
|
-
export {
|
|
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
|
+
}
|
package/app/assets/index.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -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
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@userfrosting/sprinkle-core",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
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.
|
|
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": "^
|
|
63
|
+
"vite": "^6.2",
|
|
64
64
|
"vite-plugin-dts": "^4.0.0",
|
|
65
|
-
"vitest": "^1.
|
|
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"
|
|
File without changes
|