Skip to content

Commit c715a7b

Browse files
authored
fix: s3 delete objects improve validation (#505)
1 parent 8483e05 commit c715a7b

File tree

6 files changed

+178
-27
lines changed

6 files changed

+178
-27
lines changed

src/http/plugins/xml.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ import xmlBodyParser from 'fastify-xml-body-parser'
1010

1111
export const jsonToXml = fastifyPlugin(async function (
1212
fastify: FastifyInstance,
13-
opts: { disableContentParser?: boolean }
13+
opts: { disableContentParser?: boolean; parseAsArray?: string[] }
1414
) {
1515
fastify.register(accepts)
1616

1717
if (!opts.disableContentParser) {
18-
fastify.register(xmlBodyParser)
18+
fastify.register(xmlBodyParser, {
19+
contentType: ['text/xml', 'application/xml', '*'],
20+
isArray: (_: string, jpath: string) => {
21+
return opts.parseAsArray?.includes(jpath)
22+
},
23+
})
1924
}
2025
fastify.addHook('preSerialization', async (req, res, payload) => {
2126
const accept = req.accepts()

src/http/routes/s3/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { FastifyInstance, RouteHandlerMethod } from 'fastify'
2+
import { JSONSchema } from 'json-schema-to-ts'
3+
import { trace } from '@opentelemetry/api'
24
import { db, jsonToXml, signatureV4, storage, tracingMode } from '../../plugins'
3-
import { getRouter, RequestInput } from './router'
5+
import { findArrayPathsInSchemas, getRouter, RequestInput } from './router'
46
import { s3ErrorHandler } from './error-handler'
5-
import { trace } from '@opentelemetry/api'
67

78
export default async function routes(fastify: FastifyInstance) {
89
fastify.register(async (fastify) => {
@@ -102,6 +103,9 @@ export default async function routes(fastify: FastifyInstance) {
102103

103104
fastify.register(jsonToXml, {
104105
disableContentParser,
106+
parseAsArray: findArrayPathsInSchemas(
107+
routesByMethod.filter((r) => r.schema.Body).map((r) => r.schema.Body as JSONSchema)
108+
),
105109
})
106110
fastify.register(signatureV4)
107111
fastify.register(db)

src/http/routes/s3/router.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export class Router<Context = unknown, S extends Schema = Schema> {
144144
schemaToCompile.Params = schema.Params
145145
}
146146
if (schema.Body) {
147-
schemaToCompile.Body
147+
schemaToCompile.Body = schema.Body
148148
}
149149
if (schema.Headers) {
150150
schemaToCompile.Headers = schema.Headers
@@ -154,10 +154,25 @@ export class Router<Context = unknown, S extends Schema = Schema> {
154154
schemaToCompile.Querystring = schema.Querystring
155155
}
156156

157+
// If any of the keys has a required property, then the top level object is also required
158+
const required = Object.keys(schemaToCompile).map((key) => {
159+
const k = key as keyof typeof schemaToCompile
160+
const schemaObj = schemaToCompile[k]
161+
162+
if (typeof schemaObj === 'boolean') {
163+
return
164+
}
165+
166+
if (schemaObj?.required && schemaObj.required.length > 0) {
167+
return key as string
168+
}
169+
})
170+
157171
this.ajv.addSchema(
158172
{
159173
type: 'object',
160174
properties: schemaToCompile,
175+
required: required.filter(Boolean),
161176
},
162177
method + url
163178
)
@@ -271,3 +286,32 @@ export class Router<Context = unknown, S extends Schema = Schema> {
271286
return false
272287
}
273288
}
289+
290+
/**
291+
* Given a JSONSchema Definition, it returns dotted paths of all array properties
292+
* @param schemas
293+
*/
294+
export function findArrayPathsInSchemas(schemas: JSONSchema[]): string[] {
295+
const arrayPaths: string[] = []
296+
297+
function traverse(schema: JSONSchema, currentPath = ''): void {
298+
if (typeof schema === 'boolean') {
299+
return
300+
}
301+
302+
if (schema.type === 'array') {
303+
arrayPaths.push(currentPath)
304+
}
305+
306+
if (schema.properties) {
307+
for (const key in schema.properties) {
308+
const nextSchema = schema.properties[key]
309+
traverse(nextSchema, currentPath ? `${currentPath}.${key}` : key)
310+
}
311+
}
312+
}
313+
314+
schemas.forEach((schema) => traverse(schema))
315+
316+
return arrayPaths
317+
}

src/storage/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export enum ErrorCode {
3434
DatabaseError = 'DatabaseError',
3535
MissingContentLength = 'MissingContentLength',
3636
MissingParameter = 'MissingParameter',
37+
InvalidParameter = 'InvalidParameter',
3738
InvalidUploadSignature = 'InvalidUploadSignature',
3839
LockTimeout = 'LockTimeout',
3940
S3Error = 'S3Error',
@@ -89,6 +90,14 @@ export const ERRORS = {
8990
originalError: e,
9091
}),
9192

93+
InvalidParameter: (parameter: string, e?: Error) =>
94+
new StorageBackendError({
95+
code: ErrorCode.MissingParameter,
96+
httpStatusCode: 400,
97+
message: `Invalid Parameter ${parameter}`,
98+
originalError: e,
99+
}),
100+
92101
InvalidJWT: (e?: Error) =>
93102
new StorageBackendError({
94103
code: ErrorCode.InvalidJWT,

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

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -891,8 +891,8 @@ export class S3ProtocolHandler {
891891
throw ERRORS.MissingParameter('Delete')
892892
}
893893

894-
if (!Delete.Objects) {
895-
throw ERRORS.MissingParameter('Objects')
894+
if (!Array.isArray(Delete.Objects)) {
895+
throw ERRORS.InvalidParameter('Objects')
896896
}
897897

898898
if (Delete.Objects.length === 0) {
@@ -903,28 +903,23 @@ export class S3ProtocolHandler {
903903
.from(Bucket)
904904
.deleteObjects(Delete.Objects.map((o) => o.Key || ''))
905905

906+
const deleted = Delete.Objects.filter((o) => deletedResult.find((d) => d.name === o.Key)).map(
907+
(o) => ({ Key: o.Key })
908+
)
909+
910+
const errors = Delete.Objects.filter((o) => !deletedResult.find((d) => d.name === o.Key)).map(
911+
(o) => ({
912+
Key: o.Key,
913+
Code: 'AccessDenied',
914+
Message: "You do not have permission to delete this object or the object doesn't exists",
915+
})
916+
)
917+
906918
return {
907919
responseBody: {
908920
DeletedResult: {
909-
Deleted: Delete.Objects.map((o) => {
910-
const isDeleted = deletedResult.find((d) => d.name === o.Key)
911-
if (isDeleted) {
912-
return {
913-
Deleted: {
914-
Key: o.Key,
915-
},
916-
}
917-
}
918-
919-
return {
920-
Error: {
921-
Key: o.Key,
922-
Code: 'AccessDenied',
923-
Message:
924-
"You do not have permission to delete this object or the object doesn't exists",
925-
},
926-
}
927-
}),
921+
Deleted: deleted,
922+
Error: errors,
928923
},
929924
},
930925
}

src/test/s3-protocol.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,37 @@ describe('S3 Protocol', () => {
701701
})
702702

703703
describe('DeleteObjectsCommand', () => {
704+
it('can delete a single object', async () => {
705+
const bucketName = await createBucket(client)
706+
await Promise.all([uploadFile(client, bucketName, 'test-1.jpg', 1)])
707+
708+
const deleteObjectsCommand = new DeleteObjectsCommand({
709+
Bucket: bucketName,
710+
Delete: {
711+
Objects: [
712+
{
713+
Key: 'test-1.jpg',
714+
},
715+
],
716+
},
717+
})
718+
719+
const deleteResp = await client.send(deleteObjectsCommand)
720+
721+
expect(deleteResp.Deleted).toEqual([
722+
{
723+
Key: 'test-1.jpg',
724+
},
725+
])
726+
727+
const listObjectsCommand = new ListObjectsV2Command({
728+
Bucket: bucketName,
729+
})
730+
731+
const resp = await client.send(listObjectsCommand)
732+
expect(resp.Contents).toBe(undefined)
733+
})
734+
704735
it('can delete multiple objects', async () => {
705736
const bucketName = await createBucket(client)
706737
await Promise.all([
@@ -726,7 +757,70 @@ describe('S3 Protocol', () => {
726757
},
727758
})
728759

729-
await client.send(deleteObjectsCommand)
760+
const deleteResp = await client.send(deleteObjectsCommand)
761+
762+
expect(deleteResp.Deleted).toEqual([
763+
{
764+
Key: 'test-1.jpg',
765+
},
766+
{
767+
Key: 'test-2.jpg',
768+
},
769+
{
770+
Key: 'test-3.jpg',
771+
},
772+
])
773+
774+
const listObjectsCommand = new ListObjectsV2Command({
775+
Bucket: bucketName,
776+
})
777+
778+
const resp = await client.send(listObjectsCommand)
779+
expect(resp.Contents).toBe(undefined)
780+
})
781+
782+
it('try to delete multiple objects that dont exists', async () => {
783+
const bucketName = await createBucket(client)
784+
785+
await uploadFile(client, bucketName, 'test-1.jpg', 1)
786+
787+
const deleteObjectsCommand = new DeleteObjectsCommand({
788+
Bucket: bucketName,
789+
Delete: {
790+
Objects: [
791+
{
792+
Key: 'test-1.jpg',
793+
},
794+
{
795+
Key: 'test-2.jpg',
796+
},
797+
{
798+
Key: 'test-3.jpg',
799+
},
800+
],
801+
},
802+
})
803+
804+
const deleteResp = await client.send(deleteObjectsCommand)
805+
expect(deleteResp.Deleted).toEqual([
806+
{
807+
Key: 'test-1.jpg',
808+
},
809+
])
810+
expect(deleteResp.Errors).toEqual([
811+
{
812+
Key: 'test-2.jpg',
813+
Code: 'AccessDenied',
814+
Message:
815+
"You do not have permission to delete this object or the object doesn't exists",
816+
},
817+
{
818+
Key: 'test-3.jpg',
819+
Code: 'AccessDenied',
820+
Message:
821+
"You do not have permission to delete this object or the object doesn't exists",
822+
},
823+
])
730824

731825
const listObjectsCommand = new ListObjectsV2Command({
732826
Bucket: bucketName,

0 commit comments

Comments
 (0)