-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.cjs
300 lines (269 loc) · 9.95 KB
/
index.cjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
/* NOTE:
For now, putting code inline into the CF template (with ZipFile) requires us
to use CommonJS instead of ES Modules.
See: https://github.com/aws-cloudformation/cloudformation-coverage-roadmap/issues/1832
*/
const assert = require('node:assert/strict');
const {
S3Client,
GetBucketLocationCommand,
GetBucketPolicyCommand,
PutBucketPolicyCommand,
} = require('@aws-sdk/client-s3');
// needed if no existing bucket policy, to house our statement
const docSkeleton = '{"Version":"2012-10-17","Statement":[]}';
// define our base statement ID and structure
const Sid = 'S3CloudflarePolicyWriterManagedStatement';
const baseStatement = {
Sid,
// allows get of any object from this bucket
Effect: 'Allow',
Action: 's3:GetObject',
Principal: '*',
Resource: 'arn:aws:s3:::__BUCKET_NAME__/*',
// ^^ need to specify full ARN of each bucket to which this policy is attached
// only when the source ip is in the given range(s)
Condition: {
IpAddress: {
'aws:SourceIp': [],
},
},
};
exports.handler = async (event, context) => {
const {
LOG_VERBOSE = false,
// ^ extra logging for debug purposes
AWS_REGION,
TARGET_BUCKETS,
/** ^^
* can't default this, so we'll need AT LEAST one bucket name to which we'll
* be writing directly attached bucket policies. could accept more than one
* bucket name, assuming comma-delimited.
* - 'static.example.com'
* - 'cdn.example.com,content.example.com,assets.example.com'
*/
} = process.env;
// support verbose logging based on a provided env variable
const verbose = (...messages) => {
if (LOG_VERBOSE) console.log(...messages);
};
// fetch ipv4 and v6 ranges, merge them to single array, and assign to policy doc
const ipsV4 = await (await fetch('https://www.cloudflare.com/ips-v4')).text();
const ipsV6 = await (await fetch('https://www.cloudflare.com/ips-v6')).text();
const cfRanges = `${ipsV4}\n${ipsV6}`
.split("\n")
.filter(x => x)
;
verbose(`Got ${cfRanges.length} IP ranges from Cloudflare:`, cfRanges);
baseStatement.Condition.IpAddress['aws:SourceIp'] = cfRanges;
// parse out current AWS account number
const awsAccountId = context.invokedFunctionArn.split(':')[4];
verbose(`Got current operating account id: ${awsAccountId}`);
// split buckets and convert to per-bucket objects
if (!TARGET_BUCKETS) {
throw new Error(`Missing target bucket list: ${TARGET_BUCKETS}`);
}
const targetBuckets = TARGET_BUCKETS
.split(',')
.map( bucketName => ({bucketName}) )
;
verbose(`Got ${targetBuckets.length} target buckets:`, targetBuckets);
// create a client, initially in our current region
const client = {
[AWS_REGION]: new S3Client({
region: AWS_REGION
})
};
// iterate buckets initially to:
// (1) confirm access
// (2) get current bucket policies
// (3) check whether update necessary
for (let currentBucket in targetBuckets) {
const targetBucket = targetBuckets[currentBucket];
// get region in which current bucket lives
// storing getException for later, if it fails
try {
const getBucketLocationCommand = new GetBucketLocationCommand({
Bucket: targetBucket.bucketName,
ExpectedBucketOwner: awsAccountId,
});
const {
LocationConstraint: bucketRegion = 'us-east-1',
// ^^ Buckets in Region us-east-1 will have a LocationConstraint of null.
} = await client[AWS_REGION].send(getBucketLocationCommand);
// record bucket region for later use
targetBucket.region = bucketRegion;
// create new client in bucket region, if it differs from current region
if (bucketRegion != AWS_REGION) {
verbose(`Found bucket ${targetBucket.bucketName} in different region (vs current of ${AWS_REGION}): ${bucketRegion}`);
client[bucketRegion] = new S3Client({ region: bucketRegion });
}
} catch (err) {
targetBucket.getException = err;
// skip all policy stuff below, as we likely won't have access
continue;
}
// attempt to get existing policy for given bucket
// storing getException for later, if it fails
try {
const getBucketPolicyCommand = new GetBucketPolicyCommand({
Bucket: targetBucket.bucketName,
ExpectedBucketOwner: awsAccountId,
});
const { Policy: targetPolicy } = await client[targetBucket.region].send(getBucketPolicyCommand);
targetBucket.currentPolicy = targetPolicy;
verbose(`Got ${targetPolicy.length} bytes of policy for bucket ${targetBucket.bucketName}`);
} catch (err) {
if (err.message !== 'The bucket policy does not exist') {
targetBucket.getException = err;
// skip parse + compare below
continue;
}
}
// handle empty policies that may come back as unparseable values
// like undefined or empty string
if (!targetBucket.currentPolicy) {
targetBucket.differs = true;
verbose(`Empty/missing policy for bucket ${targetBucket.bucketName}`);
continue;
}
// catch any JSON syntax errors as necessary
let testPolicy;
try {
// otherwise parse a populated JSON string
testPolicy = JSON.parse(targetBucket.currentPolicy);
// reassign parsed policy as object over JSON policy string
targetBucket.currentPolicy = testPolicy;
} catch (err) {
targetBucket.parseException = err;
// skip compare below
continue;
}
// ensure first that policy HAS a Statements array
if (
(!testPolicy.hasOwnProperty('Statement'))
||
(!Array.isArray(testPolicy.Statement))
) {
targetBucket.differs = true;
verbose(`Policy missing statements list for bucket ${targetBucket.bucketName}:`, testPolicy?.Statement);
continue;
}
// get our target statement, by Sid
const testStatements = testPolicy.Statement.filter(s => s.Sid === Sid);
switch (testStatements.length) {
// compare statement, if found
case 1:
try {
// clone base statement, subbing in the current bucket name
const expected = JSON.parse(JSON.stringify(baseStatement));
expected.Resource = expected.Resource.replace('__BUCKET_NAME__', targetBucket.bucketName);
// THEN compare against expected
assert.deepStrictEqual(testStatements[0], expected);
targetBucket.differs = false;
} catch (err) {
targetBucket.differs = true;
}
break;
// mark as differed, if not found
case 0:
targetBucket.differs = true;
break;
// same with extra logging, if multiple found
default:
targetBucket.differs = true;
verbose(`Policy has ${testStatements.length} duplicate statements with Sid "${Sid}"`);
}
verbose(`Policy ${targetBucket.differs ? 'differs' : 'up to date'} for bucket ${targetBucket.bucketName}`);
}
// simple test of whether ANY get/parse exceptions exist
let hasExceptions = targetBuckets.reduce(
(res, curr) => {
if (res) return res;
return !!(curr.getException || curr.parseException);
},
false
);
// bail early with a report of WHICH buckets failed and why
if (hasExceptions) {
console.log('Following buckets failed to get or parse current bucket policies:');
console.table(
targetBuckets
.filter(b => b.getException || b.parseException)
.map(b => ({
'bucket': b.bucketName,
'get': b.getException?.toString(),
'parse': b.parseException?.toString(),
})
)
);
return;
}
// bail early if all bucket policies are up to date
if (targetBuckets.filter(b => b.differs).length < 1) {
console.log('All bucket policies are up to date!');
return true;
}
// iterate buckets once more to update any differing policies
let updateSuccesses = 0;
for (let currentBucket in targetBuckets) {
const targetBucket = targetBuckets[currentBucket];
let targetPolicy = targetBucket.currentPolicy;
// skip any bucket already matching base document policy
if (!targetBucket.differs) continue;
// define a base document, if needed (cloning doc skeleton)
if (!targetPolicy) {
targetPolicy = JSON.parse(docSkeleton);
}
// define statement list, if needed
if (!targetPolicy.Statement || !Array.isArray(targetPolicy.Statement)) {
targetPolicy.Statement = [];
} else {
// otherwise remove existing statements using our Sid
targetPolicy.Statement = targetPolicy.Statement.filter(s => s.Sid !== Sid);
}
// add our updated statement
const newStatement = JSON.parse(JSON.stringify(baseStatement));
newStatement.Resource = newStatement.Resource.replace('__BUCKET_NAME__', targetBucket.bucketName);
targetPolicy.Statement.push(newStatement);
// trap and save put exceptions for later handling
try {
const putBucketPolicyCommand = new PutBucketPolicyCommand({
Bucket: targetBucket.bucketName,
ExpectedBucketOwner: awsAccountId,
Policy: JSON.stringify(targetPolicy),
});
await client[targetBucket.region].send(putBucketPolicyCommand);
updateSuccesses++;
verbose(`Policy updated successfully for bucket ${targetBucket.bucketName}`);
} catch(err) {
targetBucket.putException = err;
}
}
// report simple count of how many successful policy updates
console.log(`Bucket policies updated for ${updateSuccesses} buckets`);
// simple test of whether ANY put exceptions exist
hasExceptions = targetBuckets.reduce(
(res, curr) => {
if (res) return res;
return !!curr.putException;
},
false
);
// bail early with a report of WHICH buckets failed to update and why
if (hasExceptions) {
console.log('Following buckets failed to put updated bucket policy:');
console.table(
targetBuckets
.filter(b => b.putException)
.map(b => ({
'bucket': b.bucketName,
'put': b.putException?.toString(),
})
)
);
return;
}
// if no exceptions, then we're aces
return true;
};