Skip to content

Commit f299103

Browse files
authored
PLU-550: Postman attachment size limit check (#1199)
## Address VAPT issues * Change from PUT to POST presigned URL to enforce size limit * Set `manualUpload` metadata directly from the server to prevent client-side modification ## How to test? - [ ] Upload new attachment, should be able to send via Postman successfully - [ ] Previously uploaded attachments should be sent via Postman succesfully - [ ] Attempt to override by uploading a different file to the generated presigned POST URL should fail
1 parent 9cb4d05 commit f299103

File tree

12 files changed

+289
-398
lines changed

12 files changed

+289
-398
lines changed

package-lock.json

Lines changed: 8 additions & 150 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"@as-integrations/express4": "1.1.2",
3333
"@aws-sdk/client-dynamodb": "3.460.0",
3434
"@aws-sdk/client-s3": "3.369.0",
35-
"@aws-sdk/s3-request-presigner": "3.369.0",
35+
"@aws-sdk/s3-presigned-post": "3.369.0",
3636
"@bull-board/express": "5.17.0",
3737
"@graphql-tools/schema": "10.0.25",
3838
"@graphql-tools/utils": "10.9.1",

packages/backend/src/graphql/__tests__/mutations/generate-presigned-url.itest.ts renamed to packages/backend/src/graphql/__tests__/mutations/generate-presigned-post.itest.ts

Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import { ForbiddenError } from '@/errors/graphql-errors'
44
import { generateMockContext } from '@/graphql/__tests__/mutations/tiles/table.mock'
5-
import generatePresignedUrl from '@/graphql/mutations/generate-presigned-url'
5+
import generatePresignedPost from '@/graphql/mutations/generate-presigned-post'
66
import Flow from '@/models/flow'
77
import Context from '@/types/express/context'
88

@@ -12,59 +12,76 @@ const VALID_PARAMS = {
1212
fileType: 'text/plain',
1313
size: 100,
1414
updatedAt: new Date().toISOString(),
15-
manualUpload: true,
1615
}
1716

18-
const mocks = vi.hoisted(() => ({
19-
getSignedUrl: vi.fn(),
17+
// Mock the s3 helpers module
18+
vi.mock('@/helpers/s3', () => ({
19+
getPresignedPost: vi.fn(),
20+
COMMON_S3_BUCKET: 'test-bucket',
21+
COMMON_S3_MOCK_FOLDER_PREFIX: 's3:test-bucket:mock/',
22+
parseS3Id: vi.fn(),
23+
MAX_FILE_SIZE: 1024 * 1024 * 2,
24+
ACCEPTED_FILE_TYPES: ['text/plain'],
25+
validateObjectKey: vi.fn((objectKey) => {
26+
const invalidCharacters = /[\\{}^`%~#<>|[\]]/
27+
if (invalidCharacters.test(objectKey)) {
28+
return false
29+
}
30+
31+
// validate length of object key
32+
const byteLength = Buffer.byteLength(objectKey, 'utf-8')
33+
return byteLength <= 1024
34+
}),
2035
}))
2136

22-
vi.mock('@aws-sdk/s3-request-presigner', () => ({
23-
getSignedUrl: mocks.getSignedUrl,
24-
}))
37+
import { COMMON_S3_BUCKET, getPresignedPost } from '@/helpers/s3'
2538

26-
describe('generatePresignedUrl', () => {
39+
describe('generatePresignedPost', () => {
2740
let context: Context
2841
beforeEach(async () => {
2942
context = await generateMockContext()
43+
vi.clearAllMocks()
3044
})
3145

3246
it('should generate a presigned url', async () => {
33-
const expectedUrl = 'https://presigned-url.example.com'
34-
mocks.getSignedUrl.mockResolvedValueOnce(expectedUrl)
35-
3647
await Flow.query().insert({
3748
id: VALID_PARAMS.flowId,
3849
name: 'Test Flow',
3950
userId: context.currentUser.id,
4051
})
4152

42-
const result = await generatePresignedUrl(
43-
null,
44-
{ input: VALID_PARAMS },
45-
context,
53+
await generatePresignedPost(null, { input: VALID_PARAMS }, context)
54+
expect(getPresignedPost).toHaveBeenCalledWith(
55+
COMMON_S3_BUCKET,
56+
expect.stringMatching(
57+
new RegExp(
58+
`^${VALID_PARAMS.flowId}/[a-f0-9-]+/${VALID_PARAMS.filename}$`,
59+
),
60+
),
61+
VALID_PARAMS.fileType,
62+
{
63+
flowId: VALID_PARAMS.flowId,
64+
filename: VALID_PARAMS.filename,
65+
size: VALID_PARAMS.size.toString(),
66+
updatedAt: VALID_PARAMS.updatedAt,
67+
},
4668
)
47-
const expectedKeys = ['url', 's3Id']
48-
expect(Object.keys(result)).toEqual(expectedKeys)
49-
expect(result.s3Id).toContain(VALID_PARAMS.flowId)
5069
})
5170

5271
it('should throw an error if the user does not have access to the flow', async () => {
53-
vi.fn()
54-
.mockRejectedValue(Flow.hasAccess)
55-
.mockRejectedValue(
56-
new ForbiddenError('You do not have access to this flow'),
57-
)
72+
const otherUserContext = await generateMockContext()
73+
await Flow.query()
74+
.patch({
75+
userId: otherUserContext.currentUser.id,
76+
})
77+
.where('id', VALID_PARAMS.flowId)
5878

5979
await expect(
60-
generatePresignedUrl(null, { input: VALID_PARAMS }, context),
80+
generatePresignedPost(null, { input: VALID_PARAMS }, context),
6181
).rejects.toThrow(ForbiddenError)
6282
})
6383

6484
it('should throw an error if the file size is too large', async () => {
65-
const expectedUrl = 'https://presigned-url.example.com'
66-
mocks.getSignedUrl.mockResolvedValueOnce(expectedUrl)
67-
6885
await Flow.query().insert({
6986
id: VALID_PARAMS.flowId,
7087
name: 'Test Flow',
@@ -73,7 +90,7 @@ describe('generatePresignedUrl', () => {
7390

7491
const tooLargeParams = { ...VALID_PARAMS, size: 2 * 1024 * 1024 + 1 }
7592
await expect(
76-
generatePresignedUrl(null, { input: tooLargeParams }, context),
93+
generatePresignedPost(null, { input: tooLargeParams }, context),
7794
).rejects.toThrow('Size of attachment exceeds 2MB')
7895
})
7996

@@ -85,9 +102,6 @@ describe('generatePresignedUrl', () => {
85102
])(
86103
'should throw an error if the file type is not supported: %s',
87104
async (fileType) => {
88-
const expectedUrl = 'https://presigned-url.example.com'
89-
mocks.getSignedUrl.mockResolvedValueOnce(expectedUrl)
90-
91105
await Flow.query().insert({
92106
id: VALID_PARAMS.flowId,
93107
name: 'Test Flow',
@@ -100,7 +114,7 @@ describe('generatePresignedUrl', () => {
100114
}
101115

102116
await expect(
103-
generatePresignedUrl(null, { input: unsupportedParams }, context),
117+
generatePresignedPost(null, { input: unsupportedParams }, context),
104118
).rejects.toThrow('Unsupported file type')
105119
},
106120
)

packages/backend/src/graphql/mutation-resolvers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import deleteUploadedFile from './mutations/delete-uploaded-file'
1313
import duplicateFlow from './mutations/duplicate-flow'
1414
import executeStep from './mutations/execute-step'
1515
import generateAuthUrl from './mutations/generate-auth-url'
16-
import generatePresignedUrl from './mutations/generate-presigned-url'
16+
import generatePresignedPost from './mutations/generate-presigned-post'
1717
import loginWithSelectedSgid from './mutations/login-with-selected-sgid'
1818
import loginWithSgid from './mutations/login-with-sgid'
1919
import loginWithSso from './mutations/login-with-sso'
@@ -82,7 +82,7 @@ export default {
8282
updateFlowTransferStatus,
8383
duplicateFlow,
8484
deleteUploadedFile,
85-
generatePresignedUrl,
85+
generatePresignedPost,
8686
...tilesMutationResolvers,
8787

8888
// This is a special stub that enables us to group all our admin-related
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { PresignedPost } from '@aws-sdk/s3-presigned-post'
2+
import { randomUUID } from 'crypto'
3+
4+
import {
5+
ACCEPTED_FILE_TYPES,
6+
COMMON_S3_BUCKET,
7+
getPresignedPost,
8+
MAX_FILE_SIZE,
9+
validateObjectKey,
10+
} from '@/helpers/s3'
11+
import Flow from '@/models/flow'
12+
13+
import { MutationResolvers } from '../__generated__/types.generated'
14+
15+
const generatePresignedPost: MutationResolvers['generatePresignedPost'] =
16+
async (_parent, params, context) => {
17+
const { flowId, filename, fileType, size, updatedAt } = params.input
18+
19+
if (size > MAX_FILE_SIZE) {
20+
throw new Error('Size of attachment exceeds 2MB')
21+
}
22+
if (!ACCEPTED_FILE_TYPES.includes(fileType)) {
23+
throw new Error('Unsupported file type')
24+
}
25+
26+
const uuid = randomUUID()
27+
const objectKey = `${flowId}/${uuid}/${filename}`
28+
if (!validateObjectKey(objectKey)) {
29+
throw new Error('File path cannot be longer than 1024 bytes')
30+
}
31+
32+
await Flow.hasAccess(context.currentUser.id, flowId)
33+
34+
const presignedPost = await getPresignedPost(
35+
COMMON_S3_BUCKET,
36+
objectKey,
37+
fileType,
38+
{
39+
flowId,
40+
filename,
41+
size: size.toString(),
42+
updatedAt,
43+
},
44+
)
45+
46+
return {
47+
presignedPost: presignedPost as PresignedPost,
48+
s3Id: `s3:${COMMON_S3_BUCKET}:${objectKey}`,
49+
}
50+
}
51+
52+
export default generatePresignedPost

0 commit comments

Comments
 (0)