Last active
February 26, 2022 06:33
-
-
Save mstoykov/38cc1293daa9080b11e26053589a6865 to your computer and use it in GitHub Desktop.
k6 compatible awsv4 library
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-__ENV node */ | |
/* eslint no-use-before-define: [0, "nofunc"] */ | |
"use strict"; | |
// sources of inspiration: | |
// https://web-identity-federation-playground.s3.amazonaws.com/js/sigv4.js | |
// http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html | |
var crypto = require("k6/crypto"); | |
function createCanonicalRequest( | |
method, | |
pathname, | |
query, | |
headers, | |
payload, | |
doubleEscape | |
) { | |
return [ | |
method.toUpperCase(), | |
createCanonicalURI( | |
doubleEscape | |
? pathname | |
.split(/\//g) | |
.map(v => encodeURIComponent(v)) | |
.join("/") | |
: pathname | |
), | |
createCanonicalQueryString(query), | |
createCanonicalHeaders(headers), | |
createSignedHeaders(headers), | |
createCanonicalPayload(payload) | |
].join("\n"); | |
}; | |
exports.createCanonicalRequest = createCanonicalRequest; | |
function createCanonicalURI(uri) { | |
var url = uri; | |
if (uri[uri.length - 1] == "/" && url[url.length - 1] != "/") { | |
url += "/"; | |
} | |
return url; | |
} | |
function queryParse(qs) { | |
if (typeof qs !== 'string' || qs.length === 0) { | |
return {}; | |
} | |
var result = {}; | |
var split = qs.split("&"); | |
for (let i = 0; i< split.length; i++) { | |
let parts = split[i].split("="); | |
if (parts.length === 2) { | |
result[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]); | |
} else { | |
result[decodeURIComponent(split[i])] = ""; | |
} | |
} | |
return result; | |
} | |
function createCanonicalPayload(payload) { | |
if (payload == "UNSIGNED-PAYLOAD") { | |
return payload; | |
} | |
return hash(payload || "", "hex"); | |
} | |
function createCanonicalQueryString (params) { | |
if (!params) { | |
return ""; | |
} | |
if (typeof params == "string") { | |
params = queryParse(params); | |
} | |
return Object.keys(params) | |
.sort() | |
.map(function(key) { | |
var values = Array.isArray(params[key]) ? params[key] : [params[key]]; | |
return values | |
.sort() | |
.map(function(val) { | |
return encodeURIComponent(key) + "=" + encodeURIComponent(val); | |
}) | |
.join("&"); | |
}) | |
.join("&"); | |
}; | |
createCanonicalQueryString = createCanonicalQueryString; | |
function createCanonicalHeaders(headers) { | |
return Object.keys(headers) | |
.sort() | |
.map(function(name) { | |
var values = Array.isArray(headers[name]) | |
? headers[name] | |
: [headers[name]]; | |
return ( | |
name.toLowerCase().trim() + | |
":" + | |
values | |
.map(function(v) { | |
return v.replace(/\s+/g, " ").replace(/^\s+|\s+$/g, ""); | |
}) | |
.join(",") + | |
"\n" | |
); | |
}) | |
.join(""); | |
}; | |
exports.createCanonicalHeaders = createCanonicalHeaders; | |
function createSignedHeaders(headers) { | |
return Object.keys(headers) | |
.sort() | |
.map(function(name) { | |
return name.toLowerCase().trim(); | |
}) | |
.join(";"); | |
}; | |
exports.createSignedHeaders = createSignedHeaders; | |
function createCredentialScope(time, region, service) { | |
return [toDate(time), region, service, "aws4_request"].join("/"); | |
}; | |
exports.createCredentialScope = createCredentialScope; | |
function createStringToSign(time, region, service, request) { | |
return [ | |
"AWS4-HMAC-SHA256", | |
toTime(time), | |
createCredentialScope(time, region, service), | |
hash(request, "hex") | |
].join("\n"); | |
}; | |
exports.createStringToSign = createStringToSign; | |
function createAuthorizationHeader( | |
key, | |
scope, | |
signedHeaders, | |
signature | |
) { | |
return [ | |
"AWS4-HMAC-SHA256 Credential=" + key + "/" + scope, | |
"SignedHeaders=" + signedHeaders, | |
"Signature=" + signature | |
].join(", "); | |
}; | |
exports.createAuthorizationHeader = createAuthorizationHeader; | |
function createSignature( | |
secret, | |
time, | |
region, | |
service, | |
stringToSign | |
) { | |
var h1 = hmac("AWS4" + secret, toDate(time), "binary"); // date-key | |
var h2 = hmac(h1, region, "binary"); // region-key | |
var h3 = hmac(h2, service, "binary"); // service-key | |
var h4 = hmac(h3, "aws4_request", "binary"); // signing-key | |
return hmac(h4, stringToSign, "hex"); | |
}; | |
exports.createSignature = createSignature; | |
function createPresignedS3URL(name, options) { | |
options = options || {}; | |
options.method = options.method || "GET"; | |
options.bucket = options.bucket || __ENV.AWS_S3_BUCKET; | |
options.signSessionToken = true; | |
options.doubleEscape = false; | |
return createPresignedURL( | |
options.method, | |
options.bucket + ".s3.amazonaws.com", | |
"/" + name, | |
"s3", | |
"UNSIGNED-PAYLOAD", | |
options | |
); | |
}; | |
exports.createPresignedS3URL = createPresignedS3URL; | |
function createPresignedURL( | |
method, | |
host, | |
path, | |
service, | |
payload, | |
options | |
) { | |
options = options || {}; | |
options.key = options.key || __ENV.AWS_ACCESS_KEY_ID; | |
options.secret = options.secret || __ENV.AWS_SECRET_ACCESS_KEY; | |
options.sessionToken = options.sessionToken || __ENV.AWS_SESSION_TOKEN; | |
options.protocol = options.protocol || "https"; | |
options.timestamp = options.timestamp || Date.now(); | |
options.region = options.region || __ENV.AWS_REGION || "us-east-1"; | |
options.expires = options.expires || 86400; // 24 hours | |
options.headers = options.headers || {}; | |
options.signSessionToken = options.signSessionToken || false; | |
options.doubleEscape = | |
options.doubleEscape !== undefined ? options.doubleEscape : true; | |
// host is required | |
options.headers.Host = host; | |
var query = options.query ? queryParse(options.query) : {}; | |
query["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256"; | |
query["X-Amz-Credential"] = | |
options.key + | |
"/" + | |
createCredentialScope(options.timestamp, options.region, service); | |
query["X-Amz-Date"] = toTime(options.timestamp); | |
query["X-Amz-Expires"] = options.expires; | |
query["X-Amz-SignedHeaders"] = createSignedHeaders(options.headers); | |
// when a session token must be "signed" into the canonical request | |
// (needed for some services, such as s3) | |
if (options.sessionToken && options.signSessionToken) { | |
query["X-Amz-Security-Token"] = options.sessionToken; | |
} | |
var canonicalRequest = createCanonicalRequest( | |
method, | |
path, | |
query, | |
options.headers, | |
payload, | |
options.doubleEscape | |
); | |
var stringToSign = createStringToSign( | |
options.timestamp, | |
options.region, | |
service, | |
canonicalRequest | |
); | |
var signature = createSignature( | |
options.secret, | |
options.timestamp, | |
options.region, | |
service, | |
stringToSign | |
); | |
query["X-Amz-Signature"] = signature; | |
// when a session token must NOT be "signed" into the canonical request | |
// (needed for some services, such as IoT) | |
if (options.sessionToken && !options.signSessionToken) { | |
query["X-Amz-Security-Token"] = options.sessionToken; | |
} else { | |
delete query["X-Amz-Security-Token"]; | |
} | |
return ( | |
options.protocol + "://" + host + path + "?" + createCanonicalQueryString(query) | |
); | |
}; | |
exports.createPresignedURL = createPresignedURL; | |
function toTime(time) { | |
return new Date(time).toISOString().replace(/[:\-]|\.\d{3}/g, ""); | |
} | |
function toDate(time) { | |
return toTime(time).substring(0, 8); | |
} | |
function hmac(key, data, encoding) { | |
return crypto.hmac("sha256", key, data, encoding); | |
} | |
function hash(string, encoding) { | |
return crypto.sha256(string, encoding); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import html from "k6/html"; | |
import crypto from "k6/crypto"; | |
import http from "k6/http"; | |
import { check } from "k6"; | |
import v4 from "./awsv4.js"; | |
var defaultBucket = "testing-awsv4"; | |
var defaultRegion = "eu-central-1" | |
export function setup() { | |
createS3Bucket( | |
{ | |
bucket: defaultBucket, | |
region: defaultRegion, | |
}); | |
} | |
export function teardown(data) { | |
deleteS3Bucket( | |
{ | |
bucket: defaultBucket, | |
region: defaultRegion, | |
}); | |
} | |
function createS3Bucket(params) { | |
// you always create the bucket in eu-west-1 | |
var createUrl = v4.createPresignedS3URL("", | |
Object.assign({} , params, {method: "PUT"})); | |
var region = params.region || __ENV.AWS_REGION || "us-east-1"; | |
var createBody = ` | |
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> | |
<LocationConstraint>`+region+`</LocationConstraint> | |
</CreateBucketConfiguration > `; | |
console.log("s3 bucket create url ", createUrl); | |
console.log("s3 bucket create body ", createBody); | |
var res = http.put(createUrl, createBody); | |
check(res, { | |
"create s3 bucket returned 200": (r) => r.status == 200, | |
}); | |
console.log(res.body); | |
} | |
function deleteS3Objects(objects, params) { | |
var deleteBody = `<?xml version="1.0" encoding="UTF-8"?> | |
<Delete> | |
<Quiet>true</Quiet>\n`; | |
for (let i = 0; i < objects.length; i++) { | |
deleteBody += `<Object><Key>` + objects[i] + `</Key></Object>\n` | |
} | |
deleteBody += "</Delete>\n" | |
var bodyMD5 = crypto.md5(deleteBody, "base64"); | |
var headers = {"Content-MD5": bodyMD5}; | |
var deleteUrl = v4.createPresignedS3URL("", | |
Object.assign({} , params, {method: "POST", query: "delete", headers: headers})); | |
console.log("multi object delete url ", deleteUrl); | |
var res = http.post(deleteUrl, deleteBody, {"headers": headers}); | |
console.log("multi object delete body ", deleteBody); | |
check(res, | |
{"multi delete did not return 200": (r) => r.status == 200}); | |
} | |
function listS3Objects(params) { | |
var listURL = v4.createPresignedS3URL("", | |
Object.assign({} , params, {method: "GET", query: "list-type=2"})); | |
console.log("s3 bucket list url ", listURL); | |
var res = http.get(listURL); | |
check(res, { | |
"list s3 bucket returned 200": (r) => r.status == 200, | |
}); | |
var doc = html.parseHTML(res.body); | |
var result = []; | |
doc.find("Contents Key").each(function(idx, el) { | |
result.push(el.innerHTML()); | |
}) | |
console.log(JSON.stringify(result)); | |
return result; | |
} | |
function deleteS3Bucket(params) { | |
while(true) { | |
var objects = listS3Objects(params); | |
if (objects.length == 0) { | |
break | |
} | |
deleteS3Objects(objects, params) | |
break; | |
} | |
var deleteUrl = v4.createPresignedS3URL("", | |
Object.assign({} , params, {method: "DELETE"})); | |
console.log("s3 bucket delete url ", deleteUrl); | |
var res = http.del(deleteUrl); | |
check(res, { | |
"delete s3 bucket returned 204": (r) => r.status == 204, | |
}); | |
} | |
function uploadFile(path, content, params) { | |
var url = v4.createPresignedS3URL( | |
path, | |
Object.assign({}, params, | |
{ | |
bucket: defaultBucket, | |
region: defaultRegion, | |
method: "PUT", | |
})); | |
console.log("uploadFile: "+ url); | |
return http.put(url, content, {redirects: 20}); | |
} | |
function changeFileACL(path, body, params) { | |
var url = v4.createPresignedS3URL( | |
path, | |
Object.assign({}, params, | |
{ | |
bucket: defaultBucket, | |
region: defaultRegion, | |
method: "PUT", | |
query: "acl", | |
})); | |
console.log("changeFileACL: "+ url); | |
var headers = params["headers"] || {}; | |
return http.put(url, body, {headers: headers}); | |
} | |
function getFile(path, params) { | |
var url = v4.createPresignedS3URL( | |
path, | |
Object.assign({}, params, | |
{ | |
bucket: defaultBucket, | |
region: defaultRegion, | |
method: "GET", | |
})); | |
console.log("getFile: "+ url); | |
return http.get(url); | |
} | |
function publicGetFile(path, bucket) { | |
var url = "https://" + bucket + ".s3.amazonaws.com/" + path; | |
console.log("publicGetFile: "+ url); | |
return http.get(url); | |
} | |
export default function(data) { | |
console.log(JSON.stringify(v4)); | |
var random = Math.floor(Math.random() * 100); | |
var expectedBody = "a".repeat(random); | |
console.log(expectedBody); | |
var name = "randomPath" + random + "__" + __VU + "__"+ __ITER; | |
var res = uploadFile(name, expectedBody); | |
check(res, { | |
"status code of upload is 200": (r) => r.status == 200, | |
}); | |
console.log("upload response "+ res.body); | |
res = getFile(name); | |
check(res, { | |
"status code get is 200": (r) => r.status == 200, | |
"body is correct": (r) => r.body == expectedBody, | |
}); | |
res = publicGetFile(name, defaultBucket); | |
check(res, { | |
"status code public get is 403 by default": (r) => r.status == 403, | |
"body of public get is wrong by default": (r) => r.body != expectedBody, | |
}); | |
res = changeFileACL(name, null, {headers: {"x-amz-acl": "public-read"}}); | |
check(res, { | |
"status code acl change is 200": (r) => r.status == 200, | |
}); | |
res = publicGetFile(name, defaultBucket); | |
check(res, { | |
"status code public get is 200 when public": (r) => r.status == 200, | |
"body of public get is correct public": (r) => r.body == expectedBody, | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This aws demo does the following:
Create s3 bucket .. (in Europe) in setup
Each iteration creates a somewhat randomly named object
Tries to read it publicly and checks it fails
Changes the ACL to be public
Tries to read it publicly and checks it succeeds
In teardown the whole bucket is emptied and than deleted
For it to work there will need to be environmental variables
AWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
in order to authenticate.