Edgio

AWS S3 Request Signing

You may only deploy this edge function using CDN-as-Code due to Node.js dependencies. It will not work when deployed through the Edgio Console.
Sign AWS S3 requests using the AWS Signature Version 4 algorithm. This process involves calculating a signature using the request’s elements and your AWS access keys. This signature ensures that AWS can verify the request as being sent by an authenticated source, enhancing security when accessing AWS S3. Integrating this with Edge Functions allows for efficient and secure fetching of assets from S3 buckets.

Dependencies

This guide references the following dependencies that will need to be installed in your project:
Bash
1npm install crypto-js aws4fetch

Router Configuration

In the Edgio router, you can use the edge_function feature to specify the path to the edge function that will handle requests to assets in the S3 bucket.
JavaScriptroutes.js
1// This file was added by edgio init.
2// You should commit this file to source control.
3import {Router, edgioRoutes} from '@edgio/core';
4
5export default new Router()
6 // Built-in Edgio routes
7 .use(edgioRoutes)
8
9 // Specifies the edge function for /s3/* paths. Modify the path as needed.
10 .get('/s3/:anything*', {
11 edge_function: './edge-functions/main.js',
12 });

Credentials and Origin Configuration

AWS request signing requires the following credentials that need to be defined in your local .env file and within the Edgio Console for the environment you are deploying to. This information can be obtained from your AWS account.
Bash.env
1# AWS S3 credentials
2S3_HOSTNAME=edgio-docs-demo.s3.us-east-2.amazonaws.com
3S3_REGION=us-east-2
4S3_ACCESS_KEY_ID=XXX
5S3_SECRET_ACCESS_KEY=YYY
Define your S3 origin configuration in the edgio.config.js file:
JavaScriptedgio.config.js
1// This file was automatically added by edgio init.
2// You should commit this file to source control.
3// Learn more about this file at https://docs.edg.io/applications/edgio_config
4
5// Load environment variables from .env file
6require('dotenv').config();
7
8module.exports = {
9 // ... other configuration options ...
10
11 origins: [
12 {
13 // The name of the backend origin
14 name: 's3',
15
16 // Use the following to override the host header sent from the browser when connecting to the origin
17 override_host_header: process.env.S3_HOSTNAME,
18
19 // The list of origin hosts to which to connect
20 hosts: [
21 {
22 // The domain name or IP address of the origin server
23 location: process.env.S3_HOSTNAME,
24 },
25 ],
26
27 tls_verify: {
28 use_sni: true,
29 sni_hint_and_strict_san_check: process.env.S3_HOSTNAME,
30 },
31
32 // Uncomment the following to configure a shield
33 // shields: { us_east: 'DCD' },
34 },
35 ],
36};

Edge Function

