Skip to content

Commit 4ef67ef

Browse files
authored
Add S3 etag verfication (#237)
1 parent 2bc8c04 commit 4ef67ef

File tree

3 files changed

+54
-3
lines changed

3 files changed

+54
-3
lines changed

src/services/S3Service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync } from 'fs';
22
import { fileURLToPath } from 'url';
3-
import { S3Client, PutObjectCommand, ListBucketsCommand } from '@aws-sdk/client-s3';
3+
import { S3Client, PutObjectCommand, ListBucketsCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
44
import { Measure } from '../telemetry/TelemetryDecorator';
55
import { AwsClient } from './AwsClient';
66

@@ -41,6 +41,17 @@ export class S3Service {
4141
});
4242
}
4343

44+
async getHeadObject(bucketName: string, key: string) {
45+
return await this.withClient(async (client) => {
46+
return await client.send(
47+
new HeadObjectCommand({
48+
Bucket: bucketName,
49+
Key: key,
50+
}),
51+
);
52+
});
53+
}
54+
4455
async putObject(localFilePath: string, s3Url: string) {
4556
const url = new URL(s3Url);
4657
const bucket = url.hostname;

src/stacks/actions/StackActionOperations.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export async function processChangeSet(
9191
}
9292
let templateBody = document.contents();
9393
let templateS3Url: string | undefined;
94+
let expectedETag: string | undefined;
9495
try {
9596
if (params.s3Bucket) {
9697
const s3KeyPrefix = params.s3Key ? params.s3Key.slice(0, params.s3Key.lastIndexOf('/')) : undefined;
@@ -107,7 +108,8 @@ export async function processChangeSet(
107108
}
108109

109110
if (params.s3Bucket && params.s3Key) {
110-
await s3Service.putObjectContent(templateBody, params.s3Bucket, params.s3Key);
111+
const putResult = await s3Service.putObjectContent(templateBody, params.s3Bucket, params.s3Key);
112+
expectedETag = putResult.ETag;
111113
templateS3Url = `https://s3.amazonaws.com/${params.s3Bucket}/${params.s3Key}`;
112114
}
113115
} catch (error) {
@@ -125,6 +127,17 @@ export async function processChangeSet(
125127
params.onStackFailure,
126128
);
127129

130+
// Verify S3 object ETag before creating change set
131+
if (templateS3Url && expectedETag && params.s3Bucket && params.s3Key) {
132+
const headResult = await s3Service.getHeadObject(params.s3Bucket, params.s3Key);
133+
if (headResult.ETag !== expectedETag) {
134+
throw new ResponseError(
135+
ErrorCodes.InvalidParams,
136+
`S3 object ETag mismatch. Expected: ${expectedETag}, Got: ${headResult.ETag}`,
137+
);
138+
}
139+
}
140+
128141
await cfnService.createChangeSet({
129142
StackName: params.stackName,
130143
ChangeSetName: changeSetName,

tst/unit/stackActions/StackActionWorkflowOperations.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ describe('StackActionWorkflowOperations', () => {
122122
Id: 'changeset-123',
123123
});
124124

125-
mockS3Service.putObjectContent = vi.fn().mockResolvedValue({});
125+
mockS3Service.putObjectContent = vi.fn().mockResolvedValue({ ETag: '"test-etag"' });
126+
mockS3Service.getHeadObject = vi.fn().mockResolvedValue({ ETag: '"test-etag"' });
126127

127128
const result = await processChangeSet(mockCfnService, mockDocumentManager, params, 'CREATE', mockS3Service);
128129

@@ -132,6 +133,7 @@ describe('StackActionWorkflowOperations', () => {
132133
'test-bucket',
133134
'template.yaml',
134135
);
136+
expect(mockS3Service.getHeadObject).toHaveBeenCalledWith('test-bucket', 'template.yaml');
135137
expect(mockCfnService.createChangeSet).toHaveBeenCalledWith({
136138
StackName: 'test-stack',
137139
ChangeSetName: expect.stringContaining(ExtensionName.replaceAll(' ', '-')),
@@ -144,6 +146,31 @@ describe('StackActionWorkflowOperations', () => {
144146
});
145147
});
146148

149+
it('should throw error when S3 ETag mismatch occurs', async () => {
150+
const params: CreateValidationParams = {
151+
id: 'test-id',
152+
uri: 'file:///test.yaml',
153+
stackName: 'test-stack',
154+
s3Bucket: 'test-bucket',
155+
s3Key: 'template.yaml',
156+
};
157+
158+
const mockDocument = {
159+
contents: () => 'template content',
160+
documentType: 'YAML',
161+
};
162+
(mockDocumentManager.get as any).mockReturnValue(mockDocument);
163+
164+
mockS3Service.putObjectContent = vi.fn().mockResolvedValue({ ETag: '"original-etag"' });
165+
mockS3Service.getHeadObject = vi.fn().mockResolvedValue({ ETag: '"different-etag"' });
166+
167+
await expect(
168+
processChangeSet(mockCfnService, mockDocumentManager, params, 'CREATE', mockS3Service),
169+
).rejects.toThrow(ResponseError);
170+
171+
expect(mockS3Service.getHeadObject).toHaveBeenCalledWith('test-bucket', 'template.yaml');
172+
});
173+
147174
it('should throw error when document not found', async () => {
148175
const params: CreateValidationParams = {
149176
id: 'test-id',

0 commit comments

Comments
 (0)