Skip to content

Commit 12ec194

Browse files
committed
fix(lambda): close the remaining contract validation gaps
Follow-up to #7216, from review on the release PR. Every bound added here is one the AWS Lambda API reference documents; findings that asked for undocumented limits were left alone. Code source selection had two holes. `hasS3` required both S3 fields, so a partial pair alongside `imageUri` read as "image only" and the stray S3 field still went to AWS; and an `imageUri` with no `packageType` was accepted even though the package type then defaults to Zip. Both now fail at the boundary, naming the field to change, and the zip-only `s3ObjectVersion` and `sourceKmsKeyArn` count as S3 fields for the exclusivity check. The VPC guard only checked that both lists were supplied, so supplying both with one empty passed and produced a partial update. Both must now be empty (detach) or both populated (attach). Documented bounds added: - alias names: 1-128 and the documented pattern, which excludes all-digit names - descriptions: 256 characters - layer names: 140 characters and the name-or-ARN pattern - optional `functionName` on the event source mapping operations: 1-256 - `RemovePermission` statement IDs: 1-100 and its own pattern, which allows a dot where `AddPermission` does not Also rejects values that are structurally meaningless rather than merely out of range: empty tag keys, empty Kafka bootstrap servers, more than one weighted routing entry, and an event source mapping that supplies both an event source ARN and self-managed Kafka bootstrap servers.
1 parent acc4f1d commit 12ec194

16 files changed

Lines changed: 372 additions & 43 deletions

apps/sim/lib/api/contracts/tools/aws/lambda-create-alias.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@ const CreateAliasSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
aliasName: z.string().min(1, 'aliasName is required'),
19+
aliasName: z
20+
.string()
21+
.min(1, 'aliasName is required')
22+
.max(128, 'aliasName cannot exceed 128 characters')
23+
.regex(
24+
/^(?![0-9]+$)[a-zA-Z0-9-_]+$/,
25+
'aliasName may only contain letters, numbers, hyphens, and underscores, and cannot be all digits'
26+
),
2027
aliasFunctionVersion: z.string().min(1, 'aliasFunctionVersion is required'),
21-
description: z.string().optional(),
28+
description: z.string().max(256, 'description cannot exceed 256 characters').optional(),
2229
additionalVersionWeights: z
2330
.record(
2431
z.string().regex(/^[0-9]+$/, 'routing keys must be published version numbers'),
@@ -27,6 +34,10 @@ const CreateAliasSchema = z.object({
2734
.min(0, 'a routing weight cannot be negative')
2835
.max(1, 'a routing weight cannot exceed 1')
2936
)
37+
.refine(
38+
(weights) => Object.keys(weights).length <= 1,
39+
'additionalVersionWeights routes to a single second version, so it accepts at most one entry'
40+
)
3041
.optional(),
3142
})
3243