The edge function will sign the incoming request using the AwsV4Signer class, and then forward the request to the S3 bucket. The AwsV4Signer class is a third-party library that handles the signing process and is included in the edge-functions directory of this example.
JavaScriptedge-functions/main.js
1import {AwsV4Signer} from './awsv4'; // See the awsv4.js file below
2
3/**
4 * This edge function signs an S3 request using the AWS v4 signature algorithm
5 * and forwards the request to the S3 origin. Authentication credentials are
6 * read from environment variables set in the Edgio Developer Console.
7 */
8export async function handleHttpRequest(request, context) {
9 const {S3_HOSTNAME, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY} =
10 context.environmentVars;
11
12 const initialUrl = new URL(request.url);
13
14 // Remove the /s3 prefix from the path before signing since we only
15 // want to sign the path relative to the bucket.
16 // For example, /s3/some-path/file.jpg becomes /some-path/file.jpg
17 const s3Path = initialUrl.pathname.replace(/^\/s3/, '');
18 const s3Url = new URL(s3Path, `https://${S3_HOSTNAME}`);
19
20 const signer = new AwsV4Signer({
21 url: s3Url.href,
22 method: request.method,
23 region: S3_REGION,
24 service: 's3',
25 accessKeyId: S3_ACCESS_KEY_ID,
26 secretAccessKey: S3_SECRET_ACCESS_KEY,
27 signQuery: true,
28 });
29
30 const signedDetails = await signer.sign();
31
32 return fetch(signedDetails.url, {
33 method: signedDetails.method,
34 headers: signedDetails.headers,
35 edgio: {
36 origin: 's3',
37 },
38 });
39}
The awsv4.js file contains the AwsV4Signer class that handles the signing process. This class is used in the edge function to sign the incoming request.
JavaScriptedge-functions/awsv4.js
1// @ts-check
2
3import HmacSHA256 from 'crypto-js/hmac-sha256';
4import SHA256 from 'crypto-js/sha256';
5
6/**
7 * @license MIT <https://opensource.org/licenses/MIT>
8 * @copyright Michael Hart 2022
9 */
10
11const encoder = new TextEncoder();
12
13/** @type {Object.<string, string>} */
14const HOST_SERVICES = {
15 appstream2: 'appstream',
16 cloudhsmv2: 'cloudhsm',
17 email: 'ses',
18 marketplace: 'aws-marketplace',
19 mobile: 'AWSMobileHubService',
20 pinpoint: 'mobiletargeting',
21 queue: 'sqs',
22 'git-codecommit': 'codecommit',
23 'mturk-requester-sandbox': 'mturk-requester',
24 'personalize-runtime': 'personalize',
25};
26
27// https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L198
28const UNSIGNABLE_HEADERS = new Set([
29 'authorization',
30 'content-type',
31 'content-length',
32 'user-agent',
33 'presigned-expires',
34 'expect',
35 'x-amzn-trace-id',
36 'range',
37 'connection',
38]);
39
40export class AwsClient {
41 /**
42 * @param {{
43 * accessKeyId: string
44 * secretAccessKey: string
45 * sessionToken?: string
46 * service?: string
47 * region?: string
48 * cache?: Map<string,ArrayBuffer>
49 * retries?: number
50 * initRetryMs?: number
51 * }} options
52 */
53 constructor({
54 accessKeyId,
55 secretAccessKey,
56 sessionToken,
57 service,
58 region,
59 cache,
60 retries,
61 initRetryMs,
62 }) {
63 if (accessKeyId == null)
64 throw new TypeError('accessKeyId is a required option');
65 if (secretAccessKey == null)
66 throw new TypeError('secretAccessKey is a required option');
67 this.accessKeyId = accessKeyId;
68 this.secretAccessKey = secretAccessKey;
69 this.sessionToken = sessionToken;
70 this.service = service;
71 this.region = region;
72 this.cache = cache || new Map();
73 this.retries = retries != null ? retries : 10; // Up to 25.6 secs
74 this.initRetryMs = initRetryMs || 50;
75 }
76
77 /**
78 * @typedef {RequestInit & {
79 * aws?: {
80 * accessKeyId?: string
81 * secretAccessKey?: string
82 * sessionToken?: string
83 * service?: string
84 * region?: string
85 * cache?: Map<string,ArrayBuffer>
86 * datetime?: string
87 * signQuery?: boolean
88 * appendSessionToken?: boolean
89 * allHeaders?: boolean
90 * singleEncode?: boolean
91 * }
92 * }} AwsRequestInit
93 *
94 * @param {RequestInfo} input
95 * @param {?AwsRequestInit} [init]
96 * @returns {Promise<Request>}
97 */
98 async sign(input, init) {
99 if (input instanceof Request) {
100 const {method, url, headers, body} = input;
101 init = Object.assign({method, url, headers}, init);
102 if (init.body == null && headers.has('Content-Type')) {
103 init.body =
104 body != null && headers.has('X-Amz-Content-Sha256')
105 ? body
106 : await input.clone().arrayBuffer();
107 }
108 input = url;
109 }
110 const signer = new AwsV4Signer(
111 Object.assign({url: input}, init, this, init && init.aws)
112 );
113 const signed = Object.assign({}, init, await signer.sign());
114 delete signed.aws;
115 try {
116 return new Request(signed.url.toString(), signed);
117 } catch (e) {
118 if (e instanceof TypeError) {
119 // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
120 return new Request(
121 signed.url.toString(),
122 Object.assign({duplex: 'half'}, signed)
123 );
124 }
125 throw e;
126 }
127 }
128
129 /**
130 * @param {RequestInfo} input
131 * @param {?AwsRequestInit} [init]
132 * @returns {Promise<Response>}
133 */
134 async fetch(input, init) {
135 for (let i = 0; i <= this.retries; i++) {
136 const fetched = fetch(await this.sign(input, init));
137 if (i === this.retries) {
138 return fetched; // No need to await if we're returning anyway
139 }
140 const res = await fetched;
141 if (res.status < 500 && res.status !== 429) {
142 return res;
143 }
144 await new Promise((resolve) =>
145 setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i))
146 );
147 }
148 throw new Error(
149 'An unknown error occurred, ensure retries is not negative'
150 );
151 }
152}
153
154export class AwsV4Signer {
155 /**
156 * @param {{
157 * method?: string
158 * url: string
159 * headers?: HeadersInit
160 * body?: BodyInit | null
161 * accessKeyId: string
162 * secretAccessKey: string
163 * sessionToken?: string
164 * service?: string
165 * region?: string
166 * cache?: Map<string,ArrayBuffer>
167 * datetime?: string
168 * signQuery?: boolean
169 * appendSessionToken?: boolean
170 * allHeaders?: boolean
171 * singleEncode?: boolean
172 * }} options
173 */
174 constructor({
175 method,
176 url,
177 headers,
178 body,
179 accessKeyId,
180 secretAccessKey,
181 sessionToken,
182 service,
183 region,
184 cache,
185 datetime,
186 signQuery,
187 appendSessionToken,
188 allHeaders,
189 singleEncode,
190 }) {
191 if (url == null) throw new TypeError('url is a required option');
192 if (accessKeyId == null)
193 throw new TypeError('accessKeyId is a required option');
194 if (secretAccessKey == null)
195 throw new TypeError('secretAccessKey is a required option');
196
197 this.method = method || (body ? 'POST' : 'GET');
198 this.url = new URL(url);
199 this.headers = new Headers(headers || {});
200 this.body = body;
201
202 this.accessKeyId = accessKeyId;
203 this.secretAccessKey = secretAccessKey;
204 this.sessionToken = sessionToken;
205
206 let guessedService, guessedRegion;
207 if (!service || !region) {
208 [guessedService, guessedRegion] = guessServiceRegion(
209 this.url,
210 this.headers
211 );
212 }
213 /** @type {string} */
214 this.service = service || guessedService || '';
215 this.region = region || guessedRegion || 'us-east-1';
216
217 this.cache = cache || new Map();
218 this.datetime =
219 datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
220 this.signQuery = signQuery;
221 this.appendSessionToken =
222 appendSessionToken || this.service === 'iotdevicegateway';
223
224 this.headers.delete('Host'); // Can't be set in insecure env anyway
225
226 if (
227 this.service === 's3' &&
228 !this.signQuery &&
229 !this.headers.has('X-Amz-Content-Sha256')
230 ) {
231 this.headers.set('X-Amz-Content-Sha256', 'UNSIGNED-PAYLOAD');
232 }
233
234 const params = this.signQuery ? this.url.searchParams : this.headers;
235
236 params.set('X-Amz-Date', this.datetime);
237 if (this.sessionToken && !this.appendSessionToken) {
238 params.set('X-Amz-Security-Token', this.sessionToken);
239 }
240
241 // headers are always lowercase in keys()
242 this.signableHeaders = ['host', ...this.headers.keys()]
243 .filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header))
244 .sort();
245
246 this.signedHeaders = this.signableHeaders.join(';');
247
248 // headers are always trimmed:
249 // https://fetch.spec.whatwg.org/#concept-header-value-normalize
250 this.canonicalHeaders = this.signableHeaders
251 .map(
252 (header) =>
253 header +
254 ':' +
255 (header === 'host'
256 ? this.url.host
257 : (this.headers.get(header) || '').replace(/\s+/g, ' '))
258 )
259 .join('\n');
260
261 this.credentialString = [
262 this.datetime.slice(0, 8),
263 this.region,
264 this.service,
265 'aws4_request',
266 ].join('/');
267
268 if (this.signQuery) {
269 if (this.service === 's3' && !params.has('X-Amz-Expires')) {
270 params.set('X-Amz-Expires', '86400'); // 24 hours
271 }
272 params.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
273 params.set(
274 'X-Amz-Credential',
275 this.accessKeyId + '/' + this.credentialString
276 );
277 params.set('X-Amz-SignedHeaders', this.signedHeaders);
278 }
279
280 if (this.service === 's3') {
281 try {
282 /** @type {string} */
283 this.encodedPath = decodeURIComponent(
284 this.url.pathname.replace(/\+/g, ' ')
285 );
286 } catch (e) {
287 this.encodedPath = this.url.pathname;
288 }
289 } else {
290 this.encodedPath = this.url.pathname.replace(/\/+/g, '/');
291 }
292 if (!singleEncode) {
293 this.encodedPath = encodeURIComponent(this.encodedPath).replace(
294 /%2F/g,
295 '/'
296 );
297 }
298 this.encodedPath = encodeRfc3986(this.encodedPath);
299
300 const seenKeys = new Set();
301 this.encodedSearch = [...this.url.searchParams]
302 .filter(([k]) => {
303 if (!k) return false; // no empty keys
304 if (this.service === 's3') {
305 if (seenKeys.has(k)) return false; // first val only for S3
306 seenKeys.add(k);
307 }
308 return true;
309 })
310 .map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p))))
311 .sort(([k1, v1], [k2, v2]) =>
312 k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0
313 )
314 .map((pair) => pair.join('='))
315 .join('&');
316 }
317
318 /**
319 * @returns {Promise<{
320 * method: string
321 * url: URL
322 * headers: Headers
323 * body?: BodyInit | null
324 * }>}
325 */
326 async sign() {
327 if (this.signQuery) {
328 this.url.searchParams.set('X-Amz-Signature', await this.signature());
329 if (this.sessionToken && this.appendSessionToken) {
330 this.url.searchParams.set('X-Amz-Security-Token', this.sessionToken);
331 }
332 } else {
333 this.headers.set('Authorization', await this.authHeader());
334 }
335
336 return {
337 method: this.method,
338 url: this.url,
339 headers: this.headers,
340 body: this.body,
341 };
342 }
343
344 /**
345 * @returns {Promise<string>}
346 */
347 async authHeader() {
348 return [
349 'AWS4-HMAC-SHA256 Credential=' +
350 this.accessKeyId +
351 '/' +
352 this.credentialString,
353 'SignedHeaders=' + this.signedHeaders,
354 'Signature=' + (await this.signature()),
355 ].join(', ');
356 }
357
358 /**
359 * @returns {Promise<string>}
360 */
361 async signature() {
362 const date = this.datetime.slice(0, 8);
363 const cacheKey = [
364 this.secretAccessKey,
365 date,
366 this.region,
367 this.service,
368 ].join();
369 // let kCredentials = this.cache.get(cacheKey)
370 const kDate = await hmac('AWS4' + this.secretAccessKey, date);
371 const kRegion = await hmac(kDate, this.region);
372 const kService = await hmac(kRegion, this.service);
373 const kCredentials = await hmac(kService, 'aws4_request');
374 return (await hmac(kCredentials, await this.stringToSign())).toString();
375 }
376
377 /**
378 * @returns {Promise<string>}
379 */
380 async stringToSign() {
381 return [
382 'AWS4-HMAC-SHA256',
383 this.datetime,
384 this.credentialString,
385 (await hash(await this.canonicalString())).toString(),
386 ].join('\n');
387 }
388
389 /**
390 * @returns {Promise<string>}
391 */
392 async canonicalString() {
393 return [
394 this.method.toUpperCase(),
395 this.encodedPath,
396 this.encodedSearch,
397 this.canonicalHeaders + '\n',
398 this.signedHeaders,
399 await this.hexBodyHash(),
400 ].join('\n');
401 }
402
403 /**
404 * @returns {Promise<string>}
405 */
406 async hexBodyHash() {
407 let hashHeader =
408 this.headers.get('X-Amz-Content-Sha256') ||
409 (this.service === 's3' && this.signQuery ? 'UNSIGNED-PAYLOAD' : null);
410 if (hashHeader == null) {
411 if (
412 this.body &&
413 typeof this.body !== 'string' &&
414 !('byteLength' in this.body)
415 ) {
416 throw new Error(
417 'body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header'
418 );
419 }
420 hashHeader = (await hash(this.body || '')).toString();
421 }
422 return hashHeader;
423 }
424}
425
426/**
427 * @param {string} key
428 * @param {string} payload
429 // * @returns {Promise<ArrayBuffer>}
430 */
431async function hmac(key, payload) {
432 // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715
433 // const cryptoKey = await crypto.subtle.importKey(
434 // 'raw',
435 // typeof key === 'string' ? encoder.encode(key) : key,
436 // { name: 'HMAC', hash: { name: 'SHA-256' } },
437 // false,
438 // ['sign'],
439 // )
440 // return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string))
441 return HmacSHA256(payload, key);
442}
443
444/**
445 * @param {string | ArrayBufferView | ArrayBuffer} content
446 * @returns {Promise<ArrayBuffer>}
447 */
448async function hash(content) {
449 // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715
450 // return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content)
451 return SHA256(content).toString();
452}
453
454/**
455 * @param {ArrayBuffer | ArrayLike<number> | SharedArrayBuffer} buffer
456 * @returns {string}
457 */
458function buf2hex(buffer) {
459 return Array.prototype.map
460 .call(new Uint8Array(buffer), (x) => ('0' + x.toString(16)).slice(-2))
461 .join('');
462}
463
464/**
465 * @param {string} urlEncodedStr
466 * @returns {string}
467 */
468function encodeRfc3986(urlEncodedStr) {
469 return urlEncodedStr.replace(
470 /[!'()*]/g,
471 (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()
472 );
473}
474
475/**
476 * @param {URL} url
477 * @param {Headers} headers
478 * @returns {string[]} [service, region]
479 */
480function guessServiceRegion(url, headers) {
481 const {hostname, pathname} = url;
482
483 if (hostname.endsWith('.r2.cloudflarestorage.com')) {
484 return ['s3', 'auto'];
485 }
486 if (hostname.endsWith('.backblazeb2.com')) {
487 const match = hostname.match(/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/);
488 return match != null ? ['s3', match[1]] : ['', ''];
489 }
490 const match = hostname
491 .replace('dualstack.', '')
492 .match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com(?:\.cn)?$/);
493 let [service, region] = (match || ['', '']).slice(1, 3);
494
495 if (region === 'us-gov') {
496 region = 'us-gov-west-1';
497 } else if (region === 's3' || region === 's3-accelerate') {
498 region = 'us-east-1';
499 service = 's3';
500 } else if (service === 'iot') {
501 if (hostname.startsWith('iot.')) {
502 service = 'execute-api';
503 } else if (hostname.startsWith('data.jobs.iot.')) {
504 service = 'iot-jobs-data';
505 } else {
506 service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata';
507 }
508 } else if (service === 'autoscaling') {
509 const targetPrefix = (headers.get('X-Amz-Target') || '').split('.')[0];
510 if (targetPrefix === 'AnyScaleFrontendService') {
511 service = 'application-autoscaling';
512 } else if (targetPrefix === 'AnyScaleScalingPlannerFrontendService') {
513 service = 'autoscaling-plans';
514 }
515 } else if (region == null && service.startsWith('s3-')) {
516 region = service.slice(3).replace(/^fips-|^external-1/, '');
517 service = 's3';
518 } else if (service.endsWith('-fips')) {
519 service = service.slice(0, -5);
520 } else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
521 [service, region] = [region, service];
522 }
523
524 return [HOST_SERVICES[service] || service, region];
525}