Skip to content

Instantly share code, notes, and snippets.

@mstoykov
Last active February 26, 2022 06:33
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mstoykov/38cc1293daa9080b11e26053589a6865 to your computer and use it in GitHub Desktop.
Save mstoykov/38cc1293daa9080b11e26053589a6865 to your computer and use it in GitHub Desktop.
k6 compatible awsv4 library
/* 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);
}
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,
});
}
@mstoykov
Copy link
Author

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 and
AWS_SECRET_ACCESS_KEY in order to authenticate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment