odac 1.4.2 → 1.4.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ### ⚙️ Engine Tuning
2
+
3
+ - **client:** extract ws connection logic and fix recursive sharedworker reconnects reconnect bug
4
+ - **client:** remove redundant truthy check for websocket token
5
+ - **route:** remove useless variable assignment for decodedUrl
6
+
7
+ ### ⚡️ Performance Upgrades
8
+
9
+ - **client:** remove redundant token consumption during websocket initialization
10
+
11
+ ### 📚 Documentation
12
+
13
+ - **README:** enhance security section with detailed CSRF protection features
14
+
15
+ ### 🛠️ Fixes & Improvements
16
+
17
+ - **client:** enhance websocket reconnection logic with attempt tracking and timer management
18
+ - **client:** preserve existing websocket subprotocols & add try/catch layer to token provider
19
+ - **route:** improve URL decoding and public path handling for file requests
20
+ - **route:** sanitize decoded URL and improve public path validation
21
+ - **route:** use robust path.extname instead of string splitting for mime type resolution
22
+ - **websocket:** implement token provider for dynamic token handling on reconnect
23
+
24
+
25
+
26
+ ---
27
+
28
+ Powered by [⚡ ODAC](https://odac.run)
29
+
1
30
  ### doc
2
31
 
3
32
  - **forms:** update backend and frontend forms documentation with practical usage patterns and improved descriptions
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  * 🎨 **Built-in Tailwind CSS:** Zero-config integration with Tailwind CSS v4. Automatic compilation and optimization out of the box.
10
10
  * 🔗 **Powerful Routing:** Create clean, custom URLs and manage infinite pages with a flexible routing system.
11
11
  * ✨ **Seamless SPA Experience:** Automatic AJAX handling for forms and page transitions eliminates the need for complex client-side code.
12
- * 🛡️ **Built-in Security:** Automatic CSRF protection and secure default headers keep your application safe.
12
+ * 🛡️ **Built-in Security:** Enterprise-grade security out of the box. Includes secure default headers and a **Multi-tab Safe, Single-Use CSRF Protection (Nonce)**. Tokens self-replenish in the background, ensuring maximum defense without ever interrupting the user experience.
13
13
  * 🔐 **Authentication:** Ready-to-use session management with enterprise-grade **Refresh Token Rotation**, secure password hashing, and authentication helpers.
14
14
  * 🗄️ **Database Agnostic:** Integrated support for major databases (PostgreSQL, MySQL, SQLite) and Redis via Knex.js.
15
15
  * 🌍 **i18n Support:** Native multi-language support to help you reach a global audience.
package/client/odac.js CHANGED
@@ -7,10 +7,12 @@ class OdacWebSocket {
7
7
  #reconnectAttempts = 0
8
8
  #handlers = {}
9
9
  #isClosed = false
10
+ #tokenProvider = null
10
11
 
11
12
  constructor(url, protocols = [], options = {}) {
12
13
  this.#url = url
13
14
  this.#protocols = protocols
15
+ this.#tokenProvider = options.tokenProvider || null
14
16
  this.#options = {
15
17
  autoReconnect: true,
16
18
  reconnectDelay: 3000,
@@ -23,6 +25,19 @@ class OdacWebSocket {
23
25
  connect() {
24
26
  if (this.#isClosed) return
25
27
 
28
+ if (this.#tokenProvider) {
29
+ try {
30
+ const freshToken = this.#tokenProvider()
31
+ if (freshToken) {
32
+ let current = Array.isArray(this.#protocols) ? this.#protocols : this.#protocols ? [this.#protocols] : []
33
+ this.#protocols = current.filter(p => !p.startsWith('odac-token-'))
34
+ this.#protocols.push(`odac-token-${freshToken}`)
35
+ }
36
+ } catch (e) {
37
+ console.error('Odac WebSocket tokenProvider error:', e)
38
+ }
39
+ }
40
+
26
41
  this.#socket = this.#protocols.length > 0 ? new WebSocket(this.#url, this.#protocols) : new WebSocket(this.#url)
27
42
 
28
43
  this.#socket.onopen = () => {
@@ -924,12 +939,9 @@ if (typeof window !== 'undefined') {
924
939
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
925
940
  const wsUrl = `${protocol}//${window.location.host}${path}`
926
941
  const protocols = []
927
- if (token) {
928
- const csrfToken = this.token()
929
- if (csrfToken) protocols.push(`odac-token-${csrfToken}`)
930
- }
942
+ const tokenProvider = token ? () => this.token() : null
931
943
 
932
- return new OdacWebSocket(wsUrl, protocols, options)
944
+ return new OdacWebSocket(wsUrl, protocols, {...options, tokenProvider})
933
945
  }
934
946
 
935
947
  #createSharedWebSocket(path, options) {
@@ -966,6 +978,9 @@ if (typeof window !== 'undefined') {
966
978
  case 'error':
967
979
  emit('error', data)
968
980
  break
981
+ case 'requestToken':
982
+ worker.port.postMessage({type: 'provideToken', token: this.token()})
983
+ break
969
984
  }
970
985
  }
971
986
 
@@ -1031,6 +1046,67 @@ if (typeof window !== 'undefined') {
1031
1046
 
1032
1047
  const broadcast = (type, data) => ports.forEach(port => port.postMessage({type, data}))
1033
1048
 
1049
+ let wsConfig = null
1050
+ let reconnectAttempts = 0
1051
+ let reconnectTimer = null
1052
+
1053
+ const requestTokenFromPort = () => {
1054
+ return new Promise(resolve => {
1055
+ const firstPort = ports.values().next().value
1056
+ if (!firstPort) return resolve(null)
1057
+
1058
+ let timeoutTimer = null
1059
+
1060
+ const handler = event => {
1061
+ if (event.data.type === 'provideToken') {
1062
+ clearTimeout(timeoutTimer)
1063
+ firstPort.removeEventListener('message', handler)
1064
+ resolve(event.data.token)
1065
+ }
1066
+ }
1067
+
1068
+ timeoutTimer = setTimeout(() => {
1069
+ firstPort.removeEventListener('message', handler)
1070
+ resolve(null)
1071
+ }, 5000)
1072
+
1073
+ firstPort.addEventListener('message', handler)
1074
+ firstPort.postMessage({type: 'requestToken'})
1075
+ })
1076
+ }
1077
+
1078
+ const connectSocket = protocols => {
1079
+ if (!wsConfig || ports.size === 0) return
1080
+ const wsUrl = wsConfig.protocol + '//' + wsConfig.host + wsConfig.path
1081
+ socket = new OdacWebSocket(wsUrl, protocols, {
1082
+ ...wsConfig.options,
1083
+ tokenProvider: null
1084
+ })
1085
+ socket.on('open', () => {
1086
+ reconnectAttempts = 0
1087
+ broadcast('open')
1088
+ })
1089
+ socket.on('message', data => broadcast('message', data))
1090
+ socket.on('close', e => {
1091
+ broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean})
1092
+ const maxAttempts = wsConfig.options.maxReconnectAttempts || 10
1093
+ if (wsConfig && wsConfig.options.autoReconnect !== false && ports.size > 0 && reconnectAttempts < maxAttempts) {
1094
+ if (socket) {
1095
+ socket.close()
1096
+ socket = null
1097
+ }
1098
+ reconnectAttempts++
1099
+ reconnectTimer = setTimeout(() => {
1100
+ requestTokenFromPort().then(freshToken => {
1101
+ if (!freshToken || ports.size === 0) return
1102
+ connectSocket(['odac-token-' + freshToken])
1103
+ })
1104
+ }, wsConfig.options.reconnectDelay || 1000)
1105
+ }
1106
+ })
1107
+ socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
1108
+ }
1109
+
1034
1110
  self.onconnect = e => {
1035
1111
  const port = e.ports[0]
1036
1112
  ports.add(port)
@@ -1041,15 +1117,10 @@ if (typeof window !== 'undefined') {
1041
1117
  switch (type) {
1042
1118
  case 'connect':
1043
1119
  if (!socket) {
1044
- const wsUrl = protocol + '//' + host + path
1120
+ wsConfig = {host, path, protocol, options}
1045
1121
  const protocols = token ? ['odac-token-' + token] : []
1046
- socket = new OdacWebSocket(wsUrl, protocols, options)
1047
- socket.on('open', () => broadcast('open'))
1048
- socket.on('message', data => broadcast('message', data))
1049
- socket.on('close', e => broadcast('close', {code: e?.code, reason: e?.reason, wasClean: e?.wasClean}))
1050
- socket.on('error', e => broadcast('error', {message: e?.message || 'WebSocket error'}))
1122
+ connectSocket(protocols)
1051
1123
  } else if (socket.connected) {
1052
- // If already connected, notify the new port immediately
1053
1124
  port.postMessage({type: 'open'})
1054
1125
  }
1055
1126
  break
@@ -1058,9 +1129,15 @@ if (typeof window !== 'undefined') {
1058
1129
  break
1059
1130
  case 'close':
1060
1131
  ports.delete(port)
1061
- if (ports.size === 0 && socket) {
1062
- socket.close()
1063
- socket = null
1132
+ if (ports.size === 0) {
1133
+ if (reconnectTimer) {
1134
+ clearTimeout(reconnectTimer)
1135
+ reconnectTimer = null
1136
+ }
1137
+ if (socket) {
1138
+ socket.close()
1139
+ socket = null
1140
+ }
1064
1141
  }
1065
1142
  break
1066
1143
  }
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.2",
10
+ "version": "1.4.3",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
package/src/Route.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs')
2
2
  const fsPromises = fs.promises
3
+ const path = require('path')
3
4
 
4
5
  const Cron = require('./Route/Cron.js')
5
6
  const Internal = require('./Route/Internal.js')
@@ -219,42 +220,52 @@ class Route {
219
220
  if (typeof page === 'string') Odac.Request.page = page
220
221
  return await this.#executeController(Odac, pageController)
221
222
  }
222
- if (url && !url.includes('/../')) {
223
- const publicPath = `${__dir}/public${url}`
224
223
 
225
- // PROD CACHE HIT (Metadata)
226
- if (!Odac.Config.debug && this.#publicCache[publicPath]) {
227
- const cached = this.#publicCache[publicPath]
228
- Odac.Request.header('Content-Type', cached.type)
229
- Odac.Request.header('Cache-Control', 'public, max-age=31536000')
230
- Odac.Request.header('Content-Length', cached.size)
231
- return fs.createReadStream(publicPath)
232
- }
224
+ let decodedUrl
225
+ try {
226
+ decodedUrl = decodeURIComponent(url)
227
+ } catch {
228
+ decodedUrl = null // Invalid URI encoding
229
+ }
233
230
 
234
- try {
235
- const stat = await fsPromises.stat(publicPath)
236
- if (stat.isFile()) {
237
- let type = 'text/html'
238
- if (url.includes('.')) {
239
- let arr = url.split('.')
240
- type = mime[arr[arr.length - 1]]
241
- }
231
+ if (decodedUrl && !decodedUrl.includes('\0')) {
232
+ const publicDir = path.normalize(`${__dir}/public`)
233
+ const safeUrl = decodedUrl.replace(/^[/\\]+/, '')
234
+ const publicPath = path.normalize(path.join(publicDir, safeUrl))
235
+ const relativePath = path.relative(publicDir, publicPath)
242
236
 
243
- // PROD CACHE SET (Metadata Only)
244
- if (!Odac.Config.debug) {
245
- this.#publicCache[publicPath] = {
246
- type,
247
- size: stat.size
248
- }
249
- }
250
-
251
- Odac.Request.header('Content-Type', type)
237
+ if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) {
238
+ // PROD CACHE HIT (Metadata)
239
+ if (!Odac.Config.debug && this.#publicCache[publicPath]) {
240
+ const cached = this.#publicCache[publicPath]
241
+ Odac.Request.header('Content-Type', cached.type)
252
242
  Odac.Request.header('Cache-Control', 'public, max-age=31536000')
253
- Odac.Request.header('Content-Length', stat.size)
243
+ Odac.Request.header('Content-Length', cached.size)
254
244
  return fs.createReadStream(publicPath)
255
245
  }
256
- } catch {
257
- // File not found in public
246
+
247
+ try {
248
+ const stat = await fsPromises.stat(publicPath)
249
+ if (stat.isFile()) {
250
+ const extension = path.extname(publicPath).slice(1)
251
+ const type = mime[extension] || 'text/html'
252
+
253
+ // PROD CACHE SET (Metadata Only)
254
+ if (!Odac.Config.debug) {
255
+ this.#publicCache[publicPath] = {
256
+ type,
257
+ size: stat.size
258
+ }
259
+ }
260
+
261
+ Odac.Request.header('Content-Type', type)
262
+ Odac.Request.header('Cache-Control', 'public, max-age=31536000')
263
+ Odac.Request.header('Content-Length', stat.size)
264
+ return fs.createReadStream(publicPath)
265
+ }
266
+ } catch {
267
+ // File not found in public
268
+ }
258
269
  }
259
270
  }
260
271
 
@@ -83,4 +83,36 @@ describe('Odac.ws()', () => {
83
83
  socketInstance.onopen()
84
84
  expect(openHandler).toHaveBeenCalled()
85
85
  })
86
+
87
+ test('should use fresh token on reconnect', () => {
88
+ let tokenCounter = 0
89
+ mockXhr.response = JSON.stringify({token: 'initial-token'})
90
+ mockXhr.responseText = JSON.stringify({token: 'initial-token'})
91
+ mockXhr.onload = null
92
+ mockXhr.send = jest.fn(function () {
93
+ tokenCounter++
94
+ this.response = JSON.stringify({token: `token-${tokenCounter}`})
95
+ this.responseText = this.response
96
+ if (this.onload) this.onload()
97
+ })
98
+ mockDocument.cookie = 'odac_client=test-client'
99
+
100
+ window.Odac.ws('/test-ws', {token: true, autoReconnect: true, reconnectDelay: 100})
101
+ const firstCall = WebSocket.mock.calls[0]
102
+ expect(firstCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(/^odac-token-/)]))
103
+ const firstToken = firstCall[1][0]
104
+
105
+ const socketInstance = WebSocket.mock.results[0].value
106
+ socketInstance.onopen()
107
+
108
+ global.setTimeout = jest.fn(fn => fn())
109
+ socketInstance.readyState = 3
110
+ socketInstance.onclose({code: 1006})
111
+
112
+ expect(WebSocket.mock.calls.length).toBeGreaterThan(1)
113
+ const secondCall = WebSocket.mock.calls[1]
114
+ expect(secondCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(/^odac-token-/)]))
115
+ const secondToken = secondCall[1][0]
116
+ expect(secondToken).not.toBe(firstToken)
117
+ })
86
118
  })
@@ -308,4 +308,105 @@ describe('Route.check()', () => {
308
308
  expect(paramHandler).not.toHaveBeenCalled()
309
309
  })
310
310
  })
311
+
312
+ describe('public static file serving', () => {
313
+ const fs = require('fs')
314
+ const path = require('path')
315
+
316
+ const createMockOdac = url => ({
317
+ Auth: {check: jest.fn().mockResolvedValue(true)},
318
+ Config: {debug: true},
319
+ Request: {
320
+ url,
321
+ method: 'get',
322
+ route: 'test_route',
323
+ header: jest.fn(),
324
+ cookie: jest.fn(() => null),
325
+ abort: jest.fn(),
326
+ setSession: jest.fn(),
327
+ data: {url: {}}
328
+ },
329
+ request: jest.fn().mockResolvedValue(null)
330
+ })
331
+
332
+ beforeEach(() => {
333
+ global.__dir = '/app'
334
+ })
335
+
336
+ afterEach(() => {
337
+ jest.restoreAllMocks()
338
+ })
339
+
340
+ it('should serve a public static file successfully', async () => {
341
+ const mockStat = jest.spyOn(fs.promises, 'stat').mockResolvedValue({isFile: () => true, size: 1024})
342
+ const mockStream = {pipe: jest.fn()}
343
+ const mockCreateReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue(mockStream)
344
+ const expectedPath = path.normalize('/app/public/style.css')
345
+
346
+ const mockOdac = createMockOdac('/style.css')
347
+ const result = await route.check(mockOdac)
348
+
349
+ expect(mockStat).toHaveBeenCalledWith(expectedPath)
350
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/css')
351
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Length', 1024)
352
+ expect(mockCreateReadStream).toHaveBeenCalledWith(expectedPath)
353
+ expect(result).toBe(mockStream)
354
+ })
355
+
356
+ it('should prevent path traversal attacks', async () => {
357
+ const mockStat = jest.spyOn(fs.promises, 'stat')
358
+
359
+ const mockOdac = createMockOdac('/../secrets.txt')
360
+ const result = await route.check(mockOdac)
361
+
362
+ expect(mockStat).not.toHaveBeenCalled()
363
+ expect(result).toBeUndefined() // Falls through if blocked
364
+ })
365
+
366
+ it('should prevent null byte injection attacks', async () => {
367
+ const mockStat = jest.spyOn(fs.promises, 'stat')
368
+
369
+ const mockOdac = createMockOdac('/style%00.css')
370
+ const result = await route.check(mockOdac)
371
+
372
+ expect(mockStat).not.toHaveBeenCalled()
373
+ expect(result).toBeUndefined()
374
+ })
375
+
376
+ it('should handle invalid URI encoding gracefully', async () => {
377
+ const mockStat = jest.spyOn(fs.promises, 'stat')
378
+
379
+ const mockOdac = createMockOdac('/%E0%A4%A') // Invalid URL encoded string
380
+ const result = await route.check(mockOdac)
381
+
382
+ expect(mockStat).not.toHaveBeenCalled()
383
+ expect(result).toBeUndefined()
384
+ })
385
+
386
+ it('should cache metadata in production mode', async () => {
387
+ jest.spyOn(fs.promises, 'stat').mockResolvedValue({isFile: () => true, size: 2048})
388
+ const mockCreateReadStream = jest.spyOn(fs, 'createReadStream').mockReturnValue('mock_stream')
389
+
390
+ const mockOdac = createMockOdac('/script.js')
391
+ mockOdac.Config.debug = false // prod mode
392
+
393
+ // first call (cache miss -> set cache)
394
+ await route.check(mockOdac)
395
+ expect(fs.promises.stat).toHaveBeenCalledTimes(1)
396
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/javascript')
397
+
398
+ // reset mock tracking (cache hit -> no stat)
399
+ jest.clearAllMocks()
400
+ jest.spyOn(fs, 'createReadStream').mockReturnValue('mock_stream_2')
401
+
402
+ // second call
403
+ const result2 = await route.check(mockOdac)
404
+
405
+ expect(fs.promises.stat).not.toHaveBeenCalled() // Not called because metadata is cached
406
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Type', 'text/javascript')
407
+ expect(mockOdac.Request.header).toHaveBeenCalledWith('Content-Length', 2048)
408
+ expect(mockCreateReadStream).toHaveBeenCalledWith(path.normalize('/app/public/script.js'))
409
+ expect(result2).toBe('mock_stream_2')
410
+ })
411
+ })
311
412
  })