@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.
- package/LICENSE +21 -0
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +23 -5
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/mime.d.ts +21 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +46 -0
- package/dist/mime.js.map +1 -0
- package/dist/nest/upload-sweeper.service.d.ts.map +1 -1
- package/dist/nest/upload-sweeper.service.js +7 -0
- package/dist/nest/upload-sweeper.service.js.map +1 -1
- package/dist/nest/upload.controller.d.ts.map +1 -1
- package/dist/nest/upload.controller.js +82 -15
- package/dist/nest/upload.controller.js.map +1 -1
- package/dist/nest/upload.module.d.ts +5 -0
- package/dist/nest/upload.module.d.ts.map +1 -1
- package/dist/nest/upload.module.js +5 -0
- package/dist/nest/upload.module.js.map +1 -1
- package/dist/nest/upload.tokens.d.ts +23 -0
- package/dist/nest/upload.tokens.d.ts.map +1 -1
- package/dist/path-safety.d.ts +45 -0
- package/dist/path-safety.d.ts.map +1 -0
- package/dist/path-safety.js +73 -0
- package/dist/path-safety.js.map +1 -0
- package/dist/pending-registry.d.ts +19 -4
- package/dist/pending-registry.d.ts.map +1 -1
- package/dist/pending-registry.js +19 -4
- package/dist/pending-registry.js.map +1 -1
- package/dist/providers/local.d.ts.map +1 -1
- package/dist/providers/local.js +16 -3
- package/dist/providers/local.js.map +1 -1
- package/dist/providers/s3.d.ts.map +1 -1
- package/dist/providers/s3.js +14 -0
- package/dist/providers/s3.js.map +1 -1
- package/dist/registry.d.ts +8 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/types.d.ts +10 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/upload-feature.d.ts.map +1 -1
- package/dist/upload-feature.js +30 -31
- package/dist/upload-feature.js.map +1 -1
- package/package.json +8 -7
- package/src/graphql.ts +22 -5
- package/src/index.ts +5 -0
- package/src/mime.ts +42 -0
- package/src/nest/upload-sweeper.service.ts +9 -0
- package/src/nest/upload.controller.ts +95 -14
- package/src/nest/upload.module.ts +5 -0
- package/src/nest/upload.tokens.ts +23 -0
- package/src/path-safety.ts +73 -0
- package/src/pending-registry.ts +19 -4
- package/src/providers/local.ts +15 -3
- package/src/providers/s3.ts +17 -0
- package/src/registry.ts +8 -0
- package/src/types.ts +10 -2
- 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
|
-
/**
|
|
53
|
-
|
|
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({
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/pending-registry.ts
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
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'
|
package/src/providers/local.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { mkdir, writeFile, unlink } from 'node:fs/promises'
|
|
13
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/providers/s3.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
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).
|
package/src/upload-feature.ts
CHANGED
|
@@ -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
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
182
|
+
after: appendAfterHook(existingNew, newAfterHook),
|
|
188
183
|
},
|
|
189
184
|
edit: {
|
|
190
185
|
...existingEdit,
|
|
191
|
-
after:
|
|
186
|
+
after: appendAfterHook(existingEdit, editAfterHook),
|
|
192
187
|
},
|
|
193
188
|
delete: {
|
|
194
189
|
...existingDelete,
|
|
195
|
-
after:
|
|
190
|
+
after: appendAfterHook(existingDelete, deleteAfterHook),
|
|
196
191
|
},
|
|
197
192
|
} as ResourceOptions['actions']
|
|
198
193
|
|