@modern-admin/feature-upload 0.2.1 → 0.3.2

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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/dist/graphql.d.ts.map +1 -1
  3. package/dist/graphql.js +23 -5
  4. package/dist/graphql.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mime.d.ts +21 -0
  10. package/dist/mime.d.ts.map +1 -0
  11. package/dist/mime.js +46 -0
  12. package/dist/mime.js.map +1 -0
  13. package/dist/nest/upload-sweeper.service.d.ts.map +1 -1
  14. package/dist/nest/upload-sweeper.service.js +7 -0
  15. package/dist/nest/upload-sweeper.service.js.map +1 -1
  16. package/dist/nest/upload.controller.d.ts.map +1 -1
  17. package/dist/nest/upload.controller.js +82 -15
  18. package/dist/nest/upload.controller.js.map +1 -1
  19. package/dist/nest/upload.module.d.ts +5 -0
  20. package/dist/nest/upload.module.d.ts.map +1 -1
  21. package/dist/nest/upload.module.js +5 -0
  22. package/dist/nest/upload.module.js.map +1 -1
  23. package/dist/nest/upload.tokens.d.ts +23 -0
  24. package/dist/nest/upload.tokens.d.ts.map +1 -1
  25. package/dist/path-safety.d.ts +45 -0
  26. package/dist/path-safety.d.ts.map +1 -0
  27. package/dist/path-safety.js +73 -0
  28. package/dist/path-safety.js.map +1 -0
  29. package/dist/pending-registry.d.ts +19 -4
  30. package/dist/pending-registry.d.ts.map +1 -1
  31. package/dist/pending-registry.js +19 -4
  32. package/dist/pending-registry.js.map +1 -1
  33. package/dist/providers/local.d.ts.map +1 -1
  34. package/dist/providers/local.js +16 -3
  35. package/dist/providers/local.js.map +1 -1
  36. package/dist/providers/s3.d.ts.map +1 -1
  37. package/dist/providers/s3.js +14 -0
  38. package/dist/providers/s3.js.map +1 -1
  39. package/dist/registry.d.ts +8 -0
  40. package/dist/registry.d.ts.map +1 -1
  41. package/dist/registry.js.map +1 -1
  42. package/dist/types.d.ts +10 -2
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/upload-feature.d.ts.map +1 -1
  45. package/dist/upload-feature.js +30 -31
  46. package/dist/upload-feature.js.map +1 -1
  47. package/package.json +8 -7
  48. package/src/graphql.ts +22 -5
  49. package/src/index.ts +5 -0
  50. package/src/mime.ts +42 -0
  51. package/src/nest/upload-sweeper.service.ts +9 -0
  52. package/src/nest/upload.controller.ts +95 -14
  53. package/src/nest/upload.module.ts +5 -0
  54. package/src/nest/upload.tokens.ts +23 -0
  55. package/src/path-safety.ts +73 -0
  56. package/src/pending-registry.ts +19 -4
  57. package/src/providers/local.ts +15 -3
  58. package/src/providers/s3.ts +17 -0
  59. package/src/registry.ts +8 -0
  60. package/src/types.ts +10 -2
  61. package/src/upload-feature.ts +31 -36
