Skip to content

Commit fca6e88

Browse files
authored
fix: enforce S3 limits and reject negative/fractionals (#1237)
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 5515031 commit fca6e88

9 files changed

Lines changed: 242 additions & 13 deletions

File tree

src/http/routes/s3/commands/complete-multipart-upload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const CompletedMultipartUpload = {
4040
items: {
4141
type: 'object',
4242
properties: {
43-
PartNumber: { type: 'integer' },
43+
PartNumber: { type: 'integer', minimum: 1, maximum: 10000 },
4444
ETag: { type: 'string' },
4545
},
4646
required: ['PartNumber', 'ETag'],

src/http/routes/s3/commands/list-multipart-uploads.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { S3ProtocolHandler } from '@storage/protocols/s3/s3-handler'
22
import { ROUTE_OPERATIONS } from '../../operations'
33
import { S3Router } from '../router'
44

5-
const ListObjectsInput = {
6-
summary: 'List Objects',
5+
const ListMultipartUploadsInput = {
6+
summary: 'List Multipart Uploads',
77
Params: {
88
type: 'object',
99
properties: {
@@ -17,7 +17,7 @@ const ListObjectsInput = {
1717
uploads: { type: 'string' },
1818
delimiter: { type: 'string' },
1919
'encoding-type': { type: 'string', enum: ['url'] },
20-
'max-uploads': { type: 'number', minimum: 1 },
20+
'max-uploads': { type: 'integer', minimum: 1, maximum: 1000 },
2121
'key-marker': { type: 'string' },
2222
'upload-id-marker': { type: 'string' },
2323
prefix: { type: 'string' },
@@ -29,7 +29,7 @@ const ListObjectsInput = {
2929
export default function ListMultipartUploads(s3Router: S3Router) {
3030
s3Router.get(
3131
'/:Bucket?uploads',
32-
{ schema: ListObjectsInput, operation: ROUTE_OPERATIONS.S3_LIST_MULTIPART },
32+
{ schema: ListMultipartUploadsInput, operation: ROUTE_OPERATIONS.S3_LIST_MULTIPART },
3333
async (req, ctx) => {
3434
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
3535

src/http/routes/s3/commands/list-objects.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const ListObjectsV2Input = {
1717
'list-type': { type: 'string', enum: ['2'] },
1818
delimiter: { type: 'string' },
1919
'encoding-type': { type: 'string', enum: ['url'] },
20-
'max-keys': { type: 'number' },
20+
'max-keys': { type: 'integer', minimum: 0 },
2121
prefix: { type: 'string' },
2222
'continuation-token': { type: 'string' },
2323
'start-after': { type: 'string' },
@@ -40,7 +40,7 @@ const ListObjectsInput = {
4040
properties: {
4141
delimiter: { type: 'string' },
4242
'encoding-type': { type: 'string', enum: ['url'] },
43-
'max-keys': { type: 'number' },
43+
'max-keys': { type: 'integer', minimum: 0 },
4444
prefix: { type: 'string' },
4545
marker: { type: 'string' },
4646
},

src/http/routes/s3/commands/list-parts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const ListPartsInput = {
1818
type: 'object',
1919
properties: {
2020
uploadId: { type: 'string' },
21-
'max-parts': { type: 'number', minimum: 1, maximum: 1000 },
21+
'max-parts': { type: 'integer', minimum: 1, maximum: 1000 },
2222
'part-number-marker': { type: 'string' },
2323
},
2424
required: ['uploadId'],

src/http/routes/s3/commands/upload-part-copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const UploadPartCopyInput = {
1616
type: 'object',
1717
properties: {
1818
uploadId: { type: 'string' },
19-
partNumber: { type: 'number', minimum: 1, maximum: 1000 },
19+
partNumber: { type: 'integer', minimum: 1, maximum: 10000 },
2020
},
2121
required: ['uploadId', 'partNumber'],
2222
},

src/http/routes/s3/commands/upload-part.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const UploadPartInput = {
1919
type: 'object',
2020
properties: {
2121
uploadId: { type: 'string' },
22-
partNumber: { type: 'number', minimum: 1, maximum: 10000 },
22+
partNumber: { type: 'integer', minimum: 1, maximum: 10000 },
2323
},
2424
required: ['uploadId', 'partNumber'],
2525
},

src/http/routes/s3/router.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { vi } from 'vitest'
44
import { S3ProtocolHandler } from '../../../storage/protocols/s3/s3-handler'
55
import { Uploader } from '../../../storage/uploader'
66
import CompleteMultipartUpload from './commands/complete-multipart-upload'
7+
import ListMultipartUploads from './commands/list-multipart-uploads'
8+
import ListObjects from './commands/list-objects'
9+
import ListParts from './commands/list-parts'
10+
import UploadPart from './commands/upload-part'
11+
import UploadPartCopy from './commands/upload-part-copy'
712
import { getRouter, type RouteQuery, Router, type S3Router } from './router'
813

914
afterEach(() => {
@@ -665,6 +670,36 @@ describe('S3ProtocolHandler.parseMetadataHeaders', () => {
665670
})
666671

667672
describe('CompleteMultipartUpload route mapping', () => {
673+
it.each([
674+
['', false],
675+
[0, false],
676+
[1, true],
677+
[10_000, true],
678+
[10_001, false],
679+
])('validates body PartNumber %s against the S3 range', (partNumber, expected) => {
680+
const router = new Router()
681+
CompleteMultipartUpload(router as unknown as S3Router)
682+
683+
const route = router
684+
.routes()
685+
.get('/:Bucket/*')
686+
?.find((candidate) => candidate.method === 'post' && candidate.type === undefined)
687+
688+
expect(route).toBeDefined()
689+
expect(
690+
route!.validate({
691+
Params: { Bucket: 'bucket', '*': 'object' },
692+
Querystring: { uploadId: 'upload-id' },
693+
Headers: { authorization: 'authorization' },
694+
Body: {
695+
CompleteMultipartUpload: {
696+
Part: [{ PartNumber: partNumber, ETag: 'etag' }],
697+
},
698+
},
699+
})
700+
).toBe(expected)
701+
})
702+
668703
it('maps ChecksumCRC32C from the backend response on iceberg routes', async () => {
669704
const router = new Router()
670705
const completeMultipartUpload = vi.fn().mockResolvedValue({
@@ -732,6 +767,148 @@ describe('CompleteMultipartUpload route mapping', () => {
732767
})
733768
})
734769

770+
describe('UploadPart route validation', () => {
771+
it.each([
772+
[0, false],
773+
[1, true],
774+
['1', true],
775+
[1.5, false],
776+
['1.5', false],
777+
[10_000, true],
778+
[10_001, false],
779+
])('validates query partNumber %s as an integer within the S3 range', (partNumber, expected) => {
780+
const router = new Router()
781+
UploadPart(router as unknown as S3Router)
782+
783+
const route = router
784+
.routes()
785+
.get('/:Bucket/*')
786+
?.find((candidate) => candidate.method === 'put' && candidate.type === undefined)
787+
788+
expect(route).toBeDefined()
789+
expect(
790+
route!.validate({
791+
Params: { Bucket: 'bucket', '*': 'object' },
792+
Querystring: { uploadId: 'upload-id', partNumber },
793+
})
794+
).toBe(expected)
795+
})
796+
})
797+
798+
describe('UploadPartCopy route validation', () => {
799+
it.each([
800+
[0, false],
801+
[1, true],
802+
['1', true],
803+
[1.5, false],
804+
['1.5', false],
805+
[10_000, true],
806+
[10_001, false],
807+
])('validates query partNumber %s as an integer within the S3 range', (partNumber, expected) => {
808+
const router = new Router()
809+
UploadPartCopy(router as unknown as S3Router)
810+
811+
const route = router.routes().get('/:Bucket/*')?.[0]
812+
813+
expect(route).toBeDefined()
814+
expect(
815+
route!.validate({
816+
Params: { Bucket: 'bucket', '*': 'object' },
817+
Querystring: { uploadId: 'upload-id', partNumber },
818+
Headers: { 'x-amz-copy-source': '/source-bucket/source-key' },
819+
})
820+
).toBe(expected)
821+
})
822+
})
823+
824+
describe('ListParts route validation', () => {
825+
it.each([
826+
[0, false],
827+
[1, true],
828+
['1', true],
829+
[1.5, false],
830+
['1.5', false],
831+
[1_000, true],
832+
[1_001, false],
833+
])('validates max-parts %s as an integer within the S3 range', (maxParts, expected) => {
834+
const router = new Router()
835+
ListParts(router as unknown as S3Router)
836+
837+
const route = router
838+
.routes()
839+
.get('/:Bucket/*')
840+
?.find((candidate) => candidate.method === 'get' && candidate.type === undefined)
841+
842+
expect(route).toBeDefined()
843+
expect(
844+
route!.validate({
845+
Params: { Bucket: 'bucket', '*': 'object' },
846+
Querystring: { uploadId: 'upload-id', 'max-parts': maxParts },
847+
})
848+
).toBe(expected)
849+
})
850+
})
851+
852+
describe('ListMultipartUploads route validation', () => {
853+
it.each([
854+
[0, false],
855+
[1, true],
856+
[1.5, false],
857+
[1_000, true],
858+
[1_001, false],
859+
])('validates max-uploads %s against the S3 range', (maxUploads, expected) => {
860+
const router = new Router()
861+
ListMultipartUploads(router as unknown as S3Router)
862+
863+
const route = router.routes().get('/:Bucket')?.[0]
864+
865+
expect(route).toBeDefined()
866+
expect(
867+
route!.validate({
868+
Params: { Bucket: 'bucket' },
869+
Querystring: { uploads: '', 'max-uploads': maxUploads },
870+
})
871+
).toBe(expected)
872+
})
873+
})
874+
875+
describe.each([
876+
['V1', false],
877+
['V2', true],
878+
])('ListObjects %s route validation', (_version, isV2) => {
879+
it.each([
880+
[-1, false],
881+
[0, true],
882+
[1, true],
883+
['1', true],
884+
[1.5, false],
885+
['1.5', false],
886+
])('validates max-keys %s as a non-negative integer', (maxKeys, expected) => {
887+
const router = new Router()
888+
ListObjects(router as unknown as S3Router)
889+
890+
const route = router
891+
.routes()
892+
.get('/:Bucket')
893+
?.find((candidate) =>
894+
isV2
895+
? candidate.querystringMatches.some((match) => match.key === 'list-type')
896+
: candidate.querystringMatches.some((match) => match.key === '*')
897+
)
898+
899+
expect(route).toBeDefined()
900+
expect(
901+
route!.validate({
902+
Params: { Bucket: 'bucket' },
903+
Querystring: {
904+
...(isV2 ? { 'list-type': '2' } : {}),
905+
'max-keys': maxKeys,
906+
},
907+
})
908+
).toBe(expected)
909+
})
910+
})
911+
735912
describe('DeleteObject route mapping', () => {
736913
it('accepts DeleteObjects payloads at the object request cap in router validation', async () => {
737914
const { default: DeleteObject } = await import('./commands/delete-object')

src/storage/protocols/s3/s3-handler.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,56 @@ describe('S3ProtocolHandler.getObject', () => {
157157
})
158158
})
159159

160+
describe('S3ProtocolHandler.listMultipartUploads', () => {
161+
it('defaults MaxUploads to the S3 limit of 1000', async () => {
162+
const findBucket = vi.fn().mockResolvedValue({ id: 'bucket' })
163+
const listMultipartUploads = vi.fn().mockResolvedValue([])
164+
const storage = {
165+
asSuperUser: vi.fn(() => ({ findBucket })),
166+
db: { listMultipartUploads },
167+
}
168+
const handler = new S3ProtocolHandler(storage as never, 'tenant-id')
169+
170+
const response = await handler.listMultipartUploads({ Bucket: 'bucket' })
171+
172+
expect(listMultipartUploads).toHaveBeenCalledWith('bucket', {
173+
prefix: '',
174+
deltimeter: undefined,
175+
maxKeys: 1001,
176+
nextUploadKeyToken: undefined,
177+
nextUploadToken: undefined,
178+
})
179+
expect(response.responseBody.ListMultipartUploadsResult.MaxUploads).toBe(1000)
180+
})
181+
182+
it.each([
183+
0,
184+
-1,
185+
1.5,
186+
1001,
187+
Number.NaN,
188+
Number.POSITIVE_INFINITY,
189+
Number.NEGATIVE_INFINITY,
190+
])('rejects invalid MaxUploads %s before querying storage', async (maxUploads) => {
191+
const findBucket = vi.fn().mockResolvedValue({ id: 'bucket' })
192+
const listMultipartUploads = vi.fn().mockResolvedValue([])
193+
const storage = {
194+
asSuperUser: vi.fn(() => ({ findBucket })),
195+
db: { listMultipartUploads },
196+
}
197+
const handler = new S3ProtocolHandler(storage as never, 'tenant-id')
198+
199+
await expect(
200+
handler.listMultipartUploads({ Bucket: 'bucket', MaxUploads: maxUploads })
201+
).rejects.toMatchObject({
202+
code: 'InvalidParameter',
203+
message: 'Invalid Parameter MaxUploads',
204+
})
205+
expect(findBucket).not.toHaveBeenCalled()
206+
expect(listMultipartUploads).not.toHaveBeenCalled()
207+
})
208+
})
209+
160210
describe('S3ProtocolHandler.abortMultipartUpload', () => {
161211
it('aborts multipart upload and deletes from database when backend succeeds', async () => {
162212
const uploadId = 'test-upload-id'

src/storage/protocols/s3/s3-handler.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ export class S3ProtocolHandler {
289289
throw ERRORS.MissingParameter('Bucket')
290290
}
291291

292+
const limit = command.MaxUploads ?? 1000
293+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
294+
throw ERRORS.InvalidParameter('MaxUploads')
295+
}
296+
292297
await this.storage.asSuperUser().findBucket(command.Bucket)
293298

294299
const keyContinuationToken = command.KeyMarker
@@ -297,11 +302,8 @@ export class S3ProtocolHandler {
297302
const encodingType = command.EncodingType
298303
const delimiter = command.Delimiter
299304
const prefix = command.Prefix || ''
300-
const maxKeys = command.MaxUploads
301305
const bucket = command.Bucket
302306

303-
const limit = maxKeys || 200
304-
305307
const multipartUploads = await this.storage.db.listMultipartUploads(bucket, {
306308
prefix,
307309
deltimeter: delimiter,

0 commit comments

Comments
 (0)