Ethan Arrowood

@Ethan-Arrowood
\n```\n\nNow, for the heart of the solution, create an [`onSend` hook](https://fastify.dev/docs/latest/Reference/Hooks/#onsend). By default, the hook should pass through the `payload`.\n\n```js\nserver.addHook(\"onSend\", function onSendHook(request, reply, payload, done) {\n return done(null, payload);\n});\n```\n\nNext, add a filter for HTML replies. A reliable way to achieve this is by inspecting the [`content-type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header.\n\n```js\nserver.addHook(\"onSend\", function onSendHook(request, reply, payload, done) {\n if (reply.getHeader(\"content-type\").startsWith(\"text/html\")) {\n }\n return done(null, payload);\n});\n```\n\nBefore modifying the payload itself, set the appropriate [content-length](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) header. It is imperative that this value reflects the length of `payload`, otherwise most clients will not read the entire payload.\n\n```js\nserver.addHook(\"onSend\", function onSendHook(request, reply, payload, done) {\n if (reply.getHeader(\"content-type\").startsWith(\"text/html\")) {\n const contentLength = reply.getHeader(\"content-length\");\n reply.header(\"content-length\", contentLength + INJECT_CODE.length);\n }\n return done(null, payload);\n});\n```\n\nThen, pipe the `payload` through a custom Transform stream. By default, the `transform()` method should pass through the `chunk`.\n\n```js\nserver.addHook(\"onSend\", function onSendHook(request, reply, payload, done) {\n if (reply.getHeader(\"content-type\").startsWith(\"text/html\")) {\n const contentLength = reply.getHeader(\"content-length\");\n reply.header(\"content-length\", contentLength + INJECT_CODE.length);\n\n const transformedPayload = payload.pipe(\n new stream.Transform({\n transform(chunk, encoding, callback) {\n return callback(null, chunk);\n },\n }),\n );\n\n return done(null, transformedPayload);\n }\n return done(null, payload);\n});\n```\n\n> [!TIP]\n>\n> **What is a `Transform` stream?**\n>\n> If you're more familiar with Web Streams, a Node.js `Transform` is similar to a Web [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream). The implementations are quite different, but they serve a similar purpose. Essentially, it is a streaming data structure that can be both written to and read from, while simultaneously modifying the data passing through it.\n\nFinally, add an `encoding` check and then the buffer injection logic.\n\n```js\nserver.addHook(\"onSend\", function onSendHook(request, reply, payload, done) {\n if (reply.getHeader(\"content-type\").startsWith(\"text/html\")) {\n const contentLength = reply.getHeader(\"content-length\");\n reply.header(\"content-length\", contentLength + INJECT_CODE.length);\n\n const transformedPayload = payload.pipe(\n new stream.Transform({\n transform(chunk, encoding, callback) {\n if (encoding === \"buffer\") {\n const i = chunk.lastIndexOf(ENCODED_CLOSING_HTML_TAG);\n if (i > -1) {\n const injected = Buffer.alloc(chunk.length + INJECT_CODE.length);\n injected\n .fill(chunk.slice(0, i))\n .fill(INJECT_CODE, i)\n .fill(chunk.slice(i), i + INJECT_CODE.length);\n return callback(null, injected);\n }\n } else {\n console.warn(`Unexpected encoding type ${encoding}`);\n }\n\n return callback(null, chunk);\n },\n }),\n );\n\n return done(null, transformedPayload);\n }\n return done(null, payload);\n});\n```\n\nThe injection logic itself is nothing too special. It finds the last index of the closing HTML tag (`''`), and then injects `INJECT_CODE` immediately before it by creating a new `Buffer` instance and filling it with the appropriate slices.\n\n> [!TIP]\n>\n> A better way to inject HTML is to use a HTML parsing library such as [node-html-parser](https://www.npmjs.com/package/node-html-parser). It enables you to more precisely manipulate the HTML through APIs such as `htmlElement.insertAdjacentHTML(\"beforeend\", /* inject code */);`.\n\nBefore wrapping up, it is important to highlight the potential security vulnerability of HTML injection. Do not inject non-sanitized input from users, and be extra careful when doing something like this in a production application. A small bug could lead to major headaches. This example was built as a simple, demonstration and a more robust solution should be considered before using it in production.\n\nFinally, it is time to run the example!\n\nKick off the server using `node index.js`.\n\nNavigate to `http://localhost:3000` in a browser, and you should see `Injected!` alerted to the screen 🎉\n\n---\n\nIf you enjoyed this post, please consider sharing it. Furthermore, if you'd like early access to new posts, and to further support my open source work, consider subscribing to me on [polar.sh](https://polar.sh/Ethan-Arrowood/subscriptions).\n","author":{"@type":"Organization","name":"Ethan-Arrowood","url":"https://polar.sh/Ethan-Arrowood"},"publisher":{"@type":"Organization","name":"Ethan-Arrowood","url":"https://polar.sh/Ethan-Arrowood"},"datePublished":"2024-06-04T03:26:42.979Z"}

Injecting HTML into a Fastify Static File Server

Ethan-Arrowood

Recently, I've been exploring how to build a live reload server from scratch (keep an eye out for a future post on this). During this exploration, I had to figure out how to inject HTML into the payload of a fastify static file server (@fastify/static) response. My solution utilizes the Node.js Buffer API and a custom Node.js Transform stream. Let's dive in!

note

