@gentleduck/upload 0.2.5 → 0.3.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/README.md +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { __resetPostWarningsForTests, type PostIntent, PostStrategy } from '../index'
|
|
3
|
+
|
|
4
|
+
function makeCtx(url: string) {
|
|
5
|
+
const file = new File(['hello'], 'a.txt', { type: 'text/plain' })
|
|
6
|
+
return {
|
|
7
|
+
intent: { strategy: 'post', fileId: 'f', url, fields: {} } satisfies PostIntent,
|
|
8
|
+
file,
|
|
9
|
+
signal: new AbortController().signal,
|
|
10
|
+
reportProgress: vi.fn(),
|
|
11
|
+
transport: {
|
|
12
|
+
postForm: vi.fn(async () => ({ headers: {} })),
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('PostStrategy SSRF guard', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__resetPostWarningsForTests()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('rejects file://', async () => {
|
|
23
|
+
const s = PostStrategy()
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
25
|
+
await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/forbidden protocol/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('rejects javascript:', async () => {
|
|
29
|
+
const s = PostStrategy()
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
31
|
+
await expect(s.start(makeCtx('javascript:alert(1)') as any)).rejects.toThrow(/forbidden protocol/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects loopback', async () => {
|
|
35
|
+
const s = PostStrategy()
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
37
|
+
await expect(s.start(makeCtx('https://127.0.0.1/up') as any)).rejects.toThrow(/private/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('rejects AWS metadata IPv4', async () => {
|
|
41
|
+
const s = PostStrategy()
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
43
|
+
await expect(s.start(makeCtx('https://169.254.169.254/up') as any)).rejects.toThrow(/private/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('rejects host outside allowedHosts', async () => {
|
|
47
|
+
const s = PostStrategy({ allowedHosts: ['up.example.com'] })
|
|
48
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
49
|
+
await expect(s.start(makeCtx('https://evil.example.com/up') as any)).rejects.toThrow(/allow-list/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('accepts allowlisted host', async () => {
|
|
53
|
+
const s = PostStrategy({ allowedHosts: ['up.example.com'] })
|
|
54
|
+
const ctx = makeCtx('https://up.example.com/up')
|
|
55
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
56
|
+
await expect(s.start(ctx as any)).resolves.toBeUndefined()
|
|
57
|
+
expect(ctx.transport.postForm).toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('allowPrivateHosts lets loopback through (opt-in)', async () => {
|
|
61
|
+
const s = PostStrategy({ allowPrivateHosts: true })
|
|
62
|
+
const ctx = makeCtx('https://127.0.0.1/up')
|
|
63
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
64
|
+
await expect(s.start(ctx as any)).resolves.toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('warns once when allowedHosts not set', async () => {
|
|
68
|
+
const s = PostStrategy()
|
|
69
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
70
|
+
try {
|
|
71
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
72
|
+
await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
74
|
+
await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
|
|
75
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
76
|
+
} finally {
|
|
77
|
+
warn.mockRestore()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('caller label is "post.intent"', async () => {
|
|
82
|
+
const s = PostStrategy()
|
|
83
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
84
|
+
await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/post\.intent/)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('PostStrategy retry', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
__resetPostWarningsForTests()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('retries a transient failure then succeeds', async () => {
|
|
94
|
+
const s = PostStrategy({ allowedHosts: ['up.example.com'], maxRetries: 2 })
|
|
95
|
+
const ctx = makeCtx('https://up.example.com/up')
|
|
96
|
+
let calls = 0
|
|
97
|
+
ctx.transport.postForm = vi.fn(async () => {
|
|
98
|
+
calls++
|
|
99
|
+
if (calls < 2) throw new Error('network error')
|
|
100
|
+
return { headers: {} }
|
|
101
|
+
})
|
|
102
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
103
|
+
await expect(s.start(ctx as any)).resolves.toBeUndefined()
|
|
104
|
+
expect(calls).toBe(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('does not retry a non-transient failure', async () => {
|
|
108
|
+
const s = PostStrategy({ allowedHosts: ['up.example.com'] })
|
|
109
|
+
const ctx = makeCtx('https://up.example.com/up')
|
|
110
|
+
let calls = 0
|
|
111
|
+
ctx.transport.postForm = vi.fn(async () => {
|
|
112
|
+
calls++
|
|
113
|
+
throw new Error('Upload failed with status 403')
|
|
114
|
+
})
|
|
115
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
116
|
+
await expect(s.start(ctx as any)).rejects.toThrow(/403/)
|
|
117
|
+
expect(calls).toBe(1)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -1,93 +1,135 @@
|
|
|
1
|
+
import type { Contracts } from '../../core'
|
|
2
|
+
import { UploadEngineError } from '../../core'
|
|
3
|
+
import { validateUploadUrl } from '../../core/utils/url-safety'
|
|
4
|
+
import { withRetry } from '../_shared/retry'
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Implements the presigned POST upload strategy, suitable for:
|
|
5
|
-
* - Small to medium files (< 100MB typically)
|
|
6
|
-
* - Simple uploads without resume capability
|
|
7
|
-
* - Direct form-based uploads to S3 or similar storage
|
|
8
|
-
*
|
|
9
|
-
* The strategy uses multipart/form-data POST requests with presigned
|
|
10
|
-
* form fields provided by the backend. This is the simplest upload
|
|
11
|
-
* method and doesn't support resumable uploads.
|
|
12
|
-
*
|
|
13
|
-
* @module upload-strategies/post
|
|
7
|
+
* Types for the presigned HTTP POST upload strategy.
|
|
8
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
14
9
|
*/
|
|
10
|
+
export namespace PostStrategy {
|
|
11
|
+
/**
|
|
12
|
+
* Presigned HTTP POST strategy configuration options.
|
|
13
|
+
*/
|
|
14
|
+
export type Config = {
|
|
15
|
+
/**
|
|
16
|
+
* Case-insensitive allow-list of host names (with optional port). When
|
|
17
|
+
* set, the presigned POST URL must match a listed host or it is rejected.
|
|
18
|
+
*/
|
|
19
|
+
allowedHosts?: string[]
|
|
20
|
+
/**
|
|
21
|
+
* When `true`, allow private-network IP literals in the POST URL.
|
|
22
|
+
* Defaults to `false`.
|
|
23
|
+
*/
|
|
24
|
+
allowPrivateHosts?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* Retries after the first attempt for transient network failures.
|
|
27
|
+
* Defaults to the shared retry default (3).
|
|
28
|
+
*/
|
|
29
|
+
maxRetries?: number
|
|
30
|
+
}
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Presigned HTTP POST intent payload mapping parameters returned by backend endpoints.
|
|
34
|
+
*/
|
|
35
|
+
export type Intent = {
|
|
36
|
+
/** Discriminant identifier matching strategy configuration registry keys. */
|
|
37
|
+
strategy: 'post'
|
|
38
|
+
/** Unique database identifier for the file resource. */
|
|
39
|
+
fileId: string
|
|
40
|
+
/** Presigned POST action destination URL (e.g. presigned S3/GCS bucket location). */
|
|
41
|
+
url: string
|
|
42
|
+
/** Key/value pair list representing form properties (e.g. AWS credential fields, policies). */
|
|
43
|
+
fields: Record<string, string>
|
|
44
|
+
/** Optional epoch string indicating when the pre-signed credentials lapse. */
|
|
45
|
+
expiresAt?: string
|
|
46
|
+
}
|
|
21
47
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
fileId: string
|
|
27
|
-
/** Presigned POST URL (form action) */
|
|
28
|
-
url: string
|
|
29
|
-
/** Form fields to include before the file */
|
|
30
|
-
fields: Record<string, string>
|
|
31
|
-
/** Optional expiration timestamp for the presigned URL */
|
|
32
|
-
expiresAt?: string
|
|
48
|
+
/**
|
|
49
|
+
* Direct strategy cursor state representing that no resumable bytes exist.
|
|
50
|
+
*/
|
|
51
|
+
export type Cursor = Record<string, never>
|
|
33
52
|
}
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// ============================================================================
|
|
54
|
+
/** Convenience alias for {@link PostStrategy.Intent}. */
|
|
55
|
+
export type PostIntent = PostStrategy.Intent
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
/** Bytes uploaded (for progress display only) */
|
|
41
|
-
uploadedBytes: number
|
|
42
|
-
}
|
|
57
|
+
let warnedMissingAllowedHosts = false
|
|
43
58
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Reset the warn-once latch. Test-only.
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
export function __resetPostWarningsForTests(): void {
|
|
64
|
+
warnedMissingAllowedHosts = false
|
|
65
|
+
}
|
|
47
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Presigned HTTP POST form strategy.
|
|
69
|
+
*
|
|
70
|
+
* Sends the entire file along with backend policy fields as a single
|
|
71
|
+
* `multipart/form-data` request body. Does not support resumability.
|
|
72
|
+
*
|
|
73
|
+
* Security: the presigned URL is backend-supplied and flows straight to the
|
|
74
|
+
* transport, so a compromised backend or MITM could return a `file:`,
|
|
75
|
+
* `javascript:`, or private-network URL. Every URL is checked with
|
|
76
|
+
* {@link validateUploadUrl}; set {@link PostStrategy.Config.allowedHosts} to
|
|
77
|
+
* lock it to known hosts, or leave `allowPrivateHosts` at its default `false`
|
|
78
|
+
* to block loopback/private addresses.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const strategies = createStrategyRegistry([
|
|
83
|
+
* PostStrategy({ allowedHosts: ['uploads.example.com'] }),
|
|
84
|
+
* MultipartStrategy()
|
|
85
|
+
* ]);
|
|
86
|
+
* ```
|
|
87
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
88
|
+
*/
|
|
48
89
|
export function PostStrategy<
|
|
49
|
-
M extends
|
|
50
|
-
C extends
|
|
51
|
-
P extends string
|
|
52
|
-
R extends
|
|
53
|
-
>():
|
|
90
|
+
M extends Contracts.Intent.Map,
|
|
91
|
+
C extends Contracts.Cursor.Map<M>,
|
|
92
|
+
P extends string,
|
|
93
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
94
|
+
>(opts: PostStrategy.Config = {}): Contracts.Strategy.Me<M, C, P, R, 'post'> {
|
|
95
|
+
const allowedHosts = opts.allowedHosts ?? []
|
|
96
|
+
const allowPrivateHosts = opts.allowPrivateHosts === true
|
|
97
|
+
|
|
54
98
|
return {
|
|
55
|
-
/** Strategy identifier */
|
|
56
99
|
id: 'post',
|
|
57
|
-
|
|
58
|
-
/** POST uploads are not resumable */
|
|
59
100
|
resumable: false,
|
|
60
101
|
|
|
61
|
-
/**
|
|
62
|
-
* Execute the POST upload.
|
|
63
|
-
*
|
|
64
|
-
* @param ctx - Strategy context
|
|
65
|
-
* @throws Error if intent is missing URL
|
|
66
|
-
*/
|
|
67
102
|
async start(ctx) {
|
|
68
|
-
const intent = ctx.intent
|
|
103
|
+
const intent = ctx.intent as unknown as PostStrategy.Intent
|
|
69
104
|
|
|
70
|
-
// Validate intent has required URL
|
|
71
105
|
if (!intent.url) {
|
|
72
|
-
throw new
|
|
106
|
+
throw new UploadEngineError('validation_failed', { message: 'post.start: intent missing url' })
|
|
73
107
|
}
|
|
74
108
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
if (allowedHosts.length === 0 && !warnedMissingAllowedHosts) {
|
|
110
|
+
warnedMissingAllowedHosts = true
|
|
111
|
+
console.warn(
|
|
112
|
+
'[duck-upload] PostStrategy: no `allowedHosts` configured. Presigned POST URLs will be host-unrestricted. ' +
|
|
113
|
+
'Set PostStrategy.Config.allowedHosts to lock the upload host.',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
validateUploadUrl(intent.url, 'post.intent', { allowedHosts, allowPrivateHosts })
|
|
118
|
+
|
|
119
|
+
await withRetry(
|
|
120
|
+
() =>
|
|
121
|
+
ctx.transport.postForm({
|
|
122
|
+
url: intent.url,
|
|
123
|
+
file: ctx.file,
|
|
124
|
+
fields: intent.fields,
|
|
125
|
+
filename: ctx.file.name,
|
|
126
|
+
signal: ctx.signal,
|
|
127
|
+
onProgress(uploadedBytes, totalBytes) {
|
|
128
|
+
ctx.reportProgress({ uploadedBytes, totalBytes })
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
{ signal: ctx.signal, ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}) },
|
|
132
|
+
)
|
|
91
133
|
},
|
|
92
134
|
}
|
|
93
135
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { __resetPutWarningsForTests, type PutIntent, PutStrategy } from '../index'
|
|
3
|
+
|
|
4
|
+
function makeCtx(url: string, overrides: Partial<PutIntent> = {}) {
|
|
5
|
+
const file = new File(['hello world'], 'a.txt', { type: 'text/plain' })
|
|
6
|
+
return {
|
|
7
|
+
intent: { strategy: 'put', fileId: 'f', url, ...overrides } satisfies PutIntent,
|
|
8
|
+
file,
|
|
9
|
+
signal: new AbortController().signal,
|
|
10
|
+
reportProgress: vi.fn(),
|
|
11
|
+
transport: {
|
|
12
|
+
put: vi.fn(async () => ({ etag: 'abc', headers: {} })),
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('PutStrategy SSRF guard', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__resetPutWarningsForTests()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('rejects file://', async () => {
|
|
23
|
+
const s = PutStrategy()
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
25
|
+
await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/forbidden protocol/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('rejects javascript:', async () => {
|
|
29
|
+
const s = PutStrategy()
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
31
|
+
await expect(s.start(makeCtx('javascript:alert(1)') as any)).rejects.toThrow(/forbidden protocol/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects loopback', async () => {
|
|
35
|
+
const s = PutStrategy()
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
37
|
+
await expect(s.start(makeCtx('https://127.0.0.1/up') as any)).rejects.toThrow(/private/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('rejects AWS metadata IPv4', async () => {
|
|
41
|
+
const s = PutStrategy()
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
43
|
+
await expect(s.start(makeCtx('https://169.254.169.254/up') as any)).rejects.toThrow(/private/)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('rejects host outside allowedHosts', async () => {
|
|
47
|
+
const s = PutStrategy({ allowedHosts: ['up.example.com'] })
|
|
48
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
49
|
+
await expect(s.start(makeCtx('https://evil.example.com/up') as any)).rejects.toThrow(/allow-list/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('accepts allowlisted host and PUTs the file body', async () => {
|
|
53
|
+
const s = PutStrategy({ allowedHosts: ['up.example.com'] })
|
|
54
|
+
const ctx = makeCtx('https://up.example.com/up', { headers: { 'Content-Type': 'text/plain' } })
|
|
55
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
56
|
+
await expect(s.start(ctx as any)).resolves.toBeUndefined()
|
|
57
|
+
expect(ctx.transport.put).toHaveBeenCalledTimes(1)
|
|
58
|
+
const arg = (ctx.transport.put.mock.calls[0] as unknown as [{ url: string; body: unknown; headers: unknown }])[0]
|
|
59
|
+
expect(arg.url).toBe('https://up.example.com/up')
|
|
60
|
+
expect(arg.body).toBe(ctx.file)
|
|
61
|
+
expect(arg.headers).toEqual({ 'Content-Type': 'text/plain' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('allowPrivateHosts lets loopback through (opt-in)', async () => {
|
|
65
|
+
const s = PutStrategy({ allowPrivateHosts: true })
|
|
66
|
+
const ctx = makeCtx('https://127.0.0.1/up')
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
68
|
+
await expect(s.start(ctx as any)).resolves.toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('warns once when allowedHosts not set', async () => {
|
|
72
|
+
const s = PutStrategy()
|
|
73
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
74
|
+
try {
|
|
75
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
76
|
+
await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
|
|
77
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
78
|
+
await s.start(makeCtx('https://up.example.com/up') as any).catch(() => {})
|
|
79
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
80
|
+
} finally {
|
|
81
|
+
warn.mockRestore()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('caller label is "put.intent"', async () => {
|
|
86
|
+
const s = PutStrategy()
|
|
87
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
88
|
+
await expect(s.start(makeCtx('file:///etc/passwd') as any)).rejects.toThrow(/put\.intent/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('throws when intent has no url', async () => {
|
|
92
|
+
const s = PutStrategy()
|
|
93
|
+
const ctx = makeCtx('https://up.example.com/up')
|
|
94
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentionally clearing the url
|
|
95
|
+
;(ctx.intent as any).url = ''
|
|
96
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
97
|
+
await expect(s.start(ctx as any)).rejects.toThrow(/missing url/)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('PutStrategy retry', () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
__resetPutWarningsForTests()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('retries a transient failure then succeeds', async () => {
|
|
107
|
+
const s = PutStrategy({ allowedHosts: ['up.example.com'], maxRetries: 2 })
|
|
108
|
+
const ctx = makeCtx('https://up.example.com/up')
|
|
109
|
+
let calls = 0
|
|
110
|
+
ctx.transport.put = vi.fn(async () => {
|
|
111
|
+
calls++
|
|
112
|
+
if (calls < 2) throw new Error('network error')
|
|
113
|
+
return { etag: 'ok', headers: {} }
|
|
114
|
+
})
|
|
115
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
116
|
+
await expect(s.start(ctx as any)).resolves.toBeUndefined()
|
|
117
|
+
expect(calls).toBe(2)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('does not retry a non-transient failure', async () => {
|
|
121
|
+
const s = PutStrategy({ allowedHosts: ['up.example.com'] })
|
|
122
|
+
const ctx = makeCtx('https://up.example.com/up')
|
|
123
|
+
let calls = 0
|
|
124
|
+
ctx.transport.put = vi.fn(async () => {
|
|
125
|
+
calls++
|
|
126
|
+
throw new Error('Upload failed with status 403')
|
|
127
|
+
})
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: test ctx shape is a subset of the full strategy ctx
|
|
129
|
+
await expect(s.start(ctx as any)).rejects.toThrow(/403/)
|
|
130
|
+
expect(calls).toBe(1)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Contracts } from '../../core'
|
|
2
|
+
import { UploadEngineError } from '../../core'
|
|
3
|
+
import { validateUploadUrl } from '../../core/utils/url-safety'
|
|
4
|
+
import { withRetry } from '../_shared/retry'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Types for the single-request presigned HTTP PUT upload strategy.
|
|
8
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
9
|
+
*/
|
|
10
|
+
export namespace PutStrategy {
|
|
11
|
+
/**
|
|
12
|
+
* Presigned HTTP PUT strategy configuration options.
|
|
13
|
+
*/
|
|
14
|
+
export type Config = {
|
|
15
|
+
/**
|
|
16
|
+
* Case-insensitive allow-list of host names (with optional port). When
|
|
17
|
+
* set, the presigned PUT URL must match a listed host or it is rejected.
|
|
18
|
+
*/
|
|
19
|
+
allowedHosts?: string[]
|
|
20
|
+
/**
|
|
21
|
+
* When `true`, allow private-network IP literals in the PUT URL.
|
|
22
|
+
* Defaults to `false`.
|
|
23
|
+
*/
|
|
24
|
+
allowPrivateHosts?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* Retries after the first attempt for transient network failures.
|
|
27
|
+
* Defaults to the shared retry default (3).
|
|
28
|
+
*/
|
|
29
|
+
maxRetries?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Presigned HTTP PUT intent payload. The whole file body is sent in one
|
|
34
|
+
* request to a backend-signed URL (e.g. S3 `putObject`, GCS signed PUT).
|
|
35
|
+
*/
|
|
36
|
+
export type Intent = {
|
|
37
|
+
/** Discriminant identifier matching strategy configuration registry keys. */
|
|
38
|
+
strategy: 'put'
|
|
39
|
+
/** Unique database identifier for the file resource. */
|
|
40
|
+
fileId: string
|
|
41
|
+
/** Presigned PUT destination URL. */
|
|
42
|
+
url: string
|
|
43
|
+
/** Optional request headers required by the signature (e.g. `Content-Type`). */
|
|
44
|
+
headers?: Record<string, string>
|
|
45
|
+
/** Optional epoch string indicating when the pre-signed credentials lapse. */
|
|
46
|
+
expiresAt?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Direct strategy cursor state representing that no resumable bytes exist.
|
|
51
|
+
*/
|
|
52
|
+
export type Cursor = Record<string, never>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Convenience alias for {@link PutStrategy.Intent}. */
|
|
56
|
+
export type PutIntent = PutStrategy.Intent
|
|
57
|
+
|
|
58
|
+
let warnedMissingAllowedHosts = false
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reset the warn-once latch. Test-only.
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
export function __resetPutWarningsForTests(): void {
|
|
65
|
+
warnedMissingAllowedHosts = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Presigned single-request HTTP PUT strategy.
|
|
70
|
+
*
|
|
71
|
+
* Sends the entire file as the request body to a backend-signed URL in one PUT.
|
|
72
|
+
* The most common direct-to-storage pattern (S3 `putObject`, GCS/Azure signed
|
|
73
|
+
* PUT). Not resumable — use {@link multipartStrategy} or the tus strategy for
|
|
74
|
+
* resumable transfers.
|
|
75
|
+
*
|
|
76
|
+
* Security: the presigned URL is backend-supplied and flows straight to the
|
|
77
|
+
* transport, so a compromised backend or MITM could return a `file:`,
|
|
78
|
+
* `javascript:`, or private-network URL. Every URL is checked with
|
|
79
|
+
* {@link validateUploadUrl}; set {@link PutStrategy.Config.allowedHosts} to
|
|
80
|
+
* lock it to known hosts, or leave `allowPrivateHosts` at its default `false`
|
|
81
|
+
* to block loopback/private addresses.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const strategies = createStrategyRegistry([
|
|
86
|
+
* PutStrategy({ allowedHosts: ['uploads.example.com'] }),
|
|
87
|
+
* multipartStrategy(),
|
|
88
|
+
* ]);
|
|
89
|
+
* ```
|
|
90
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
91
|
+
*/
|
|
92
|
+
export function PutStrategy<
|
|
93
|
+
M extends Contracts.Intent.Map,
|
|
94
|
+
C extends Contracts.Cursor.Map<M>,
|
|
95
|
+
P extends string,
|
|
96
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
97
|
+
>(opts: PutStrategy.Config = {}): Contracts.Strategy.Me<M, C, P, R, 'put'> {
|
|
98
|
+
const allowedHosts = opts.allowedHosts ?? []
|
|
99
|
+
const allowPrivateHosts = opts.allowPrivateHosts === true
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
id: 'put',
|
|
103
|
+
resumable: false,
|
|
104
|
+
|
|
105
|
+
async start(ctx) {
|
|
106
|
+
const intent = ctx.intent as unknown as PutStrategy.Intent
|
|
107
|
+
|
|
108
|
+
if (!intent.url) {
|
|
109
|
+
throw new UploadEngineError('validation_failed', { message: 'put.start: intent missing url' })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (allowedHosts.length === 0 && !warnedMissingAllowedHosts) {
|
|
113
|
+
warnedMissingAllowedHosts = true
|
|
114
|
+
console.warn(
|
|
115
|
+
'[duck-upload] PutStrategy: no `allowedHosts` configured. Presigned PUT URLs will be host-unrestricted. ' +
|
|
116
|
+
'Set PutStrategy.Config.allowedHosts to lock the upload host.',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
validateUploadUrl(intent.url, 'put.intent', { allowedHosts, allowPrivateHosts })
|
|
121
|
+
|
|
122
|
+
const totalBytes = ctx.file.size
|
|
123
|
+
|
|
124
|
+
await withRetry(
|
|
125
|
+
() =>
|
|
126
|
+
ctx.transport.put({
|
|
127
|
+
url: intent.url,
|
|
128
|
+
body: ctx.file,
|
|
129
|
+
headers: intent.headers ?? {},
|
|
130
|
+
signal: ctx.signal,
|
|
131
|
+
onProgress(uploadedBytes, total) {
|
|
132
|
+
ctx.reportProgress({ uploadedBytes, totalBytes: total || totalBytes })
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
{ signal: ctx.signal, ...(opts.maxRetries !== undefined ? { maxRetries: opts.maxRetries } : {}) },
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
* Core strategy types live in `core/contracts/strategy.types.ts`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
|
|
9
|
-
export type { StrategyCtx, StrategyRegistry, UploadStrategy } from '../../core/contracts'
|
|
7
|
+
import type { Contracts } from '../../core/contracts'
|
|
10
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Create an empty strategy registry. Register upload strategies with `set`, then
|
|
11
|
+
* resolve them by id with `get` / `has`.
|
|
12
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
13
|
+
*/
|
|
11
14
|
export function createStrategyRegistry<
|
|
12
|
-
M extends
|
|
13
|
-
C extends
|
|
14
|
-
P extends string
|
|
15
|
-
R extends
|
|
16
|
-
>():
|
|
17
|
-
const map: Partial<{ [K in keyof M & string]:
|
|
15
|
+
M extends Contracts.Intent.Map,
|
|
16
|
+
C extends Contracts.Cursor.Map<M>,
|
|
17
|
+
P extends string,
|
|
18
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
19
|
+
>(): Contracts.Strategy.Registry<M, C, P, R> {
|
|
20
|
+
const map: Partial<{ [K in keyof M & string]: Contracts.Strategy.Me<M, C, P, R, K> }> = {}
|
|
18
21
|
|
|
19
22
|
return {
|
|
20
23
|
get(id) {
|