Skip to content

Commit 818b51c

Browse files
nk1107nick-Ag
authored andcommitted
adding s3 bucket name validation (#3086)
1 parent 988b27d commit 818b51c

File tree

4 files changed

+251
-5
lines changed

4 files changed

+251
-5
lines changed

packages/destination-actions/src/destinations/liveramp-audiences/__tests__/liveramp.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe('Liveramp Audiences', () => {
7878
mapping: {
7979
s3_aws_access_key: 's3_aws_access_key',
8080
s3_aws_secret_key: 's3_aws_secret_key',
81-
s3_aws_bucket_name: 's3_aws_bucket_name',
81+
s3_aws_bucket_name: 's3-aws-bucket-name',
8282
s3_aws_region: 's3_aws_region',
8383
audience_key: 'audience_key',
8484
delimiter: ',',
@@ -106,7 +106,7 @@ describe('Liveramp Audiences', () => {
106106
mapping: {
107107
s3_aws_access_key: 's3_aws_access_key',
108108
s3_aws_secret_key: 's3_aws_secret_key',
109-
s3_aws_bucket_name: 's3_aws_bucket_name',
109+
s3_aws_bucket_name: 's3-aws-bucket-name',
110110
s3_aws_region: 's3_aws_region',
111111
audience_key: 'audience_key',
112112
delimiter: ',',
@@ -130,6 +130,37 @@ describe('Liveramp Audiences', () => {
130130
expect(e.status).toEqual(400)
131131
}
132132
})
133+
it(`should throw error if S3 bucket name is invalid`, async () => {
134+
try {
135+
await testDestination.executeBatch('audienceEnteredS3', {
136+
events: mockedEvents,
137+
mapping: {
138+
s3_aws_access_key: 's3_aws_access_key',
139+
s3_aws_secret_key: 's3_aws_secret_key',
140+
s3_aws_bucket_name: 'for-liveramp/folder01/folder_001/',
141+
s3_aws_region: 's3_aws_region',
142+
audience_key: 'audience_key',
143+
delimiter: ',',
144+
filename: 'filename.csv',
145+
enable_batching: true
146+
},
147+
subscriptionMetadata: {
148+
destinationConfigId: 'destinationConfigId',
149+
actionConfigId: 'actionConfigId'
150+
},
151+
settings: {
152+
__segment_internal_engage_force_full_sync: true,
153+
__segment_internal_engage_batch_sync: true
154+
}
155+
})
156+
} catch (e) {
157+
expect(e).toBeInstanceOf(PayloadValidationError)
158+
expect(e.message).toEqual(
159+
`Invalid S3 bucket name: "for-liveramp/folder01/folder_001/". Bucket names cannot contain '/' characters, must be lowercase, and follow AWS naming conventions.`
160+
)
161+
expect(e.status).toEqual(400)
162+
}
163+
})
133164

134165
it(`should throw error if S3 bucket path is invalid`, async () => {
135166
try {
@@ -138,7 +169,7 @@ describe('Liveramp Audiences', () => {
138169
mapping: {
139170
s3_aws_access_key: 's3_aws_access_key',
140171
s3_aws_secret_key: 's3_aws_secret_key',
141-
s3_aws_bucket_name: 's3_aws_bucket_name',
172+
s3_aws_bucket_name: 's3-aws-bucket-name',
142173
s3_aws_region: 's3_aws_region',
143174
audience_key: 'audience_key',
144175
delimiter: ',',
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { isValidS3BucketName } from '../audienceEnteredS3/s3'
2+
3+
describe('isValidS3BucketName', () => {
4+
describe('valid bucket names', () => {
5+
it('should return true for valid bucket names', () => {
6+
const validNames = [
7+
'my-bucket',
8+
'mybucket123',
9+
'my.bucket.name',
10+
'abc',
11+
'test-bucket-123',
12+
'bucket.with.dots',
13+
'a1b2c3',
14+
'bucket-name-with-hyphens',
15+
'bucket.name.with.dots',
16+
'my-bucket-123.test',
17+
'validbucketname123'
18+
]
19+
20+
validNames.forEach((name) => {
21+
expect(isValidS3BucketName(name)).toBe(true)
22+
})
23+
})
24+
})
25+
26+
describe('invalid bucket names - length constraints', () => {
27+
it('should return false for bucket names that are too short', () => {
28+
expect(isValidS3BucketName('ab')).toBe(false)
29+
expect(isValidS3BucketName('a')).toBe(false)
30+
expect(isValidS3BucketName('')).toBe(false)
31+
})
32+
33+
it('should return false for bucket names that are too long', () => {
34+
const longName = 'a'.repeat(64) // 64 characters, exceeds 63 limit
35+
expect(isValidS3BucketName(longName)).toBe(false)
36+
})
37+
})
38+
39+
describe('invalid bucket names - character constraints', () => {
40+
it('should return false for bucket names with uppercase letters', () => {
41+
expect(isValidS3BucketName('MyBucket')).toBe(false)
42+
expect(isValidS3BucketName('BUCKET')).toBe(false)
43+
expect(isValidS3BucketName('Test-Bucket')).toBe(false)
44+
})
45+
46+
it('should return false for bucket names with invalid characters', () => {
47+
expect(isValidS3BucketName('my_bucket')).toBe(false) // underscore
48+
expect(isValidS3BucketName('my bucket')).toBe(false) // space
49+
expect(isValidS3BucketName('my@bucket')).toBe(false) // @ symbol
50+
expect(isValidS3BucketName('my#bucket')).toBe(false) // # symbol
51+
expect(isValidS3BucketName('my$bucket')).toBe(false) // $ symbol
52+
expect(isValidS3BucketName('my%bucket')).toBe(false) // % symbol
53+
expect(isValidS3BucketName('my&bucket')).toBe(false) // & symbol
54+
expect(isValidS3BucketName('my*bucket')).toBe(false) // * symbol
55+
expect(isValidS3BucketName('my+bucket')).toBe(false) // + symbol
56+
expect(isValidS3BucketName('my=bucket')).toBe(false) // = symbol
57+
expect(isValidS3BucketName('my\\bucket')).toBe(false) // backslash
58+
expect(isValidS3BucketName('my/bucket')).toBe(false) // forward slash
59+
})
60+
})
61+
62+
describe('invalid bucket names - begin/end constraints', () => {
63+
it('should return false for bucket names that start with invalid characters', () => {
64+
expect(isValidS3BucketName('.bucket')).toBe(false)
65+
expect(isValidS3BucketName('-bucket')).toBe(false)
66+
expect(isValidS3BucketName('.my-bucket')).toBe(false)
67+
expect(isValidS3BucketName('-my-bucket')).toBe(false)
68+
})
69+
70+
it('should return false for bucket names that end with invalid characters', () => {
71+
expect(isValidS3BucketName('bucket.')).toBe(false)
72+
expect(isValidS3BucketName('bucket-')).toBe(false)
73+
expect(isValidS3BucketName('my-bucket.')).toBe(false)
74+
expect(isValidS3BucketName('my-bucket-')).toBe(false)
75+
})
76+
})
77+
78+
describe('invalid bucket names - consecutive periods', () => {
79+
it('should return false for bucket names with consecutive periods', () => {
80+
expect(isValidS3BucketName('my..bucket')).toBe(false)
81+
expect(isValidS3BucketName('bucket..name')).toBe(false)
82+
expect(isValidS3BucketName('my...bucket')).toBe(false)
83+
expect(isValidS3BucketName('a..b')).toBe(false)
84+
})
85+
})
86+
87+
describe('invalid bucket names - IP address format', () => {
88+
it('should return false for bucket names formatted as IP addresses', () => {
89+
expect(isValidS3BucketName('192.168.1.1')).toBe(false)
90+
expect(isValidS3BucketName('10.0.0.1')).toBe(false)
91+
expect(isValidS3BucketName('172.16.0.1')).toBe(false)
92+
expect(isValidS3BucketName('255.255.255.255')).toBe(false)
93+
expect(isValidS3BucketName('0.0.0.0')).toBe(false)
94+
})
95+
96+
it('should return false for bucket names that look like IP addresses (even invalid ones)', () => {
97+
expect(isValidS3BucketName('192.168.1.300')).toBe(false) // 300 > 255, but still IP-like
98+
expect(isValidS3BucketName('999.999.999.999')).toBe(false) // invalid IP but IP-like format
99+
expect(isValidS3BucketName('123.456.789.012')).toBe(false) // invalid numbers but IP-like format
100+
expect(isValidS3BucketName('1.2.3.4')).toBe(false) // valid IP format
101+
expect(isValidS3BucketName('001.002.003.004')).toBe(false) // leading zeros, still IP-like
102+
})
103+
104+
it('should return true for non-IP-like names with dots and numbers', () => {
105+
expect(isValidS3BucketName('192.168.1')).toBe(true) // incomplete IP (only 3 parts)
106+
expect(isValidS3BucketName('192.168.1.1.1')).toBe(true) // too many octets (5 parts)
107+
expect(isValidS3BucketName('bucket.123.name')).toBe(true) // clearly not IP format
108+
expect(isValidS3BucketName('v1.2.3')).toBe(true) // version-like, not IP
109+
expect(isValidS3BucketName('file.123')).toBe(true) // only 2 parts
110+
expect(isValidS3BucketName('a.b.c.d')).toBe(true) // 4 parts but contains letters
111+
expect(isValidS3BucketName('my-bucket.123.test')).toBe(true) // contains hyphens
112+
})
113+
})
114+
115+
describe('invalid bucket names - forbidden prefixes', () => {
116+
it('should return false for bucket names with forbidden prefixes', () => {
117+
expect(isValidS3BucketName('xn--bucket')).toBe(false)
118+
expect(isValidS3BucketName('sthree-bucket')).toBe(false)
119+
expect(isValidS3BucketName('amzn-s3-demo-bucket')).toBe(false)
120+
expect(isValidS3BucketName('xn--test')).toBe(false)
121+
expect(isValidS3BucketName('sthree-test123')).toBe(false)
122+
expect(isValidS3BucketName('amzn-s3-demo-test')).toBe(false)
123+
})
124+
})
125+
126+
describe('invalid bucket names - forbidden suffixes', () => {
127+
it('should return false for bucket names with forbidden suffixes', () => {
128+
expect(isValidS3BucketName('bucket-s3alias')).toBe(false)
129+
expect(isValidS3BucketName('bucket--ol-s3')).toBe(false)
130+
expect(isValidS3BucketName('bucket.mrap')).toBe(false)
131+
expect(isValidS3BucketName('bucket--x-s3')).toBe(false)
132+
expect(isValidS3BucketName('bucket--table-s3')).toBe(false)
133+
expect(isValidS3BucketName('my-bucket-s3alias')).toBe(false)
134+
expect(isValidS3BucketName('test--ol-s3')).toBe(false)
135+
expect(isValidS3BucketName('example.mrap')).toBe(false)
136+
})
137+
})
138+
139+
describe('edge cases', () => {
140+
it('should handle exact length limits', () => {
141+
const minValidName = 'abc' // 3 characters (minimum)
142+
const maxValidName = 'a'.repeat(63) // 63 characters (maximum)
143+
144+
expect(isValidS3BucketName(minValidName)).toBe(true)
145+
expect(isValidS3BucketName(maxValidName)).toBe(true)
146+
})
147+
148+
it('should handle single character begin/end cases', () => {
149+
expect(isValidS3BucketName('a')).toBe(false) // too short but would be valid otherwise
150+
expect(isValidS3BucketName('a1a')).toBe(true) // minimum valid case
151+
expect(isValidS3BucketName('1a1')).toBe(true) // numbers at begin/end
152+
})
153+
})
154+
})

packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredS3/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ActionDefinition, PayloadValidationError } from '@segment/actions-core'
2-
import { isValidS3Path, normalizeS3Path, uploadS3 } from './s3'
2+
import { isValidS3Path, isValidS3BucketName, normalizeS3Path, uploadS3 } from './s3'
33
import { generateFile } from '../operations'
44
import { sendEventToAWS } from '../awsClient'
55
import { LIVERAMP_MIN_RECORD_COUNT, LIVERAMP_LEGACY_FLOW_FLAG_NAME } from '../properties'
@@ -130,6 +130,12 @@ async function processData(input: ProcessDataInput<Payload>, subscriptionMetadat
130130
`received payload count below LiveRamp's ingestion limits. expected: >=${LIVERAMP_MIN_RECORD_COUNT} actual: ${input.payloads.length}`
131131
)
132132
}
133+
//validate s3 bucket name
134+
if (input.payloads[0].s3_aws_bucket_name && !isValidS3BucketName(input.payloads[0].s3_aws_bucket_name)) {
135+
throw new PayloadValidationError(
136+
`Invalid S3 bucket name: "${input.payloads[0].s3_aws_bucket_name}". Bucket names cannot contain '/' characters, must be lowercase, and follow AWS naming conventions.`
137+
)
138+
}
133139

134140
// validate s3 path
135141
input.payloads[0].s3_aws_bucket_path = normalizeS3Path(input.payloads[0].s3_aws_bucket_path)

packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredS3/s3.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,59 @@ function normalizeS3Path(path?: string): string | undefined {
8888
return path?.replace(/^\/+|\/+$/g, '')
8989
}
9090

91-
export { validateS3, uploadS3, isValidS3Path, normalizeS3Path }
91+
/**
92+
* Checks whether the provided string is a valid S3 bucket name.
93+
* https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
94+
*
95+
* @param bucketName - The S3 bucket name to validate.
96+
* @returns `true` if the bucket name is valid according to AWS naming rules, otherwise `false`.
97+
*/
98+
function isValidS3BucketName(bucketName: string): boolean {
99+
// Basic checks
100+
if (!bucketName || bucketName.length < 3 || bucketName.length > 63) {
101+
return false
102+
}
103+
104+
// Bucket names can consist only of lowercase letters, numbers, periods (.), and hyphens (-)
105+
const validCharsRegex = /^[a-z0-9.-]+$/
106+
if (!validCharsRegex.test(bucketName)) {
107+
return false
108+
}
109+
110+
// Bucket names must begin and end with a letter or number
111+
const beginEndRegex = /^[a-z0-9].*[a-z0-9]$/
112+
if (!beginEndRegex.test(bucketName)) {
113+
return false
114+
}
115+
116+
// Bucket names must not contain two adjacent periods
117+
if (bucketName.includes('..')) {
118+
return false
119+
}
120+
121+
// Bucket names must not be formatted as an IP address (IPv4)
122+
const ipRegex = /^(?:\d{1,3}\.){3}\d{1,3}$/
123+
if (ipRegex.test(bucketName)) {
124+
return false
125+
}
126+
127+
// Bucket names must not start with forbidden prefixes
128+
const forbiddenPrefixes = ['xn--', 'sthree-', 'amzn-s3-demo-']
129+
for (const prefix of forbiddenPrefixes) {
130+
if (bucketName.startsWith(prefix)) {
131+
return false
132+
}
133+
}
134+
135+
// Bucket names must not end with forbidden suffixes
136+
const forbiddenSuffixes = ['-s3alias', '--ol-s3', '.mrap', '--x-s3', '--table-s3']
137+
for (const suffix of forbiddenSuffixes) {
138+
if (bucketName.endsWith(suffix)) {
139+
return false
140+
}
141+
}
142+
143+
return true
144+
}
145+
146+
export { validateS3, uploadS3, isValidS3Path, normalizeS3Path, isValidS3BucketName }

0 commit comments

Comments
 (0)