The source code is available here: https://github.com/Ethan-Arrowood/fastify-inject-html-example

To get started, import the necessary dependencies and instantiate the fastify server.

import fastify from "fastify";
import fastifyStatic from "@fastify/static";
import path from "node:path";
import stream from "node:stream";

const server = fastify({ logger: true });

server.register(fastifyStatic, {
  root: path.join(import.meta.dirname, "site"),
});

// Insert the remaining code here, before the `.listen()` call

server.listen({
  port: 3000,
  listenTextResolver: function listenTextResolver(address) {
    return `Server listening at ${address}`;
  },
});

Next, create some necessary constants.

const ENCODED_CLOSING_HTML_TAG = new Uint8Array([
  60, 47, 104, 116, 109, 108, 62,
]);

const INJECT_CODE = fs.readFileSync(
  path.join(import.meta.dirname, "inject.html"),
);

The ENCODED_CLOSING_HTML_TAG is the encoded string '</html>'. You can generate this for yourself using: new TextEncoder().encode('</html>'). The INJECT_CODE variable is a Node.js Buffer since no encoding was passed to fs.readFileSync().

tip

What is a Node.js Buffer?

A Buffer represents a fixed-length sequence of bytes. It is one of the simplest data structures in a Node.js application. Uniquely, it is a subclass of the JavaScript primitive data type Uint8Array.

I'm currently working on an in-depth article about "Low Level JavaScript" which will explore APIs such as Uint8Array and Buffer in much greater detail. Subscribe to me on polar.sh to receive early access to upcoming posts and many more perks!

Create inject.html with the content:

<script>
  alert("Injected!");
</script>

Now, for the heart of the solution, create an onSend hook. By default, the hook should pass through the payload.

server.addHook("onSend", function onSendHook(request, reply, payload, done) {
  return done(null, payload);
});

Next, add a filter for HTML replies. A reliable way to achieve this is by inspecting the content-type header.

server.addHook("onSend", function onSendHook(request, reply, payload, done) {
  if (reply.getHeader("content-type").startsWith("text/html")) {
  }
  return done(null, payload);
});

Before modifying the payload itself, set the appropriate content-length header. It is imperative that this value reflects the length of payload, otherwise most clients will not read the entire payload.

server.addHook("onSend", function onSendHook(request, reply, payload, done) {
  if (reply.getHeader("content-type").startsWith("text/html")) {
    const contentLength = reply.getHeader("content-length");
    reply.header("content-length", contentLength + INJECT_CODE.length);
  }
  return done(null, payload);
});

Then, pipe the payload through a custom Transform stream. By default, the transform() method should pass through the chunk.

server.addHook("onSend", function onSendHook(request, reply, payload, done) {
  if (reply.getHeader("content-type").startsWith("text/html")) {
    const contentLength = reply.getHeader("content-length");
    reply.header("content-length", contentLength + INJECT_CODE.length);

    const transformedPayload = payload.pipe(
      new stream.Transform({
        transform(chunk, encoding, callback) {
          return callback(null, chunk);
        },
      }),
    );

    return done(null, transformedPayload);
  }
  return done(null, payload);
});
tip

What is a Transform stream?

If you're more familiar with Web Streams, a Node.js Transform is similar to a Web TransformStream. The implementations are quite different, but they serve a similar purpose. Essentially, it is a streaming data structure that can be both written to and read from, while simultaneously modifying the data passing through it.

Finally, add an encoding check and then the buffer injection logic.

server.addHook("onSend", function onSendHook(request, reply, payload, done) {
  if (reply.getHeader("content-type").startsWith("text/html")) {
    const contentLength = reply.getHeader("content-length");
    reply.header("content-length", contentLength + INJECT_CODE.length);

    const transformedPayload = payload.pipe(
      new stream.Transform({
        transform(chunk, encoding, callback) {
          if (encoding === "buffer") {
            const i = chunk.lastIndexOf(ENCODED_CLOSING_HTML_TAG);
            if (i > -1) {
              const injected = Buffer.alloc(chunk.length + INJECT_CODE.length);
              injected
                .fill(chunk.slice(0, i))
                .fill(INJECT_CODE, i)
                .fill(chunk.slice(i), i + INJECT_CODE.length);
              return callback(null, injected);
            }
          } else {
            console.warn(`Unexpected encoding type ${encoding}`);
          }

          return callback(null, chunk);
        },
      }),
    );

    return done(null, transformedPayload);
  }
  return done(null, payload);
});

The injection logic itself is nothing too special. It finds the last index of the closing HTML tag ('</html>'), and then injects INJECT_CODE immediately before it by creating a new Buffer instance and filling it with the appropriate slices.

tip

A better way to inject HTML is to use a HTML parsing library such as node-html-parser. It enables you to more precisely manipulate the HTML through APIs such as htmlElement.insertAdjacentHTML("beforeend", /* inject code */);.

Before wrapping up, it is important to highlight the potential security vulnerability of HTML injection. Do not inject non-sanitized input from users, and be extra careful when doing something like this in a production application. A small bug could lead to major headaches. This example was built as a simple, demonstration and a more robust solution should be considered before using it in production.

Finally, it is time to run the example!

Kick off the server using node index.js.

Navigate to http://localhost:3000 in a browser, and you should see Injected! alerted to the screen 🎉


If you enjoyed this post, please consider sharing it. Furthermore, if you'd like early access to new posts, and to further support my open source work, consider subscribing to me on polar.sh.