@ditojs/server 2.86.0 → 2.88.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.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@ditojs/server",
3
- "version": "2.86.0",
3
+ "version": "2.88.0",
4
4
  "type": "module",
5
5
  "description": "Dito.js Server – Dito.js is a declarative and modern web framework, based on Objection.js, Koa.js and Vue.js",
6
- "repository": "https://github.com/ditojs/dito/tree/master/packages/server",
6
+ "repository": "https://github.com/ditojs/dito/tree/main/packages/server",
7
7
  "author": "Jürg Lehni <juerg@scratchdisk.com> (http://scratchdisk.com)",
8
8
  "license": "MIT",
9
9
  "main": "./src/index.js",
@@ -25,10 +25,10 @@
25
25
  "node >= 18"
26
26
  ],
27
27
  "dependencies": {
28
- "@ditojs/admin": "^2.86.0",
29
- "@ditojs/build": "^2.86.0",
30
- "@ditojs/router": "^2.86.0",
31
- "@ditojs/utils": "^2.86.0",
28
+ "@ditojs/admin": "^2.87.0",
29
+ "@ditojs/build": "^2.87.0",
30
+ "@ditojs/router": "^2.87.0",
31
+ "@ditojs/utils": "^2.87.0",
32
32
  "@koa/cors": "^5.0.0",
33
33
  "@koa/etag": "^5.0.2",
34
34
  "@koa/multer": "^4.0.0",
@@ -84,10 +84,11 @@
84
84
  "@types/koa-session": "^6.4.5",
85
85
  "@types/koa-static": "^4.0.4",
86
86
  "@types/koa__cors": "^5.0.1",
87
+ "@types/koa__multer": "^2.0.8",
87
88
  "@types/node": "^25.4.0",
88
89
  "knex": "^3.1.0",
89
90
  "objection": "^3.1.5",
90
91
  "typescript": "^5.9.3"
91
92
  },
92
- "gitHead": "92bc92fbb077de992fe1422adee414b8a9967c09"
93
+ "gitHead": "1d413e8528f8a40e3cc7950db62ebeb4d687ce67"
93
94
  }
@@ -40,6 +40,7 @@ import { Service } from '../services/index.js'
40
40
  import { Storage } from '../storage/index.js'
41
41
  import { convertSchema } from '../schema/index.js'
42
42
  import { getDuration, subtractDuration } from '../utils/duration.js'
43
+ import { resolveFileUrl } from '../utils/asset.js'
43
44
  import {
44
45
  ResponseError,
45
46
  ValidationError,
@@ -997,8 +998,7 @@ export class Application extends Koa {
997
998
  }...`
998
999
  )
999
1000
  if (url.startsWith('file://')) {
1000
- const filepath = path.resolve(url.substring(7))
1001
- data = await fs.readFile(filepath)
1001
+ data = await fs.readFile(new URL(resolveFileUrl(url)))
1002
1002
  } else {
1003
1003
  const response = await fetch(url)
1004
1004
  const arrayBuffer = await response.arrayBuffer()
@@ -1007,6 +1007,9 @@ export class Application extends Koa {
1007
1007
  }
1008
1008
  }
1009
1009
  const importedFile = await storage.addFile(file, data)
1010
+ // Sign the imported foreign file so it passes verification when
1011
+ // createAssets() triggers $parseJson() → convertAssetFile().
1012
+ storage.signAssetFile(importedFile)
1010
1013
  await this.createAssets(storage, [importedFile], 0, transaction)
1011
1014
  importedFiles.push(importedFile)
1012
1015
  // Merge back the changed file properties into the actual file
@@ -534,8 +534,12 @@ export class Controller {
534
534
  } else if (isFunction(authorize)) {
535
535
  return async (ctx, member) => {
536
536
  const res = await authorize(ctx, member)
537
- // Pass res through `processAuthorize()` to support strings & arrays.
538
- return this.processAuthorize(res)(ctx, member)
537
+ // Deny access if the function forgot to return a value.
538
+ // Otherwise pass res through `processAuthorize()` to
539
+ // support strings & arrays.
540
+ return res === undefined
541
+ ? false
542
+ : this.processAuthorize(res)(ctx, member)
539
543
  }
540
544
  } else if (isString(authorize) || isArray(authorize)) {
541
545
  return async (ctx, member) => {
@@ -66,11 +66,13 @@ export const AssetMixin = mixin(
66
66
  }
67
67
 
68
68
  // @override
69
- $parseJson(json) {
69
+ $parseJson(json, { trusted = false } = {}) {
70
70
  const { file, storage } = json
71
71
  // Convert `AssetMixin#file` to an `AssetFile` instance:
72
72
  if (file && storage) {
73
- this.constructor.app.getStorage(storage)?.convertAssetFile(file)
73
+ this.constructor.app
74
+ .getStorage(storage)
75
+ ?.convertAssetFile(file, { trusted })
74
76
  }
75
77
  return json
76
78
  }
