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';45export default new Router()6 // Built-in Edgio routes7 .use(edgioRoutes)89 // 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 credentials2S3_HOSTNAME=edgio-docs-demo.s3.us-east-2.amazonaws.com3S3_REGION=us-east-24S3_ACCESS_KEY_ID=XXX5S3_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_config45// Load environment variables from .env file6require('dotenv').config();78module.exports = {9 // ... other configuration options ...1011 origins: [12 {13 // The name of the backend origin14 name: 's3',1516 // Use the following to override the host header sent from the browser when connecting to the origin17 override_host_header: process.env.S3_HOSTNAME,1819 // The list of origin hosts to which to connect20 hosts: [21 {22 // The domain name or IP address of the origin server23 location: process.env.S3_HOSTNAME,24 },25 ],2627 tls_verify: {28 use_sni: true,29 sni_hint_and_strict_san_check: process.env.S3_HOSTNAME,30 },3132 // Uncomment the following to configure a shield33 // 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 below23/**4 * This edge function signs an S3 request using the AWS v4 signature algorithm5 * and forwards the request to the S3 origin. Authentication credentials are6 * 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;1112 const initialUrl = new URL(request.url);1314 // Remove the /s3 prefix from the path before signing since we only15 // want to sign the path relative to the bucket.16 // For example, /s3/some-path/file.jpg becomes /some-path/file.jpg17 const s3Path = initialUrl.pathname.replace(/^\/s3/, '');18 const s3Url = new URL(s3Path, `https://${S3_HOSTNAME}`);1920 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 });2930 const signedDetails = await signer.sign();3132 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-check23import HmacSHA256 from 'crypto-js/hmac-sha256';4import SHA256 from 'crypto-js/sha256';56/**7 * @license MIT <https://opensource.org/licenses/MIT>8 * @copyright Michael Hart 20229 */1011const encoder = new TextEncoder();1213/** @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};2627// https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L19828const 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]);3940export class AwsClient {41 /**42 * @param {{43 * accessKeyId: string44 * secretAccessKey: string45 * sessionToken?: string46 * service?: string47 * region?: string48 * cache?: Map<string,ArrayBuffer>49 * retries?: number50 * initRetryMs?: number51 * }} options52 */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 secs74 this.initRetryMs = initRetryMs || 50;75 }7677 /**78 * @typedef {RequestInit & {79 * aws?: {80 * accessKeyId?: string81 * secretAccessKey?: string82 * sessionToken?: string83 * service?: string84 * region?: string85 * cache?: Map<string,ArrayBuffer>86 * datetime?: string87 * signQuery?: boolean88 * appendSessionToken?: boolean89 * allHeaders?: boolean90 * singleEncode?: boolean91 * }92 * }} AwsRequestInit93 *94 * @param {RequestInfo} input95 * @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 ? body106 : 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=1360943120 return new Request(121 signed.url.toString(),122 Object.assign({duplex: 'half'}, signed)123 );124 }125 throw e;126 }127 }128129 /**130 * @param {RequestInfo} input131 * @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 anyway139 }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}153154export class AwsV4Signer {155 /**156 * @param {{157 * method?: string158 * url: string159 * headers?: HeadersInit160 * body?: BodyInit | null161 * accessKeyId: string162 * secretAccessKey: string163 * sessionToken?: string164 * service?: string165 * region?: string166 * cache?: Map<string,ArrayBuffer>167 * datetime?: string168 * signQuery?: boolean169 * appendSessionToken?: boolean170 * allHeaders?: boolean171 * singleEncode?: boolean172 * }} options173 */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');196197 this.method = method || (body ? 'POST' : 'GET');198 this.url = new URL(url);199 this.headers = new Headers(headers || {});200 this.body = body;201202 this.accessKeyId = accessKeyId;203 this.secretAccessKey = secretAccessKey;204 this.sessionToken = sessionToken;205206 let guessedService, guessedRegion;207 if (!service || !region) {208 [guessedService, guessedRegion] = guessServiceRegion(209 this.url,210 this.headers211 );212 }213 /** @type {string} */214 this.service = service || guessedService || '';215 this.region = region || guessedRegion || 'us-east-1';216217 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';223224 this.headers.delete('Host'); // Can't be set in insecure env anyway225226 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 }233234 const params = this.signQuery ? this.url.searchParams : this.headers;235236 params.set('X-Amz-Date', this.datetime);237 if (this.sessionToken && !this.appendSessionToken) {238 params.set('X-Amz-Security-Token', this.sessionToken);239 }240241 // headers are always lowercase in keys()242 this.signableHeaders = ['host', ...this.headers.keys()]243 .filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header))244 .sort();245246 this.signedHeaders = this.signableHeaders.join(';');247248 // headers are always trimmed:249 // https://fetch.spec.whatwg.org/#concept-header-value-normalize250 this.canonicalHeaders = this.signableHeaders251 .map(252 (header) =>253 header +254 ':' +255 (header === 'host'256 ? this.url.host257 : (this.headers.get(header) || '').replace(/\s+/g, ' '))258 )259 .join('\n');260261 this.credentialString = [262 this.datetime.slice(0, 8),263 this.region,264 this.service,265 'aws4_request',266 ].join('/');267268 if (this.signQuery) {269 if (this.service === 's3' && !params.has('X-Amz-Expires')) {270 params.set('X-Amz-Expires', '86400'); // 24 hours271 }272 params.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');273 params.set(274 'X-Amz-Credential',275 this.accessKeyId + '/' + this.credentialString276 );277 params.set('X-Amz-SignedHeaders', this.signedHeaders);278 }279280 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);299300 const seenKeys = new Set();301 this.encodedSearch = [...this.url.searchParams]302 .filter(([k]) => {303 if (!k) return false; // no empty keys304 if (this.service === 's3') {305 if (seenKeys.has(k)) return false; // first val only for S3306 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 : 0313 )314 .map((pair) => pair.join('='))315 .join('&');316 }317318 /**319 * @returns {Promise<{320 * method: string321 * url: URL322 * headers: Headers323 * body?: BodyInit | null324 * }>}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 }335336 return {337 method: this.method,338 url: this.url,339 headers: this.headers,340 body: this.body,341 };342 }343344 /**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 }357358 /**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 }376377 /**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 }388389 /**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 }402403 /**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}425426/**427 * @param {string} key428 * @param {string} payload429 // * @returns {Promise<ArrayBuffer>}430 */431async function hmac(key, payload) {432 // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715433 // 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}443444/**445 * @param {string | ArrayBufferView | ArrayBuffer} content446 * @returns {Promise<ArrayBuffer>}447 */448async function hash(content) {449 // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715450 // return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content)451 return SHA256(content).toString();452}453454/**455 * @param {ArrayBuffer | ArrayLike<number> | SharedArrayBuffer} buffer456 * @returns {string}457 */458function buf2hex(buffer) {459 return Array.prototype.map460 .call(new Uint8Array(buffer), (x) => ('0' + x.toString(16)).slice(-2))461 .join('');462}463464/**465 * @param {string} urlEncodedStr466 * @returns {string}467 */468function encodeRfc3986(urlEncodedStr) {469 return urlEncodedStr.replace(470 /[!'()*]/g,471 (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()472 );473}474475/**476 * @param {URL} url477 * @param {Headers} headers478 * @returns {string[]} [service, region]479 */480function guessServiceRegion(url, headers) {481 const {hostname, pathname} = url;482483 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 = hostname491 .replace('dualstack.', '')492 .match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com(?:\.cn)?$/);493 let [service, region] = (match || ['', '']).slice(1, 3);494495 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 }523524 return [HOST_SERVICES[service] || service, region];525}