hx-ext-amz-content-sha256 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License
2
+
3
+ Copyright (c) 2023, Alexander Petros
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ ## HTMX Extension: amz-content-sha256
2
+
3
+ ### Overview
4
+
5
+ `amz-content-sha256` is a htmx extension that adds an additional header to POST and PUT requests made from a form. This header is called `x-amz-content-sha256` and it contains a SHA-256 hash of the form data. This extension is particularly useful for interacting with AWS services that require the content hash as part of the request for data integrity verification.
6
+
7
+ The extension computes the SHA-256 hash of the form data and attaches it to the request header, ensuring that the data sent is not tampered with in transit. This is especially important for certain AWS services that use this hash to validate the integrity of the data sent to APIs, such as AWS Lambda behind CloudFront.
8
+
9
+ ### Why Do You Need This?
10
+
11
+ Some AWS services, like Lambda functions behind CloudFront, require clients to send a SHA-256 hash of the request data (e.g., form data) to ensure the integrity of the request. Without this hash, AWS might reject your requests, resulting in errors such as:
12
+
13
+ _Error Example_:
14
+
15
+ ```json
16
+ {
17
+ "message": "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details."
18
+ }
19
+ ```
20
+
21
+ This issue can occur when using Lambda Function URLs behind CloudFront, as explained in this [Aws Community Post](https://repost.aws/questions/QUbHCI9AfyRdaUPCCo_3XKMQ/lambda-function-url-behind-cloudfront-invalidsignatureexception-only-on-post). See the [AWS Docs](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-lambda.html) that confirm that behaviour.
22
+
23
+ ### How Does It Work?
24
+
25
+ 1. Form Submission: When a form is submitted using a POST or PUT request, the extension calculates the SHA-256 hash of the form data.
26
+ 2. Header Addition: The calculated hash is added to the request as the header x-amz-content-sha256.
27
+ 3. Request Integrity: This header ensures that the data sent is valid and hasn't been altered, which is required for services such as Lambda behind CloudFront.
28
+
29
+ ### Installation
30
+
31
+ 1. Include the extension in your HTML page headers.
32
+ 2. Add the tag hx-ext="amz-content-sha256" to your desired form or even the document body if you want this behaviour for all post and put requests.
33
+ 3. Add a form element that will trigger a POST or PUT request.
34
+ 4. When the form is submitted, the extension will automatically compute the SHA-256 hash and add the x-amz-content-sha256 header to the request.
35
+
36
+ _Example_:
37
+
38
+ ```html
39
+ <form hx-post="/your-api-endpoint" hx-ext="amz-content-sha256">
40
+ <input type="text" name="example" value="test" />
41
+ <button type="submit">Submit</button>
42
+ </form>
43
+ ```
44
+
45
+ `
@@ -0,0 +1,84 @@
1
+ htmx.defineExtension("amz-content-sha256", {
2
+ init: function () {
3
+ /**
4
+ * The `init` method is called when the extension is initialized.
5
+ * It sets up an object `RequestHashQueue` that will store SHA-256 hashes for each POST/PUT request,
6
+ * categorized by the request path.
7
+ * It also defines an asynchronous function `calculateSHA256` that computes the SHA-256 hash required by AWS
8
+ * for data integrity verification.
9
+ */
10
+ this.RequestHashQueue = {}; // Queue to store hashes for each request path.
11
+
12
+ this.calculateSHA256 = async (data) => {
13
+ // Convert the data into a byte array using TextEncoder
14
+ const encoder = new TextEncoder();
15
+ const dataBuffer = encoder.encode(data);
16
+
17
+ // Calculate the SHA-256 hash of the data
18
+ const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer);
19
+
20
+ // Convert the hash buffer into a hexadecimal string
21
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
22
+ const hashHex = hashArray
23
+ .map((byte) => byte.toString(16).padStart(2, "0"))
24
+ .join("");
25
+
26
+ return hashHex; // Return the hex representation of the SHA-256 hash
27
+ };
28
+
29
+ return null; // Nothing needs to be returned from `init`
30
+ },
31
+
32
+ onEvent: async function (name, e) {
33
+ switch (name) {
34
+ case "htmx:confirm":
35
+ /**
36
+ * This event is triggered when a form submission is confirmed.
37
+ * If the request method is POST or PUT, we process the form data and calculate its SHA-256 hash.
38
+ * The hash is then added to the `RequestHashQueue` for the specific request path.
39
+ * If the queue already exists for that path, the new hash is appended to the queue.
40
+ */
41
+ if (e.detail.verb == "post" || e.detail.verb == "put") {
42
+ e.preventDefault(); // Prevent the default form submission to handle it programmatically.
43
+
44
+ const form = e.target; // The form element that triggered the event.
45
+
46
+ // Ensure the target is a valid HTMLFormElement
47
+ if (form instanceof HTMLFormElement) {
48
+ // Create a FormData object from the form, and convert it into a URLSearchParams string
49
+ const formData = new FormData(form);
50
+ const data = new URLSearchParams(formData).toString();
51
+
52
+ // Calculate the SHA-256 hash of the form data
53
+ const sha256Hash = await this.calculateSHA256(data);
54
+
55
+ // Check if there's already a hash queue for this path, and either initialize or append to it
56
+ if (!this.RequestHashQueue[e.detail.path]) {
57
+ this.RequestHashQueue[e.detail.path] = [sha256Hash];
58
+ } else {
59
+ this.RequestHashQueue[e.detail.path].push(sha256Hash);
60
+ }
61
+ }
62
+
63
+ // Proceed with the request after processing the form data and hash
64
+ e.detail.issueRequest();
65
+ }
66
+ break;
67
+
68
+ case "htmx:configRequest":
69
+ /**
70
+ * Before the actual request is sent to AWS, if it is a POST or PUT request,
71
+ * this event is triggered. Here, we retrieve the SHA-256 hash from the `RequestHashQueue`
72
+ * and add it to the request headers under the name `x-amz-content-sha256`.
73
+ * This header is necessary for AWS services to validate the integrity of the request payload.
74
+ */
75
+ if (e.detail.verb == "post" || e.detail.verb == "put") {
76
+ // If there is a hash in the queue for the current request path, add it to the request header
77
+ if (this.RequestHashQueue[e.detail.path].length > 0)
78
+ e.detail.headers["x-amz-content-sha256"] =
79
+ this.RequestHashQueue[e.detail.path].pop();
80
+ }
81
+ break;
82
+ }
83
+ },
84
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "hx-ext-amz-content-sha256",
3
+ "main": "amz-content-sha256.js",
4
+ "version": "1.0.0",
5
+ "scripts": {
6
+ "lint": "eslint test/ext test",
7
+ "lint-fix": "eslint test/ext test --fix",
8
+ "format": "eslint --fix test/ext test",
9
+ "test": "mocha-chrome test/index.html"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/felipegenef/amz-content-sha256.git"
14
+ },
15
+ "devDependencies": {
16
+ "chai": "^4.3.10",
17
+ "chai-dom": "^1.12.0",
18
+ "mocha": "10.1.0",
19
+ "mocha-chrome": "https://github.com/Telroshan/mocha-chrome",
20
+ "sinon": "^9.2.4",
21
+ "htmx.org": "^2.0.2"
22
+ }
23
+ }
@@ -0,0 +1,87 @@
1
+ describe("Create a hash from a request", function () {
2
+ beforeEach(function () {
3
+ this.server = makeServer();
4
+ clearWorkArea();
5
+ });
6
+ afterEach(function () {
7
+ this.server.restore();
8
+ clearWorkArea();
9
+ });
10
+
11
+ it("Should hash the request if it is a post", async function () {
12
+ this.server.respondWith("POST", "/test", function (xhr) {
13
+ xhr.respond(
14
+ 200,
15
+ {},
16
+ JSON.stringify({ hash: xhr.requestHeaders["x-amz-content-sha256"] })
17
+ );
18
+ });
19
+
20
+ var html = make(
21
+ '<form hx-post="/test" hx-ext="amz-content-sha256" > ' +
22
+ '<input type="text" name="email" value="email@email.com"> ' +
23
+ '<input type="password" name="password" value="123456"> ' +
24
+ '<button id="btnSubmit">Submit</button> '
25
+ );
26
+
27
+ byId("btnSubmit").click();
28
+ // Await for async crypto hash function
29
+ await new Promise((res) => setTimeout(res, 100));
30
+ this.server.respond();
31
+ this.server.lastRequest.response.should.equal(
32
+ '{"hash":"9e2fbbb1b4b2e1cabd1923ec1376ab3c57b904820101b6edccf1a2c1adc20faf"}' // Hash for this first form data
33
+ );
34
+ });
35
+
36
+ it("Should hash the request if it is a put", async function () {
37
+ this.server.respondWith("PUT", "/test", function (xhr) {
38
+ xhr.respond(
39
+ 200,
40
+ {},
41
+ JSON.stringify({ hash: xhr.requestHeaders["x-amz-content-sha256"] })
42
+ );
43
+ });
44
+
45
+ var html = make(
46
+ '<form hx-put="/test" hx-ext="amz-content-sha256" > ' +
47
+ '<input type="text" name="email" value="email2@email.com"> ' +
48
+ '<input type="password" name="password" value="123456"> ' +
49
+ '<button id="btnSubmit">Submit</button> '
50
+ );
51
+
52
+ byId("btnSubmit").click();
53
+ // Await for async crypto hash function
54
+ await new Promise((res) => setTimeout(res, 100));
55
+ this.server.respond();
56
+ this.server.lastRequest.response.should.equal(
57
+ '{"hash":"31b0fe62efaa67d156b565b8d5221c3e7fdf2201f52f9adcd955d36614c65f59"}' // Hash for this first form data
58
+ );
59
+ });
60
+
61
+ it("Should not send a hash if there is no form element", async function () {
62
+ this.server.respondWith("POST", "/test", function (xhr) {
63
+ const hashNotSend =
64
+ xhr.requestHeaders["x-amz-content-sha256"] == undefined;
65
+ xhr.respond(
66
+ 200,
67
+ {},
68
+ JSON.stringify({
69
+ hashNotSend,
70
+ hash: xhr.requestHeaders["x-amz-content-sha256"],
71
+ })
72
+ );
73
+ });
74
+
75
+ var html = make(
76
+ '<button hx-post="/test" hx-ext="amz-content-sha256" id="btnSubmit">Submit</button> '
77
+ );
78
+
79
+ byId("btnSubmit").click();
80
+ // Await for async crypto hash function
81
+ await new Promise((res) => setTimeout(res, 100));
82
+ this.server.respond();
83
+ this.server.lastRequest.response.should.equal(
84
+ '{"hashNotSend":true}' // Hash for this first form data
85
+ );
86
+ });
87
+ });
@@ -0,0 +1,69 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Mocha Tests</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="stylesheet" href="../node_modules/mocha/mocha.css" />
8
+ <meta http-equiv="cache-control" content="no-cache, must-revalidate, post-check=0, pre-check=0" />
9
+ <meta http-equiv="cache-control" content="max-age=0" />
10
+ <meta http-equiv="expires" content="0" />
11
+ <meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT" />
12
+ <meta http-equiv="pragma" content="no-cache" />
13
+ <meta name="htmx-config" content='{"historyEnabled":false,"defaultSettleDelay":0,"head":{"boost":"none","other":"none"}}'>
14
+ </head>
15
+ <body style="padding:20px;font-family: sans-serif">
16
+
17
+ <h1 style="margin-top: 40px">htmx.js test suite</h1>
18
+ <p id="version-number">Version:&nbsp;</p>
19
+
20
+ <h2>Scratch Page</h2>
21
+ <ul>
22
+ <li>
23
+ <a href="scratch/scratch.html">Scratch Page</a>
24
+ </li>
25
+ </ul>
26
+
27
+ <h2>Manual Tests</h2>
28
+ <a href="manual">Here</a>
29
+
30
+ <h2>Mocha Test Suite</h2>
31
+ <a href="index.html">[ALL]</a>
32
+
33
+ <script src="../node_modules/chai/chai.js"></script>
34
+ <script src="../node_modules/chai-dom/chai-dom.js"></script>
35
+ <script src="../node_modules/mocha/mocha.js"></script>
36
+ <script src="../node_modules/sinon/pkg/sinon.js"></script>
37
+ <script src="../node_modules/htmx.org/dist/htmx.js"></script>
38
+ <script>
39
+ // Do not log all the events in headless mode (the log output is enormous)
40
+ if (navigator.webdriver) {
41
+ htmx.logAll = function () { }
42
+ }
43
+
44
+ // Add the version number to the top
45
+ document.getElementById('version-number').innerText += htmx.version
46
+ </script>
47
+
48
+ <script class="mocha-init">
49
+ mocha.setup('bdd');
50
+ mocha.checkLeaks();
51
+ window.should = window.chai.should()
52
+ </script>
53
+
54
+ <script src="./util.js"></script>
55
+ <script src="../amz-content-sha256.js"></script>
56
+ <script src="ext/test.js"></script>
57
+
58
+ <div id="mocha"></div>
59
+
60
+ <script class="mocha-exec">
61
+ document.addEventListener("DOMContentLoaded", function () {
62
+ mocha.run();
63
+ })
64
+ </script>
65
+ <em>Work Area</em>
66
+ <hr/>
67
+ <div id="work-area" hx-history-elt></div>
68
+ </body>
69
+ </html>
package/test/util.js ADDED
@@ -0,0 +1,115 @@
1
+ /* Test Utilities */
2
+
3
+ function byId(id) {
4
+ return document.getElementById(id);
5
+ }
6
+
7
+ function make(htmlStr) {
8
+ htmlStr = htmlStr.trim();
9
+ var makeFn = function () {
10
+ var range = document.createRange();
11
+ var fragment = range.createContextualFragment(htmlStr);
12
+ var wa = getWorkArea();
13
+ var child = null;
14
+ var children = fragment.children || fragment.childNodes; // IE
15
+ var appendedChildren = [];
16
+ while (children.length > 0) {
17
+ child = children[0];
18
+ wa.appendChild(child);
19
+ appendedChildren.push(child);
20
+ }
21
+ for (var i = 0; i < appendedChildren.length; i++) {
22
+ htmx.process(appendedChildren[i]);
23
+ }
24
+ return child; // return last added element
25
+ };
26
+ if (getWorkArea()) {
27
+ return makeFn();
28
+ } else {
29
+ ready(makeFn);
30
+ }
31
+ }
32
+
33
+ function ready(fn) {
34
+ if (document.readyState !== "loading") {
35
+ fn();
36
+ } else {
37
+ document.addEventListener("DOMContentLoaded", fn);
38
+ }
39
+ }
40
+
41
+ function getWorkArea() {
42
+ return byId("work-area");
43
+ }
44
+
45
+ function clearWorkArea() {
46
+ var workArea = getWorkArea();
47
+ if (workArea) workArea.innerHTML = "";
48
+ }
49
+
50
+ function removeWhiteSpace(str) {
51
+ return str.replace(/\s/g, "");
52
+ }
53
+
54
+ function getHTTPMethod(xhr) {
55
+ return xhr.requestHeaders["X-HTTP-Method-Override"] || xhr.method;
56
+ }
57
+
58
+ function makeServer() {
59
+ var server = sinon.fakeServer.create();
60
+ server.fakeHTTPMethods = true;
61
+ server.getHTTPMethod = function (xhr) {
62
+ return getHTTPMethod(xhr);
63
+ };
64
+ return server;
65
+ }
66
+
67
+ function parseParams(str) {
68
+ var re = /([^&=]+)=?([^&]*)/g;
69
+ var decode = function (str) {
70
+ return decodeURIComponent(str.replace(/\+/g, " "));
71
+ };
72
+ var params = {};
73
+ var e;
74
+ if (str) {
75
+ if (str.substr(0, 1) == "?") {
76
+ str = str.substr(1);
77
+ }
78
+ while ((e = re.exec(str))) {
79
+ var k = decode(e[1]);
80
+ var v = decode(e[2]);
81
+ if (params[k] !== undefined) {
82
+ if (!Array.isArray(params[k])) {
83
+ params[k] = [params[k]];
84
+ }
85
+ params[k].push(v);
86
+ } else {
87
+ params[k] = v;
88
+ }
89
+ }
90
+ }
91
+ return params;
92
+ }
93
+
94
+ function getQuery(url) {
95
+ var question = url.indexOf("?");
96
+ var hash = url.indexOf("#");
97
+ if (hash == -1 && question == -1) return "";
98
+ if (hash == -1) hash = url.length;
99
+ return question == -1 || hash == question + 1
100
+ ? url.substring(hash)
101
+ : url.substring(question + 1, hash);
102
+ }
103
+
104
+ function getParameters(xhr) {
105
+ if (getHTTPMethod(xhr) == "GET") {
106
+ return parseParams(getQuery(xhr.url));
107
+ } else {
108
+ return parseParams(xhr.requestBody);
109
+ }
110
+ }
111
+
112
+ function log(val) {
113
+ console.log(val);
114
+ return val;
115
+ }