@@ -609,11 +609,11 @@ export class Model extends objection.Model {
609
609
  }
610
610
  // Also run through normal $parseJson(), for handling of `Date` and
611
611
  // `AssetFile`.
612
- return this.$parseJson(json)
612
+ return this.$parseJson(json, { trusted: true })
613
613
  }
614
614
 
615
615
  // @override
616
- $parseJson(json) {
616
+ $parseJson(json, { trusted = false } = {}) {
617
617
  const { constructor } = this
618
618
  for (const key of constructor.dateAttributes) {
619
619
  const date = json[key]
@@ -634,7 +634,7 @@ export class Model extends objection.Model {
634
634
  if (isArray(data)) {
635
635
  data.forEach(convertToAssetFiles)
636
636
  } else {
637
- storage.convertAssetFile(data)
637
+ storage.convertAssetFile(data, { trusted })
638
638
  }
639
639
  }
640
640
  }
@@ -58,6 +58,8 @@ export class AssetFile {
58
58
  }
59
59
 
60
60
  static convert(object, storage) {
61
+ // Signature must never be persisted.
62
+ delete object.signature
61
63
  Object.setPrototypeOf(object, AssetFile.prototype)
62
64
  setHiddenProperty(object, SYMBOL_STORAGE, storage)
63
65
  }
@@ -1,11 +1,14 @@
1
1
  import path from 'path'
2
2
  import { URL } from 'url'
3
+ import crypto from 'crypto'
3
4
  import multer from '@koa/multer'
4
5
  import picomatch from 'picomatch'
5
6
  import { PassThrough } from 'stream'
6
7
  import { readMediaAttributes } from 'leather'
7
8
  import { hyphenate, toPromiseCallback } from '@ditojs/utils'
8
9
  import { AssetFile } from './AssetFile.js'
10
+ import { AssetError } from '../errors/AssetError.js'
11
+ import { resolveFileUrl } from '../utils/asset.js'
9
12
 
10
13
  const storageClasses = {}
11
14
 
@@ -73,10 +76,24 @@ export class Storage {
73
76
  }
74
77
 
75
78
  isImportSourceAllowed(url) {
76
- return picomatch.isMatch(url, this.config.allowedImports || [])
79
+ return picomatch.isMatch(
80
+ resolveFileUrl(url),
81
+ (this.config.allowedImports || []).map(resolveFileUrl)
82
+ )
77
83
  }
78
84
 
79
- convertAssetFile(file) {
85
+ convertAssetFile(file, { trusted = false } = {}) {
86
+ if (!trusted) {
87
+ if (this.isImportSourceAllowed(file.url)) {
88
+ this.signAssetFile(file)
89
+ } else if (!this.verifyAssetFile(file)) {
90
+ throw new AssetError(
91
+ `Invalid asset signature for file '${
92
+ file.name ?? file.key
93
+ }'`
94
+ )
95
+ }
96
+ }
80
97
  AssetFile.convert(file, this)
81
98
  }
82
99
 
@@ -90,7 +107,8 @@ export class Storage {
90
107
  url: this._getFileUrl(storageFile),
91
108
  // In case `config.readDimensions` is set:
92
109
  width: storageFile.width,
93
- height: storageFile.height
110
+ height: storageFile.height,
111
+ signature: this._signAssetKey(storageFile.key)
94
112
  }
95
113
  }
96
114
 
@@ -104,7 +122,7 @@ export class Storage {
104
122
  file.url = this._getFileUrl(file)
105
123
  // TODO: Support `config.readDimensions`, but this can only be done once
106
124
  // there are separate storage instances per model assets config!
107
- this.convertAssetFile(file)
125
+ this.convertAssetFile(file, { trusted: true })
108
126
  return file
109
127
  }
110
128
 
@@ -128,6 +146,34 @@ export class Storage {
128
146
  return this._getFileUrl(file)
129
147
  }
130
148
 
149
+ signAssetFile(file) {
150
+ file.signature = this._signAssetKey(file.key)
151
+ }
152
+
153
+ verifyAssetFile(file) {
154
+ if (file) {
155
+ const expected = this._signAssetKey(file.key)
156
+ try {
157
+ return crypto.timingSafeEqual(
158
+ Buffer.from(expected, 'hex'),
159
+ Buffer.from(file.signature, 'hex')
160
+ )
161
+ } catch {}
162
+ }
163
+ return false
164
+ }
165
+
166
+ _signAssetKey(key) {
167
+ const secret = (
168
+ this.app.keys?.[0] ??
169
+ (Storage._fallbackSecret ??= crypto.randomBytes(32))
170
+ )
171
+ return crypto
172
+ .createHmac('sha256', secret)
173
+ .update(key)
174
+ .digest('hex')
175
+ }
176
+
131
177
  _getUrl(...parts) {
132
178
  return this.url
133
179
  ? new URL(path.posix.join(...parts), this.url).toString()
@@ -0,0 +1,58 @@
1
+ import { Storage } from './Storage.js'
2
+
3
+ function createStorage(keys = ['test-secret']) {
4
+ const app = { keys }
5
+ return new Storage(app, { name: 'test' })
6
+ }
7
+
8
+ function simulateUpload(storage, key, originalname) {
9
+ return storage.convertStorageFile({
10
+ key,
11
+ originalname,
12
+ mimetype: 'image/png',
13
+ size: 2048
14
+ })
15
+ }
16
+
17
+ describe('Storage: asset key signature verification', () => {
18
+ it('preserves the key through a full upload-then-save cycle', () => {
19
+ const storage = createStorage()
20
+ const uploaded = simulateUpload(storage, 'real-key.png', 'photo.png')
21
+ const fromClient = { ...uploaded }
22
+ storage.convertAssetFile(fromClient, { trusted: false })
23
+ expect(fromClient.key).toBe('real-key.png')
24
+ })
25
+
26
+ it('throws when a forged key is submitted', () => {
27
+ const storage = createStorage()
28
+ const file = { key: 'victim-key.png', name: 'photo.png' }
29
+ expect(() => {
30
+ storage.convertAssetFile(file, { trusted: false })
31
+ }).toThrow('Invalid asset signature')
32
+ })
33
+
34
+ it('throws when a valid signature is paired with a swapped key', () => {
35
+ const storage = createStorage()
36
+ const uploaded = simulateUpload(storage, 'legit.png', 'legit.png')
37
+ const forged = { ...uploaded, key: 'victim-file.png' }
38
+ expect(() => {
39
+ storage.convertAssetFile(forged, { trusted: false })
40
+ }).toThrow('Invalid asset signature')
41
+ })
42
+
43
+ it('does not throw when addFile() converts a server-created file', async () => {
44
+ const storage = createStorage()
45
+ const file = { key: 'imported.png', name: 'import.png' }
46
+ const data = Buffer.from('fake-image-data')
47
+ await storage.addFile(file, data)
48
+ expect(file.key).toBe('imported.png')
49
+ })
50
+
51
+ it('strips signature after conversion', () => {
52
+ const storage = createStorage()
53
+ const uploaded = simulateUpload(storage, 'upload.png', 'photo.png')
54
+ const fromClient = { ...uploaded }
55
+ storage.convertAssetFile(fromClient, { trusted: false })
56
+ expect('signature' in fromClient).toBe(false)
57
+ })
58
+ })
@@ -0,0 +1,7 @@
1
+ import path from 'path'
2
+
3
+ export function resolveFileUrl(url) {
4
+ return url.startsWith('file://')
5
+ ? `file://${path.resolve(url.slice(7))}`
6
+ : url
7
+ }