apps/sim/lib/api/contracts/tools/aws/lambda-create-event-source-mapping.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,26 @@ const CreateEventSourceMappingSchema = z
5454
documentDbFullDocument: z.enum(['UpdateLookup', 'Default']).optional(),
5555
amazonManagedKafkaConsumerGroupId: z.string().optional(),
5656
selfManagedKafkaConsumerGroupId: z.string().optional(),
57-
selfManagedKafkaBootstrapServers: z.array(z.string()).optional(),
57+
selfManagedKafkaBootstrapServers: z
58+
.array(z.string().min(1, 'a bootstrap server cannot be empty'))
59+
.optional(),
5860
})
5961
.superRefine((value, ctx) => {
60-
if (!value.eventSourceArn && !value.selfManagedKafkaBootstrapServers?.length) {
62+
const hasBootstrapServers = Boolean(value.selfManagedKafkaBootstrapServers?.length)
63+
if (!value.eventSourceArn && !hasBootstrapServers) {
6164
ctx.addIssue({
6265
code: 'custom',
6366
path: ['eventSourceArn'],
6467
message:
6568
'An event source is required: provide eventSourceArn, or selfManagedKafkaBootstrapServers for a self-managed Kafka cluster',
6669
})
70+
} else if (value.eventSourceArn && hasBootstrapServers) {
71+
ctx.addIssue({
72+
code: 'custom',
73+
path: ['selfManagedKafkaBootstrapServers'],
74+
message:
75+
'A mapping has one event source: provide eventSourceArn, or selfManagedKafkaBootstrapServers, not both',
76+
})
6777
}
6878
if (value.startingPosition === 'AT_TIMESTAMP' && !value.startingPositionTimestamp) {
6979
ctx.addIssue({

apps/sim/lib/api/contracts/tools/aws/lambda-create-function.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const CreateFunctionSchema = z
2626
s3ObjectVersion: z.string().optional(),
2727
imageUri: z.string().optional(),
2828
sourceKmsKeyArn: z.string().optional(),
29-
description: z.string().optional(),
29+
description: z.string().max(256, 'description cannot exceed 256 characters').optional(),
3030
functionTimeout: z.number().int().min(1).max(900).optional(),
3131
memorySize: z.number().int().min(128).max(32768).optional(),
3232
ephemeralStorageSize: z.number().int().min(512).max(10240).optional(),
@@ -48,36 +48,41 @@ const CreateFunctionSchema = z
4848
logGroup: z.string().optional(),
4949
})
5050
.superRefine((value, ctx) => {
51+
const hasAnyZipField = Boolean(
52+
value.s3Bucket || value.s3Key || value.s3ObjectVersion || value.sourceKmsKeyArn
53+
)
5154
const hasS3 = Boolean(value.s3Bucket && value.s3Key)
52-
if (!hasS3 && !value.imageUri) {
55+
if (value.imageUri && hasAnyZipField) {
5356
ctx.addIssue({
5457
code: 'custom',
55-
path: ['s3Bucket'],
58+
path: ['imageUri'],
5659
message:
57-
'A code source is required: provide s3Bucket and s3Key for a .zip package, or imageUri for a container image',
60+
'Provide either a .zip package (s3Bucket, s3Key, s3ObjectVersion, sourceKmsKeyArn) or imageUri, not both',
5861
})
5962
return
6063
}
61-
if (hasS3 && value.imageUri) {
64+
if (!value.imageUri && !hasS3) {
6265
ctx.addIssue({
6366
code: 'custom',
64-
path: ['imageUri'],
65-
message: 'Provide either an S3 package or imageUri, not both',
67+
path: hasAnyZipField ? ['s3Key'] : ['s3Bucket'],
68+
message: hasAnyZipField
69+
? 's3Bucket and s3Key must be provided together for a .zip package'
70+
: 'A code source is required: provide s3Bucket and s3Key for a .zip package, or imageUri for a container image',
6671
})
6772
return
6873
}
69-
if (value.packageType === 'Image' && hasS3) {
74+
if (value.imageUri && value.packageType !== 'Image') {
7075
ctx.addIssue({
7176
code: 'custom',
72-
path: ['imageUri'],
73-
message: 'packageType Image requires imageUri, not an S3 package',
77+
path: ['packageType'],
78+
message: 'packageType must be Image when imageUri is set',
7479
})
7580
}
76-
if (value.packageType === 'Zip' && value.imageUri) {
81+
if (hasS3 && value.packageType === 'Image') {
7782
ctx.addIssue({
7883
code: 'custom',
79-
path: ['s3Bucket'],
80-
message: 'packageType Zip requires an S3 package, not imageUri',
84+
path: ['imageUri'],
85+
message: 'packageType Image requires imageUri, not an S3 package',
8186
})
8287
}
8388
if (hasS3) {
@@ -96,15 +101,26 @@ const CreateFunctionSchema = z
96101
})
97102
}
98103
}
99-
const hasSubnets = value.vpcSubnetIds !== undefined
100-
const hasSecurityGroups = value.vpcSecurityGroupIds !== undefined
101-
if (hasSubnets !== hasSecurityGroups) {
104+
const subnetIds = value.vpcSubnetIds
105+
const securityGroupIds = value.vpcSecurityGroupIds
106+
if ((subnetIds === undefined) !== (securityGroupIds === undefined)) {
102107
ctx.addIssue({
103108
code: 'custom',
104-
path: [hasSubnets ? 'vpcSecurityGroupIds' : 'vpcSubnetIds'],
109+
path: [subnetIds === undefined ? 'vpcSubnetIds' : 'vpcSecurityGroupIds'],
105110
message:
106111
'vpcSubnetIds and vpcSecurityGroupIds must be supplied together: send both lists to attach a VPC, or both empty to detach',
107112
})
113+
} else if (
114+
subnetIds !== undefined &&
115+
securityGroupIds !== undefined &&
116+
(subnetIds.length === 0) !== (securityGroupIds.length === 0)
117+
) {
118+
ctx.addIssue({
119+
code: 'custom',
120+
path: [subnetIds.length === 0 ? 'vpcSubnetIds' : 'vpcSecurityGroupIds'],
121+
message:
122+
'vpcSubnetIds and vpcSecurityGroupIds must both be empty to detach, or both be populated to attach',
123+
})
108124
}
109125
})
110126

apps/sim/lib/api/contracts/tools/aws/lambda-delete-alias.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ const DeleteAliasSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
aliasName: z.string().min(1, 'aliasName is required'),
19+
aliasName: z
20+
.string()
21+
.min(1, 'aliasName is required')
22+
.max(128, 'aliasName cannot exceed 128 characters')
23+
.regex(
24+
/^(?![0-9]+$)[a-zA-Z0-9-_]+$/,
25+
'aliasName may only contain letters, numbers, hyphens, and underscores, and cannot be all digits'
26+
),
2027
})
2128

2229
const DeleteAliasResponseSchema = lambdaMessageResponseSchema

apps/sim/lib/api/contracts/tools/aws/lambda-get-alias.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ const GetAliasSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
aliasName: z.string().min(1, 'aliasName is required'),
19+
aliasName: z
20+
.string()
21+
.min(1, 'aliasName is required')
22+
.max(128, 'aliasName cannot exceed 128 characters')
23+
.regex(
24+
/^(?![0-9]+$)[a-zA-Z0-9-_]+$/,
25+
'aliasName may only contain letters, numbers, hyphens, and underscores, and cannot be all digits'
26+
),
2027
})
2128

2229
const GetAliasResponseSchema = z.object({

apps/sim/lib/api/contracts/tools/aws/lambda-get-layer-version.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1212

1313
const GetLayerVersionSchema = z.object({
1414
...lambdaConnectionFields,
15-
layerName: z.string().min(1, 'layerName is required'),
15+
layerName: z
16+
.string()
17+
.min(1, 'layerName is required')
18+
.max(140, 'layerName cannot exceed 140 characters')
19+
.regex(
20+
/^(arn:[a-zA-Z0-9-]+:lambda:[a-zA-Z0-9-]+:d{12}:layer:[a-zA-Z0-9-_]+)$|^[a-zA-Z0-9-_]+$/,
21+
'layerName must be a layer name or a layer ARN'
22+
),
1623
versionNumber: z.number().int().min(1, 'versionNumber must be at least 1'),
1724
})
1825

apps/sim/lib/api/contracts/tools/aws/lambda-list-event-source-mappings.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1414
const ListEventSourceMappingsSchema = z.object({
1515
...lambdaConnectionFields,
1616
...lambdaPaginationFields,
17-
functionName: z.string().optional(),
17+
functionName: z
18+
.string()
19+
.min(1, 'functionName cannot be empty')
20+
.max(256, 'functionName cannot exceed 256 characters')
21+
.optional(),
1822
eventSourceArn: z.string().optional(),
1923
})
2024

apps/sim/lib/api/contracts/tools/aws/lambda-list-layer-versions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1414
const ListLayerVersionsSchema = z.object({
1515
...lambdaConnectionFields,
1616
...lambdaSmallPaginationFields,
17-
layerName: z.string().min(1, 'layerName is required'),
17+
layerName: z
18+
.string()
19+
.min(1, 'layerName is required')
20+
.max(140, 'layerName cannot exceed 140 characters')
21+
.regex(
22+
/^(arn:[a-zA-Z0-9-]+:lambda:[a-zA-Z0-9-]+:d{12}:layer:[a-zA-Z0-9-_]+)$|^[a-zA-Z0-9-_]+$/,
23+
'layerName must be a layer name or a layer ARN'
24+
),
1825
compatibleRuntime: z.string().optional(),
1926
compatibleArchitecture: z.enum(['x86_64', 'arm64']).optional(),
2027
})

apps/sim/lib/api/contracts/tools/aws/lambda-publish-version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const PublishVersionSchema = z.object({
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
1919
codeSha256: z.string().optional(),
20-
description: z.string().optional(),
20+
description: z.string().max(256, 'description cannot exceed 256 characters').optional(),
2121
revisionId: z.string().optional(),
2222
})
2323

apps/sim/lib/api/contracts/tools/aws/lambda-remove-permission.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ const RemovePermissionSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
statementId: z.string().min(1, 'statementId is required'),
19+
statementId: z
20+
.string()
21+
.min(1, 'statementId is required')
22+
.max(100, 'statementId cannot exceed 100 characters')
23+
.regex(
24+
/^[a-zA-Z0-9-_.]+$/,
25+
'statementId may only contain letters, numbers, hyphens, underscores, and dots'
26+
),
2027
qualifier: z
2128
.string()
2229
.min(1, 'qualifier cannot be empty')

0 commit comments

Comments
 (0)