package/src/mime.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Server-side MIME allow-list matching.
3
+ *
4
+ * `UploadPropertyConfig.mimeTypes` uses the same syntax as the HTML `accept`
5
+ * attribute — exact types (`image/jpeg`), type wildcards (`image/*`), or the
6
+ * catch-all wildcard (`*`, or its explicit `type`/`subtype` form). The
7
+ * frontend enforces it for UX; the server
8
+ * re-checks it so a hand-crafted request cannot store a disallowed type.
9
+ *
10
+ * Note: the matched MIME is the *declared* `Content-Type` from the multipart
11
+ * part, which a client can spoof. This is defense-in-depth (it stops the
12
+ * trivial "bypass the frontend accept filter" attack and pairs with the size
13
+ * limits), not content sniffing — magic-byte inspection would need a decoder
14
+ * per format and is intentionally out of scope here.
15
+ */
16
+
17
+ /** Normalise a raw MIME to `type/subtype`, lower-cased, without parameters. */
18
+ function normalizeType(raw: string): string {
19
+ return raw.split(';')[0]!.trim().toLowerCase()
20
+ }
21
+
22
+ /**
23
+ * True when `type` matches at least one pattern in `patterns`. An empty or
24
+ * omitted pattern list allows everything (no restriction configured).
25
+ */
26
+ export function mimeMatches(type: string, patterns: readonly string[] | null | undefined): boolean {
27
+ if (!patterns || patterns.length === 0) return true
28
+ const t = normalizeType(type)
29
+ if (!t) return false
30
+ const [tMain, tSub] = t.split('/')
31
+ for (const raw of patterns) {
32
+ const p = normalizeType(raw)
33
+ if (p === '*' || p === '*/*') return true
34
+ const [pMain, pSub] = p.split('/')
35
+ if (pSub === '*') {
36
+ if (pMain === tMain) return true
37
+ } else if (pMain === tMain && pSub === tSub) {
38
+ return true
39
+ }
40
+ }
41
+ return false
42
+ }
@@ -36,6 +36,15 @@ export class UploadSweeperService implements OnModuleInit, OnModuleDestroy {
36
36
  onModuleInit(): void {
37
37
  const interval = this.options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS
38
38
  if (interval <= 0) return
39
+ if (!this.options.acknowledgeSingleInstance) {
40
+ console.warn(
41
+ '[modern-admin/feature-upload] The pending-upload registry and sweeper are ' +
42
+ 'in-process (single-instance). Behind a load balancer with ≥2 replicas the ' +
43
+ 'sweeper can delete a just-saved file (see PendingUploadsRegistry docs). ' +
44
+ 'Run single-instance until a shared store lands, or pass ' +
45
+ '`acknowledgeSingleInstance: true` to silence this warning.',
46
+ )
47
+ }
39
48
  this.timer = setInterval(() => {
40
49
  void PendingUploadsRegistry.sweep()
41
50
  }, interval)
@@ -29,10 +29,12 @@ import {
29
29
  Inject,
30
30
  InternalServerErrorException,
31
31
  NotFoundException,
32
+ PayloadTooLargeException,
32
33
  Post,
33
34
  Param,
34
35
  Query,
35
36
  Req,
37
+ UnsupportedMediaTypeException,
36
38
  UseGuards,
37
39
  } from '@nestjs/common'
38
40
  import { ApiCookieAuth, ApiTags } from '@nestjs/swagger'
@@ -46,15 +48,46 @@ import {
46
48
  import { MODERN_ADMIN, ModernAdminAuthGuard } from '@modern-admin/nest'
47
49
  import { UploadProviderRegistry } from '../registry.js'
48
50
  import { PendingUploadsRegistry } from '../pending-registry.js'
51
+ import { isUnsafeKey, sanitizeFilename } from '../path-safety.js'
52
+ import { mimeMatches } from '../mime.js'
49
53
  import { UPLOAD_MODULE_OPTIONS, type ModernAdminUploadModuleOptions } from './upload.tokens.js'
50
54
  import type { UploadedFile, UploadedFileInfo } from '../types.js'
51
55
 
52
- /** Reads every file from a multipart/form-data stream using busboy. */
53
- function parseAllFiles(req: IncomingMessage): Promise<UploadedFile[]> {
56
+ /** Default hard cap on a single buffered file (bytes) — 25 MiB. */
57
+ const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024
58
+ /** Default cap on files per multipart request for `isArray` properties. */
59
+ const DEFAULT_MAX_FILES = 20
60
+
61
+ /** Server-side limits enforced while parsing a multipart upload. */
62
+ interface ParseLimits {
63
+ /** Hard per-file byte cap. Streams exceeding it are rejected (413). */
64
+ maxFileSize: number
65
+ /** Max files per request. Excess files are rejected (400). */
66
+ maxFiles: number
67
+ /** Allowed MIME patterns (HTML `accept` syntax). Empty ⇒ no restriction. */
68
+ mimeTypes?: string[]
69
+ }
70
+
71
+ /**
72
+ * Reads every file from a multipart/form-data stream using busboy, enforcing
73
+ * size / count / MIME limits *as it streams* so a hostile request cannot
74
+ * buffer unbounded bytes into memory before any check runs.
75
+ */
76
+ function parseAllFiles(req: IncomingMessage, limits: ParseLimits): Promise<UploadedFile[]> {
54
77
  return new Promise((resolve, reject) => {
55
78
  let bb: ReturnType<typeof Busboy>
56
79
  try {
57
- bb = Busboy({ headers: req.headers as Record<string, string> })
80
+ bb = Busboy({
81
+ headers: req.headers as Record<string, string>,
82
+ limits: {
83
+ fileSize: limits.maxFileSize,
84
+ files: limits.maxFiles,
85
+ // The upload endpoint carries no data fields — keep them tightly
86
+ // bounded so field spam cannot buffer memory either.
87
+ fields: 10,
88
+ fieldSize: 100 * 1024,
89
+ },
90
+ })
58
91
  } catch {
59
92
  reject(new BadRequestException('Request is not multipart/form-data'))
60
93
  return
@@ -67,6 +100,10 @@ function parseAllFiles(req: IncomingMessage): Promise<UploadedFile[]> {
67
100
  let settled = false
68
101
  let firstError: unknown
69
102
 
103
+ const fail = (err: unknown): void => {
104
+ firstError = firstError ?? err
105
+ }
106
+
70
107
  const tryResolve = (): void => {
71
108
  if (settled || !finished || pending > 0) return
72
109
  settled = true
@@ -77,33 +114,59 @@ function parseAllFiles(req: IncomingMessage): Promise<UploadedFile[]> {
77
114
 
78
115
  bb.on('file', (_fieldname, stream, info) => {
79
116
  pending++
117
+ // Reject a disallowed MIME up front — don't buffer a single byte of it.
118
+ const mimeType = info.mimeType || 'application/octet-stream'
119
+ if (!mimeMatches(mimeType, limits.mimeTypes)) {
120
+ fail(new UnsupportedMediaTypeException(`File type "${mimeType}" is not allowed`))
121
+ stream.resume() // drain so busboy keeps parsing the rest of the body
122
+ stream.on('close', () => {
123
+ pending--
124
+ tryResolve()
125
+ })
126
+ return
127
+ }
80
128
  const chunks: Buffer[] = []
81
129
  stream.on('data', (chunk: Buffer) => chunks.push(chunk))
130
+ // Busboy emits 'limit' when the per-file byte cap is exceeded.
131
+ stream.on('limit', () => {
132
+ fail(new PayloadTooLargeException(`File exceeds the maximum size of ${limits.maxFileSize} bytes`))
133
+ })
82
134
  stream.on('end', () => {
83
- const buffer = Buffer.concat(chunks)
84
- files.push({
85
- originalName: info.filename || 'upload',
86
- mimeType: info.mimeType || 'application/octet-stream',
87
- size: buffer.length,
88
- buffer,
89
- })
135
+ // `truncated` is set when the fileSize limit tripped mid-stream.
136
+ if (!(stream as { truncated?: boolean }).truncated) {
137
+ const buffer = Buffer.concat(chunks)
138
+ files.push({
139
+ // Sanitise before the name can steer key generation (extname /
140
+ // custom uploadPath) — strips separators and traversal segments.
141
+ originalName: sanitizeFilename(info.filename),
142
+ mimeType,
143
+ size: buffer.length,
144
+ buffer,
145
+ })
146
+ }
90
147
  pending--
91
148
  tryResolve()
92
149
  })
93
150
  stream.on('error', (err) => {
94
- firstError = firstError ?? err
151
+ fail(err)
95
152
  pending--
96
153
  tryResolve()
97
154
  })
98
155
  })
99
156
 
157
+ // Busboy stops emitting files past the `files` limit — surface it as a 400
158
+ // rather than silently dropping the extras.
159
+ bb.on('filesLimit', () => {
160
+ fail(new BadRequestException(`Too many files — at most ${limits.maxFiles} allowed`))
161
+ })
162
+
100
163
  bb.on('finish', () => {
101
164
  finished = true
102
165
  tryResolve()
103
166
  })
104
167
 
105
168
  bb.on('error', (err: unknown) => {
106
- firstError = firstError ?? err
169
+ fail(err)
107
170
  finished = true
108
171
  tryResolve()
109
172
  })
@@ -137,8 +200,20 @@ export class UploadController {
137
200
  ): Promise<UploadedFileInfo[]> {
138
201
  const { providerId, registered } = this.resolveProperty(resourceId, field)
139
202
 
140
- // 1 — Parse the multipart body (collect every file).
141
- const files = await parseAllFiles(req)
203
+ // 1 — Parse the multipart body (collect every file), enforcing size /
204
+ // count / MIME limits while streaming so nothing unbounded is buffered.
205
+ const moduleMax = this.moduleOptions.maxFileSize ?? DEFAULT_MAX_FILE_SIZE
206
+ // Per-property maxSize tightens the cap but can never raise it above the
207
+ // module-wide hard bound.
208
+ const maxFileSize = Math.min(moduleMax, registered.maxSize ?? Number.POSITIVE_INFINITY)
209
+ const maxFiles = registered.isArray
210
+ ? (this.moduleOptions.maxFiles ?? DEFAULT_MAX_FILES)
211
+ : 1
212
+ const files = await parseAllFiles(req, {
213
+ maxFileSize,
214
+ maxFiles,
215
+ ...(registered.mimeTypes ? { mimeTypes: registered.mimeTypes } : {}),
216
+ })
142
217
 
143
218
  // 2 — If the property is single-value, only the first file is honoured.
144
219
  // The frontend sends one anyway; this is a safety bound.
@@ -150,6 +225,11 @@ export class UploadController {
150
225
  const results: UploadedFileInfo[] = []
151
226
  for (const file of accepted) {
152
227
  const computedKey = registered.uploadPath ? registered.uploadPath(file.originalName) : undefined
228
+ // A custom `uploadPath` could still produce a traversal/absolute key —
229
+ // reject at the boundary (the provider also contains it defensively).
230
+ if (computedKey !== undefined && isUnsafeKey(computedKey)) {
231
+ throw new BadRequestException('Computed upload key is not allowed')
232
+ }
153
233
  const key = await registered.provider.upload(file, computedKey)
154
234
  // Track as pending — it will be confirmed by `new.after`/`edit.after`
155
235
  // when the form is saved, or cleaned up by the sweeper when the TTL
@@ -178,6 +258,7 @@ export class UploadController {
178
258
  @Query('key') key: string,
179
259
  ): Promise<void> {
180
260
  if (!key) throw new BadRequestException('Missing "key" query parameter')
261
+ if (isUnsafeKey(key)) throw new BadRequestException('Invalid "key" query parameter')
181
262
  // Validate that the resource/field is actually upload-enabled. This avoids
182
263
  // leaking the existence of arbitrary resource ids to unauthenticated callers
183
264
  // (the auth guard already requires authentication).
@@ -18,6 +18,11 @@
18
18
  * The module depends on `MODERN_ADMIN` and `ModernAdminAuthGuard` being
19
19
  * available in the DI tree, which is satisfied when `ModernAdminModule.forRoot`
20
20
  * is registered with `global: true` (the recommended default).
21
+ *
22
+ * ⚠️ Single-instance: the pending-upload registry + sweeper are in-process.
23
+ * Behind a load balancer with ≥2 replicas the sweeper can delete a just-saved
24
+ * file (see `PendingUploadsRegistry` docs). The sweeper warns once at startup;
25
+ * pass `acknowledgeSingleInstance: true` to silence it once understood.
21
26
  */
22
27
 
23
28
  import { type DynamicModule, Module } from '@nestjs/common'
@@ -21,4 +21,27 @@ export interface ModernAdminUploadModuleOptions {
21
21
  * just never expire).
22
22
  */
23
23
  sweepIntervalMs?: number
24
+ /**
25
+ * Hard upper bound (bytes) on a single uploaded file, applied by the
26
+ * multipart parser regardless of per-property `maxSize`. Files are buffered
27
+ * in memory, so this caps memory use per request and blocks OOM-style DoS.
28
+ * A property's own `maxSize` still applies when it is *smaller* than this.
29
+ * Default: 25 MiB.
30
+ */
31
+ maxFileSize?: number
32
+ /**
33
+ * Maximum number of files accepted in a single multipart request for an
34
+ * `isArray` property. Single-value properties always cap at 1. Default: 20.
35
+ */
36
+ maxFiles?: number
37
+ /**
38
+ * Suppress the single-instance startup warning.
39
+ *
40
+ * `PendingUploadsRegistry` is an in-process `Map`: behind a load balancer
41
+ * with ≥2 replicas the confirm/sweeper lifecycle can delete a just-saved
42
+ * file (see the class docs). The sweeper logs a warning once at startup to
43
+ * make this loud. Set `true` once you have confirmed a single-instance
44
+ * deployment (or accepted the risk) to silence it.
45
+ */
46
+ acknowledgeSingleInstance?: boolean
24
47
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Path-safety helpers shared by the upload providers and the upload
3
+ * entry points (REST controller + GraphQL extension).
4
+ *
5
+ * Storage keys and filenames are attacker-controllable in two ways:
6
+ *
7
+ * 1. **Upload** — the client-supplied `originalName` flows into key
8
+ * generation (default `uuid + extname`, or a custom `uploadPath`). A raw
9
+ * name like `../../evil.sh` must never be able to steer the key outside
10
+ * the intended storage space.
11
+ * 2. **Delete** — a `type: 'file'` property is just a string in the DB. An
12
+ * attacker who can edit a record can write `../../../../etc/passwd` into
13
+ * it; the next edit/delete would call `provider.delete(key)` with that
14
+ * value. Without containment the local provider's `unlink` escapes the
15
+ * upload directory.
16
+ *
17
+ * Defense is layered: keys are rejected at the transport boundary
18
+ * (`isUnsafeKey`), filenames are sanitised before they can influence a key
19
+ * (`sanitizeFilename`), and the local provider resolves every path against its
20
+ * upload directory (`resolveWithinDir`) as the authoritative backstop.
21
+ */
22
+
23
+ import { resolve, sep } from 'node:path'
24
+
25
+ /**
26
+ * True when `key` must be refused: empty, containing a NUL byte, absolute
27
+ * (POSIX `/…` or Windows `C:\…`), or containing a `..` traversal segment.
28
+ *
29
+ * Forward and back slashes are both treated as separators so a key crafted on
30
+ * one platform cannot escape on another. Legitimate nested keys
31
+ * (`avatars/2024/uuid.jpg`) and prefixes are allowed — only traversal and
32
+ * absolute paths are rejected.
33
+ */
34
+ export function isUnsafeKey(key: unknown): boolean {
35
+ if (typeof key !== 'string' || key.length === 0) return true
36
+ if (key.includes('\0')) return true
37
+ const normalized = key.replace(/\\/g, '/')
38
+ if (normalized.startsWith('/')) return true // POSIX absolute
39
+ if (/^[a-zA-Z]:/.test(normalized)) return true // Windows drive-absolute
40
+ return normalized.split('/').some((segment) => segment === '..')
41
+ }
42
+
43
+ /**
44
+ * Reduce a client-supplied filename to a safe basename that cannot influence
45
+ * the directory a key resolves into. Strips any directory components (either
46
+ * separator), NUL bytes, and leading dots (so `..` / hidden-traversal names
47
+ * collapse). Falls back to `'upload'` when nothing safe remains.
48
+ */
49
+ export function sanitizeFilename(name: unknown): string {
50
+ if (typeof name !== 'string' || name.length === 0) return 'upload'
51
+ const base = name.replace(/\\/g, '/').split('/').pop() ?? ''
52
+ const cleaned = base
53
+ .replace(/\0/g, '')
54
+ .replace(/^\.+/, '') // no leading dots → no bare '..' / hidden traversal
55
+ .trim()
56
+ return cleaned.length > 0 ? cleaned : 'upload'
57
+ }
58
+
59
+ /**
60
+ * Resolve `key` against `baseDir` and guarantee the result stays inside it.
61
+ * Throws when the resolved path escapes the directory (traversal / absolute
62
+ * key). Returns the absolute, contained path for the caller to read/write.
63
+ */
64
+ export function resolveWithinDir(baseDir: string, key: string): string {
65
+ const base = resolve(baseDir)
66
+ const full = resolve(base, key)
67
+ if (full !== base && !full.startsWith(base + sep)) {
68
+ throw new Error(
69
+ `[modern-admin/feature-upload] refusing to access "${key}" outside the upload directory`,
70
+ )
71
+ }
72
+ return full
73
+ }
@@ -27,13 +27,28 @@
27
27
  * The registry is a process-level singleton (same pattern as
28
28
  * `UploadProviderRegistry`).
29
29
  *
30
+ * ⚠️ SINGLE-INSTANCE ONLY (until a shared store lands).
31
+ * ------------------------------------------------------
32
+ * This `Map` lives in one process's memory. Behind a load balancer with ≥2
33
+ * replicas it is UNSAFE and can lose data:
34
+ *
35
+ * 1. Replica A handles `POST /upload` → `track(key)` in A's map only.
36
+ * 2. Replica B handles the form save → `confirm(key)` is a no-op (B never
37
+ * saw the pending entry).
38
+ * 3. A's sweeper still considers the key pending; once the TTL elapses it
39
+ * DELETES the file the record now references → broken/missing upload.
40
+ *
41
+ * The provider *registry* key is deterministic (`up_<resourceId>_<prop>`), so
42
+ * uploads still ROUTE correctly across replicas — but the pending lifecycle
43
+ * above does not. Run the upload feature single-instance, OR set a large
44
+ * `pendingTtlMs` to widen the confirm window, until the roadmap item below
45
+ * replaces this with a shared store.
46
+ *
30
47
  * TODO(roadmap): swap the in-process `Map` for a Redis-backed store and move
31
48
  * the sweeper driver (`UploadSweeperService`) onto BullMQ. This is required
32
49
  * for multi-instance deployments where one Nest replica serves the upload
33
- * request and a different replica processes the form submission without a
34
- * shared store the second replica would not see the pending entry, the
35
- * confirm hook would no-op, and the file would be swept on the originator.
36
- * BullMQ also gives us crash-safe scheduling and retries.
50
+ * request and a different replica processes the form submission. BullMQ also
51
+ * gives us crash-safe scheduling and retries.
37
52
  */
38
53
 
39
54
  import { UploadProviderRegistry } from './registry.js'
@@ -10,8 +10,9 @@
10
10
  */
11
11
 
12
12
  import { mkdir, writeFile, unlink } from 'node:fs/promises'
13
- import { join, extname, dirname } from 'node:path'
13
+ import { extname, dirname } from 'node:path'
14
14
  import { uuidv7 } from '@modern-admin/core'
15
+ import { resolveWithinDir } from '../path-safety.js'
15
16
  import type { IUploadProvider, UploadedFile } from '../types.js'
16
17
 
17
18
  export interface LocalUploadOptions {
@@ -36,7 +37,9 @@ export class LocalUploadProvider implements IUploadProvider {
36
37
 
37
38
  async upload(file: UploadedFile, key?: string): Promise<string> {
38
39
  const resolvedKey = key ?? `${uuidv7()}${extname(file.originalName)}`
39
- const dest = join(this.options.uploadDir, resolvedKey)
40
+ // Contain the key inside uploadDir — refuses traversal/absolute keys
41
+ // (e.g. a malicious `uploadPath` or `../../evil.sh`).
42
+ const dest = resolveWithinDir(this.options.uploadDir, resolvedKey)
40
43
  // Create the full directory tree (handles nested keys like 'avatars/2024/01/uuid.jpg').
41
44
  await mkdir(dirname(dest), { recursive: true })
42
45
  await writeFile(dest, file.buffer)
@@ -49,8 +52,17 @@ export class LocalUploadProvider implements IUploadProvider {
49
52
  }
50
53
 
51
54
  async delete(key: string): Promise<void> {
55
+ let dest: string
52
56
  try {
53
- await unlink(join(this.options.uploadDir, key))
57
+ // Refuse to unlink anything outside uploadDir — a `type: 'file'` value
58
+ // is an arbitrary DB string, so `key` may be attacker-controlled
59
+ // (`../../../../app/src/main.ts`). Never delete outside the directory.
60
+ dest = resolveWithinDir(this.options.uploadDir, key)
61
+ } catch {
62
+ return
63
+ }
64
+ try {
65
+ await unlink(dest)
54
66
  } catch (err) {
55
67
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
56
68
  }
@@ -28,6 +28,7 @@
28
28
 
29
29
  import { extname } from 'node:path'
30
30
  import { uuidv7 } from '@modern-admin/core'
31
+ import { isUnsafeKey } from '../path-safety.js'
31
32
  import type { IUploadProvider, UploadedFile } from '../types.js'
32
33
 
33
34
  export interface S3UploadOptions {
@@ -120,6 +121,14 @@ export class S3UploadProvider implements IUploadProvider {
120
121
  }
121
122
 
122
123
  async upload(file: UploadedFile, key?: string): Promise<string> {
124
+ // Reject traversal / absolute keys before they reach the bucket. Keys
125
+ // legitimately contain `/` separators (prefixes), but never `..` or a
126
+ // leading slash.
127
+ if (key !== undefined && isUnsafeKey(key)) {
128
+ throw new Error(
129
+ `[modern-admin/feature-upload] refusing to upload with unsafe key "${key}"`,
130
+ )
131
+ }
123
132
  const c = await this.client()
124
133
  const ext = extname(file.originalName)
125
134
  const prefix = this.options.prefix ? `${this.options.prefix}/` : ''
@@ -163,6 +172,11 @@ export class S3UploadProvider implements IUploadProvider {
163
172
  }
164
173
 
165
174
  async getUrl(key: string): Promise<string> {
175
+ if (isUnsafeKey(key)) {
176
+ throw new Error(
177
+ `[modern-admin/feature-upload] refusing to build a URL for unsafe key "${key}"`,
178
+ )
179
+ }
166
180
  if (this.options.signed) {
167
181
  return this.signedUrl(key)
168
182
  }
@@ -170,6 +184,9 @@ export class S3UploadProvider implements IUploadProvider {
170
184
  }
171
185
 
172
186
  async delete(key: string): Promise<void> {
187
+ // A `type: 'file'` value is an arbitrary DB string — never issue a
188
+ // DeleteObject for a traversal/absolute key.
189
+ if (isUnsafeKey(key)) return
173
190
  try {
174
191
  const sdk = await import('@aws-sdk/client-s3' as string)
175
192
  const c = await this.client()
package/src/registry.ts CHANGED
@@ -19,6 +19,14 @@ export interface RegisteredUploadConfig {
19
19
  uploadPath?: (filename: string) => string
20
20
  /** True for multi-file properties — controller will accept N files per request. */
21
21
  isArray?: boolean
22
+ /**
23
+ * Allowed MIME patterns (HTML `accept` syntax). Enforced server-side by the
24
+ * upload controller / GraphQL resolver — a request whose file declares a
25
+ * non-matching type is rejected. `undefined`/empty means no restriction.
26
+ */
27
+ mimeTypes?: string[]
28
+ /** Maximum accepted file size in bytes. Enforced server-side (per file). */
29
+ maxSize?: number
22
30
  }
23
31
 
24
32
  const _registry = new Map<string, RegisteredUploadConfig>()
package/src/types.ts CHANGED
@@ -83,10 +83,18 @@ export interface UploadPropertyConfig {
83
83
  /**
84
84
  * Allowed MIME type patterns (same syntax as the HTML `accept` attribute).
85
85
  * Examples: `['image/*']`, `['image/jpeg', 'application/pdf']`.
86
- * Enforcement is advisory on the frontend; the controller does not re-validate.
86
+ * Enforced both on the frontend (UX) and server-side the upload controller
87
+ * / GraphQL resolver rejects a file whose declared type does not match. The
88
+ * server match is on the *declared* Content-Type (defense-in-depth, not
89
+ * content sniffing).
87
90
  */
88
91
  mimeTypes?: string[]
89
- /** Maximum upload size in bytes. Advisory (frontend warning only). */
92
+ /**
93
+ * Maximum upload size in bytes. Enforced server-side per file (the multipart
94
+ * parser aborts an oversized stream) in addition to the frontend warning.
95
+ * A module-wide hard cap (`ModernAdminUploadModule.forRoot({ maxFileSize })`,
96
+ * default 25 MiB) always applies on top, even when this is unset.
97
+ */
90
98
  maxSize?: number
91
99
  /**
92
100
  * Treat the property as an array of file keys (multi-file upload).
@@ -26,7 +26,7 @@
26
26
  * })
27
27
  */
28
28
 
29
- import { uuidv7, type ActionRequest, type ActionResponse, type FeatureFn, type ResourceOptions } from '@modern-admin/core'
29
+ import { appendAfterHook, uuidv7, type ActionRequest, type ActionResponse, type FeatureFn, type ResourceOptions } from '@modern-admin/core'
30
30
  import type { UploadFeatureOptions, UploadPropertyConfig } from './types.js'
31
31
  import { UploadProviderRegistry } from './registry.js'
32
32
  import { PendingUploadsRegistry } from './pending-registry.js'
@@ -44,25 +44,6 @@ type HookFn = (
44
44
  context: unknown,
45
45
  ) => ActionResponse | Promise<ActionResponse>
46
46
 
47
- /** Normalise a hook value (fn | fn[] | undefined) into an array. */
48
- function toArray(hook: unknown): HookFn[] {
49
- if (!hook) return []
50
- return Array.isArray(hook) ? (hook as HookFn[]) : [hook as HookFn]
51
- }
52
-
53
- /**
54
- * Merge `after` hooks from an existing action override with new hooks,
55
- * producing an array that runs all of them in order.
56
- * If `existing` already defines `after`, the new hooks are appended.
57
- */
58
- function mergeAfterHook(
59
- existing: Record<string, unknown> | undefined,
60
- newHook: HookFn,
61
- ): HookFn[] {
62
- const existing_after = toArray(existing?.after)
63
- return [...existing_after, newHook]
64
- }
65
-
66
47
  // ─── Value helpers ────────────────────────────────────────────────────────────
67
48
 
68
49
  /** Return non-empty file keys for the given value (handles single + array). */
@@ -77,20 +58,34 @@ function toKeys(value: unknown): string[] {
77
58
  // ─── Feature function ─────────────────────────────────────────────────────────
78
59
 
79
60
  export function uploadFeature(options: UploadFeatureOptions): FeatureFn {
80
- // Register configs immediately before the FeatureFn is called.
81
- const registered = new Map<string, RegisteredProp>()
82
-
83
- for (const [propPath, config] of Object.entries(options.properties)) {
84
- const providerId = `up_${uuidv7().replace(/-/g, '')}`
85
- UploadProviderRegistry.register(providerId, {
86
- provider: config.provider,
87
- uploadPath: config.uploadPath,
88
- isArray: config.isArray ?? false,
89
- })
90
- registered.set(propPath, { providerId, config })
91
- }
61
+ // Precompute per-property config plus a process-local fallback id, used only
62
+ // when the FeatureFn is invoked without a resource (e.g. unit tests calling
63
+ // it directly). In production the id is derived from `resource.id()` below.
64
+ const props = Object.entries(options.properties).map(([propPath, config]) => ({
65
+ propPath,
66
+ config,
67
+ fallbackId: `up_${uuidv7().replace(/-/g, '')}`,
68
+ }))
69
+
70
+ return (resourceOptions: ResourceOptions, resource?): ResourceOptions => {
71
+ // Register providers with a DETERMINISTIC id derived from the resource id
72
+ // + property path. Every replica behind a load balancer computes the same
73
+ // id, so a property served by replica A resolves on replica B's registry
74
+ // (a per-process UUID would 500 on the "wrong" replica). Registration
75
+ // happens here (at bootstrap decorate time), before any request is served.
76
+ const registered = new Map<string, RegisteredProp>()
77
+ for (const { propPath, config, fallbackId } of props) {
78
+ const providerId = resource ? `up_${resource.id()}_${propPath}` : fallbackId
79
+ UploadProviderRegistry.register(providerId, {
80
+ provider: config.provider,
81
+ uploadPath: config.uploadPath,
82
+ isArray: config.isArray ?? false,
83
+ ...(config.mimeTypes ? { mimeTypes: config.mimeTypes } : {}),
84
+ ...(config.maxSize != null ? { maxSize: config.maxSize } : {}),
85
+ })
86
+ registered.set(propPath, { providerId, config })
87
+ }
92
88
 
93
- return (resourceOptions: ResourceOptions): ResourceOptions => {
94
89
  // --- Property overrides ---
95
90
  const propOverrides: ResourceOptions['properties'] = {}
96
91
  for (const [propPath, { providerId, config }] of registered) {
@@ -184,15 +179,15 @@ export function uploadFeature(options: UploadFeatureOptions): FeatureFn {
184
179
  const actionOverrides = {
185
180
  new: {
186
181
  ...existingNew,
187
- after: mergeAfterHook(existingNew, newAfterHook),
182
+ after: appendAfterHook(existingNew, newAfterHook),
188
183
  },
189
184
  edit: {
190
185
  ...existingEdit,
191
- after: mergeAfterHook(existingEdit, editAfterHook),
186
+ after: appendAfterHook(existingEdit, editAfterHook),
192
187
  },
193
188
  delete: {
194
189
  ...existingDelete,
195
- after: mergeAfterHook(existingDelete, deleteAfterHook),
190
+ after: appendAfterHook(existingDelete, deleteAfterHook),
196
191
  },
197
192
  } as ResourceOptions['actions']
198
193