From 85dd094b4512f3eb8645276021d8c617b08135fb Mon Sep 17 00:00:00 2001
From: Andres Morey <andresmarcel@gmail.com>
Date: Mon, 10 Jun 2024 12:37:26 +0300
Subject: [PATCH] Adds Node-HTTP implementation library, adds new built-in body
 parser for Express and Node-HTTP (#47)

---
 README.md                            | 118 ++++++++---
 examples/express/app.js              |   3 -
 examples/express/package.json        |   2 +-
 examples/node-http/README.md         |  21 ++
 examples/node-http/package.json      |   9 +
 examples/node-http/server.js         |  76 +++++++
 packages/express/README.md           |   6 -
 packages/express/src/index.test.ts   |  57 +----
 packages/express/src/index.ts        |  28 +--
 packages/node-http/README.md         | 145 +++++++++++++
 packages/node-http/package.json      |  37 ++++
 packages/node-http/src/index.test.ts | 303 +++++++++++++++++++++++++++
 packages/node-http/src/index.ts      | 115 ++++++++++
 packages/node-http/tsconfig.json     |   6 +
 packages/node-http/vite.config.ts    |  27 +++
 pnpm-lock.yaml                       |  81 ++++---
 16 files changed, 908 insertions(+), 126 deletions(-)
 create mode 100644 examples/node-http/README.md
 create mode 100644 examples/node-http/package.json
 create mode 100644 examples/node-http/server.js
 create mode 100644 packages/node-http/README.md
 create mode 100644 packages/node-http/package.json
 create mode 100644 packages/node-http/src/index.test.ts
 create mode 100644 packages/node-http/src/index.ts
 create mode 100644 packages/node-http/tsconfig.json
 create mode 100644 packages/node-http/vite.config.ts

diff --git a/README.md b/README.md
index b544d77..616e40c 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ We hope you enjoy using this software. Contributions and suggestions are welcome
 ## Features
 
 - Runs on both node and edge runtimes
-- Includes integrations for [Next.js](packages/nextjs), [Sveltekit](packages/sveltekit) and [Express](packages/express)
+- Includes integrations for [Next.js](packages/nextjs), [Sveltekit](packages/sveltekit), [Express](packages/express) and [Node-HTTP](packages/node-http)
 - Includes a low-level API for custom integrations ([see here](packages/core))
 - Handles form-urlencoded, multipart/form-data or json-encoded HTTP request bodies
 - Gets token from HTTP request header or from request body
@@ -21,6 +21,7 @@ We hope you enjoy using this software. Contributions and suggestions are welcome
 * [Next.js](packages/nextjs)
 * [SvelteKit](packages/sveltekit)
 * [Express](packages/express)
+* [Node-HTTP](packages/node-http)
 * [Core API](packages/core)
 
 ## Quickstart (Next.js)
@@ -198,38 +199,18 @@ const csrfMiddleware = createCsrfMiddleware({
 const app = express();
 const port = 3000;
 
-// add body parsing middleware
-app.use(express.urlencoded({ extended: false }));
-
 // add csrf middleware
 app.use(csrfMiddleware);
 
 // define handlers
-app.get('/', (_, res) => {
-  res.status(200).json({ success: true });
-});
-
-// start server
-app.listen(port, () => {
-  console.log(`Example app listening on port ${port}`)
-});
-```
-
-Now, all HTTP submission requests (e.g. POST, PUT, DELETE, PATCH) will be rejected if they do not include a valid CSRF token. To add the CSRF token to your forms, you can fetch it from the `X-CSRF-Token` HTTP response header server-side or client-side. For example:
-
-```javascript
-// app.js
-...
-
-// define handlers
-app.get('/my-form', (req, res) => {
+app.get('/', (req, res) => {
   const csrfToken = res.getHeader('X-CSRF-Token') || 'missing';
   res.send(`
     <!doctype html>
     <html>
       <body>
         <p>CSRF token value: ${csrfToken}</p>
-        <form action="/my-form" method="post">
+        <form action="/" method="post">
           <legend>Form with CSRF (should succeed):</legend>
           <input type="hidden" name="csrf_token" value="${csrfToken}" />
           <input type="text" name="input1" />
@@ -240,13 +221,100 @@ app.get('/my-form', (req, res) => {
   `);
 });
 
-app.post('/my-form', (req, res) => {
+app.post('/', (req, res) => {
   res.send('success');
 });
 
-...
+// start server
+app.listen(port, () => {
+  console.log(`Example app listening on port ${port}`)
+});
 ```
 
+With the middleware installed, all HTTP submission requests (e.g. POST, PUT, DELETE, PATCH) will be rejected if they do not include a valid CSRF token. 
+
+## Quickstart (Node-HTTP)
+
+First, install Edge-CSRF's Node-HTTP integration library:
+
+```console
+npm install @edge-csrf/node-http
+# or
+pnpm add @edge-csrf/node-http
+# or
+yarn add @edge-csrf/node-http
+```
+
+Next, add the Edge-CSRF CSRF protection function to your request handlers:
+
+```javascript
+// server.js
+
+import { createServer } from 'http';
+
+import { createCsrfProtect } from '@edge-csrf/node-http';
+
+// initalize csrf protection middleware
+const csrfProtect = createCsrfProtect({
+  cookie: {
+    secure: process.env.NODE_ENV === 'production',
+  },
+});
+
+// init server
+const server = createServer(async (req, res) => {
+  // apply csrf protection
+  try {
+    await csrfProtect(req, res);
+  } catch (err) {
+    if (err instanceof CsrfError) {
+      res.writeHead(403);
+      res.end('invalid csrf token');
+      return;
+    }
+    throw err;
+  }
+
+  // add handler
+  if (req.url === '/') {
+    if (req.method === 'GET') {
+      const csrfToken = res.getHeader('X-CSRF-Token') || 'missing';
+      res.writeHead(200, { 'Content-Type': 'text/html' });
+      res.end(`
+        <!doctype html>
+        <html>
+          <body>
+            <form action="/" method="post">
+              <legend>Form with CSRF (should succeed):</legend>
+              <input type="hidden" name="csrf_token" value="${csrfToken}" />
+              <input type="text" name="input1" />
+              <button type="submit">Submit</button>
+            </form>
+          </body>
+        </html>
+      `);
+      return;
+    }
+
+    if (req.method === 'POST') {
+      res.writeHead(200, { 'Content-Type': 'text/plain' });
+      res.end('success');
+      return;
+    }
+  }
+
+  res.writeHead(404);
+  res.end('not found');
+});
+
+// start server
+server.listen(3000, () => {
+  console.log('Server is listening on port 3000');
+});
+```
+
+With the CSRF protection method, all HTTP submission requests (e.g. POST, PUT, DELETE, PATCH) will be rejected if they do not include a valid CSRF token. 
+
 ## Development
 
 ### Get the code
diff --git a/examples/express/app.js b/examples/express/app.js
index 9abb786..e27c7d9 100644
--- a/examples/express/app.js
+++ b/examples/express/app.js
@@ -12,9 +12,6 @@ const csrfMiddleware = createCsrfMiddleware({
 const app = express();
 const port = 3000;
 
-// add body parsing middleware
-app.use(express.urlencoded({ extended: false }));
-
 // add csrf middleware
 app.use(csrfMiddleware);
 
diff --git a/examples/express/package.json b/examples/express/package.json
index d98f759..24c7c31 100644
--- a/examples/express/package.json
+++ b/examples/express/package.json
@@ -4,7 +4,7 @@
   "private": true,
   "type": "module",
   "dependencies": {
-    "@edge-csrf/express": "^2.1.0",
+    "@edge-csrf/express": "^2.2.0",
     "express": "^4.19.2"
   }
 }
diff --git a/examples/node-http/README.md b/examples/node-http/README.md
new file mode 100644
index 0000000..248892e
--- /dev/null
+++ b/examples/node-http/README.md
@@ -0,0 +1,21 @@
+This is an [Express](https://expressjs.com) example app.
+
+## Getting Started
+
+First, install dependencies:
+
+```bash
+npm install
+# or
+pnpm install
+# or
+yarn install
+```
+
+Next, run the server:
+
+```bash
+node app.js
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
diff --git a/examples/node-http/package.json b/examples/node-http/package.json
new file mode 100644
index 0000000..81060c0
--- /dev/null
+++ b/examples/node-http/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "edge-csrf-example",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "dependencies": {
+    "@edge-csrf/node-http": "^2.2.0"
+  }
+}
diff --git a/examples/node-http/server.js b/examples/node-http/server.js
new file mode 100644
index 0000000..1d0609f
--- /dev/null
+++ b/examples/node-http/server.js
@@ -0,0 +1,76 @@
+import { createServer } from 'http';
+
+import { CsrfError, createCsrfProtect } from '@edge-csrf/node-http';
+
+// initalize csrf protection method
+const csrfProtect = createCsrfProtect({
+  cookie: {
+    secure: process.env.NODE_ENV === 'production',
+  },
+});
+
+// init server
+const server = createServer(async (req, res) => {
+  // apply csrf protection
+  try {
+    await csrfProtect(req, res);
+  } catch (err) {
+    if (err instanceof CsrfError) {
+      res.writeHead(403);
+      res.end('invalid csrf token');
+      return;
+    }
+    throw err;
+  }
+
+  // add handler
+  if (req.url === '/') {
+    if (req.method === 'GET') {
+      const csrfToken = res.getHeader('X-CSRF-Token') || 'missing';
+      res.writeHead(200, { 'Content-Type': 'text/html' });
+      res.end(`
+        <!doctype html>
+        <html>
+          <body>
+            <p>CSRF token value: ${csrfToken}</p>
+            <h2>HTML Form Submission Example:</h2>
+            <form action="/" method="post">
+              <legend>Form without CSRF (should fail):</legend>
+              <input type="text" name="input1" />
+              <button type="submit">Submit</button>
+            </form>
+            <br />
+            <form action="/" method="post">
+              <legend>Form with incorrect CSRF (should fail):</legend>
+              <input type="hidden" name="csrf_token" value="notvalid" />
+              <input type="text" name="input1" />
+              <button type="submit">Submit</button>
+            </form>
+            <br />
+            <form action="/" method="post">
+              <legend>Form with CSRF (should succeed):</legend>
+              <input type="hidden" name="csrf_token" value="${csrfToken}" />
+              <input type="text" name="input1" />
+              <button type="submit">Submit</button>
+            </form>
+          </body>
+        </html>
+      `);
+      return;
+    }
+
+    if (req.method === 'POST') {
+      res.writeHead(200, { 'Content-Type': 'text/plain' });
+      res.end('success');
+      return;
+    }
+  }
+
+  res.writeHead(404);
+  res.end('not found');
+});
+
+// start server
+server.listen(3000, () => {
+  console.log('Server is listening on port 3000');
+});
diff --git a/packages/express/README.md b/packages/express/README.md
index 082d207..a7dff06 100644
--- a/packages/express/README.md
+++ b/packages/express/README.md
@@ -33,9 +33,6 @@ const csrfMiddleware = createCsrfMiddleware({
 const app = express();
 const port = 3000;
 
-// add body parsing middleware
-app.use(express.urlencoded({ extended: false }));
-
 // add csrf middleware
 app.use(csrfMiddleware);
 
@@ -107,9 +104,6 @@ const csrfProtect = createCsrfProtect({
 const app = express();
 const port = 3000;
 
-// add body parsing middleware
-app.use(express.urlencoded({ extended: false }));
-
 // add csrf middleware
 app.use(async (req, res, next) => {
   try {
diff --git a/packages/express/src/index.test.ts b/packages/express/src/index.test.ts
index dd2c60b..16d3671 100644
--- a/packages/express/src/index.test.ts
+++ b/packages/express/src/index.test.ts
@@ -7,10 +7,9 @@ import * as util from '@shared/util';
 import { ExpressConfig, ExpressTokenOptions, createCsrfMiddleware } from './index';
 
 function createApp(): Express {
-  const app = express();
-  app.use(express.urlencoded({ extended: false }));
-
   const csrfMiddleware = createCsrfMiddleware();
+
+  const app = express();
   app.use(csrfMiddleware);
 
   app.get('/', (_, res) => {
@@ -24,7 +23,7 @@ function createApp(): Express {
   return app;
 }
 
-describe('NextTokenOptions tests', () => {
+describe('ExpressTokenOptions tests', () => {
   it('returns default values when options are absent', () => {
     const tokenOpts = new ExpressTokenOptions();
     expect(tokenOpts.responseHeader).toEqual('X-CSRF-Token');
@@ -42,7 +41,7 @@ describe('NextTokenOptions tests', () => {
   });
 });
 
-describe('NextConfig tests', () => {
+describe('ExpressConfig tests', () => {
   it('returns default config when options are absent', () => {
     const config = new ExpressConfig();
     expect(config.excludePathPrefixes).toEqual([]);
@@ -74,23 +73,11 @@ describe('csrfProtectMiddleware integration tests', () => {
     expect(token).not.toBe('');
   });
 
-  it('should work with express.json()', async () => {
-    // init app
-    const app = express();
-    app.use(express.json());
-
-    const csrfMiddleware = createCsrfMiddleware();
-    app.use(csrfMiddleware);
-
-    app.post('/', (_, res) => {
-      res.status(200).json({ success: true });
-    });
-
-    // make request
+  it('should work with application/json', async () => {
     const secretUint8 = util.createSecret(8);
     const tokenUint8 = await util.createToken(secretUint8, 8);
 
-    const resp = await request(app)
+    const resp = await request(testApp)
       .post('/')
       .set('Content-Type', 'application/json')
       .set('Cookie', [`_csrfSecret=${util.utoa(secretUint8)}`])
@@ -103,23 +90,11 @@ describe('csrfProtectMiddleware integration tests', () => {
     expect(newTokenStr).not.toBe('');
   });
 
-  it('should work with express.text()', async () => {
-    // init app
-    const app = express();
-    app.use(express.text());
-
-    const csrfMiddleware = createCsrfMiddleware();
-    app.use(csrfMiddleware);
-
-    app.post('/', (_, res) => {
-      res.status(200).json({ success: true });
-    });
-
-    // make request
+  it('should work with text/plain', async () => {
     const secretUint8 = util.createSecret(8);
     const tokenUint8 = await util.createToken(secretUint8, 8);
 
-    const resp = await request(app)
+    const resp = await request(testApp)
       .post('/')
       .set('Content-Type', 'text/plain')
       .set('Cookie', [`_csrfSecret=${util.utoa(secretUint8)}`])
@@ -132,23 +107,11 @@ describe('csrfProtectMiddleware integration tests', () => {
     expect(newTokenStr).not.toBe('');
   });
 
-  it('should work with express.urlencoded()', async () => {
-    // init app
-    const app = express();
-    app.use(express.urlencoded({ extended: false }));
-
-    const csrfMiddleware = createCsrfMiddleware();
-    app.use(csrfMiddleware);
-
-    app.post('/', (_, res) => {
-      res.status(200).json({ success: true });
-    });
-
-    // make request
+  it('should work with application/x-www-form-urlencoded', async () => {
     const secretUint8 = util.createSecret(8);
     const tokenUint8 = await util.createToken(secretUint8, 8);
 
-    const resp = await request(app)
+    const resp = await request(testApp)
       .post('/')
       .set('Content-Type', 'application/x-www-form-urlencoded')
       .set('Cookie', [`_csrfSecret=${util.utoa(secretUint8)}`])
diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts
index bc2d843..6a40686 100644
--- a/packages/express/src/index.ts
+++ b/packages/express/src/index.ts
@@ -6,6 +6,20 @@ import type { ConfigOptions } from '@shared/protect';
 
 export { CsrfError };
 
+/**
+ * Parse request body as string
+ * @param {ExpressRequest} req - The node http request
+ * @returns Promise that resolves to the body
+ */
+function getRequestBody(req: ExpressRequest): Promise<string> {
+  return new Promise((resolve, reject) => {
+    let body = '';
+    req.on('data', (chunk) => { body += chunk.toString(); });
+    req.on('end', () => resolve(body));
+    req.on('error', (err) => reject(err));
+  });
+}
+
 /**
  * Represents token options in config
  */
@@ -72,23 +86,11 @@ export function createCsrfProtect(opts?: Partial<ExpressConfigOptions>): Express
       else if (value !== undefined) headers.append(key, value);
     });
 
-    let body: URLSearchParams | string | undefined;
-    if (!['GET', 'HEAD'].includes(req.method)) {
-      const contentType = headers.get('content-type') || 'text/plain';
-      if (typeof req.body === 'string') {
-        body = req.body;
-      } else if (typeof req.body === 'object' && ['application/json', 'application/ld+json'].includes(contentType)) {
-        body = JSON.stringify(req.body);
-      } else {
-        body = new URLSearchParams(req.body);
-      }
-    }
-
     // init request object
     const request = new Request(url, {
       method: req.method,
       headers,
-      body,
+      body: ['GET', 'HEAD'].includes(req.method || '') ? undefined : await getRequestBody(req),
     });
 
     // execute protect function
diff --git a/packages/node-http/README.md b/packages/node-http/README.md
new file mode 100644
index 0000000..d088544
--- /dev/null
+++ b/packages/node-http/README.md
@@ -0,0 +1,145 @@
+# Node-HTTP
+
+This is the documentation for Edge-CSRF's Node built-in http module integration.
+
+## Quickstart
+
+First, add the integration library as a dependency:
+
+```console
+npm install @edge-csrf/node-http
+# or
+pnpm add @edge-csrf/node-http
+# or
+yarn add @edge-csrf/node-http
+```
+
+Next, add the Edge-CSRF CSRF protection function to your app:
+
+```javascript
+// server.js
+
+import { createServer } from 'http';
+
+import { createCsrfProtect } from '@edge-csrf/node-http';
+
+// initalize csrf protection middleware
+const csrfProtect = createCsrfProtect({
+  cookie: {
+    secure: process.env.NODE_ENV === 'production',
+  },
+});
+
+// init server
+const server = createServer(async (req, res) => {
+  // apply csrf protection
+  try {
+    await csrfProtect(req, res);
+  } catch (err) {
+    if (err instanceof CsrfError) {
+      res.writeHead(403);
+      res.end('invalid csrf token');
+      return;
+    }
+    throw err;
+  }
+
+  // add handler
+  if (req.url === '/') {
+    if (req.method === 'GET') {
+      const csrfToken = res.getHeader('X-CSRF-Token') || 'missing';
+      res.writeHead(200, { 'Content-Type': 'text/html' });
+      res.end(`
+        <!doctype html>
+        <html>
+          <body>
+            <form action="/" method="post">
+              <legend>Form with CSRF (should succeed):</legend>
+              <input type="hidden" name="csrf_token" value="${csrfToken}" />
+              <input type="text" name="input1" />
+              <button type="submit">Submit</button>
+            </form>
+          </body>
+        </html>
+      `);
+      return;
+    }
+
+    if (req.method === 'POST') {
+      res.writeHead(200, { 'Content-Type': 'text/plain' });
+      res.end('success');
+      return;
+    }
+  }
+
+  res.writeHead(404);
+  res.end('not found');
+});
+
+// start server
+server.listen(3000, () => {
+  console.log('Server is listening on port 3000');
+});
+```
+
+With the CSRF protection method, all HTTP submission requests (e.g. POST, PUT, DELETE, PATCH) will be rejected if they do not include a valid CSRF token. 
+
+## Example
+
+Check out the example Node-HTTP server in this repository: [Node-HTTP example](examples/node-http).
+
+## Configuration
+
+```javascript
+// default config
+
+{
+  cookie: {
+    name: '_csrfSecret',
+    path: '/',
+    maxAge: undefined,
+    domain: '',
+    secure: true,
+    httpOnly: true,
+    sameSite: 'strict'
+  },
+  excludePathPrefixes: [],
+  ignoreMethods: ['GET', 'HEAD', 'OPTIONS'],
+  saltByteLength: 8,
+  secretByteLength: 18,
+  token: {
+    responseHeader: 'X-CSRF-Token'
+  }
+}
+```
+
+## API
+
+The following are named exports in the the `@edge-csrf/node-http` module:
+
+### Types
+
+```
+NodeHttpCsrfProtect - A function that implements CSRF protection for Node http requests
+
+  * @param {IncomingMessage} request - The Node HTTP module request instance
+  * @param {ServerResponse} response - The Node HTTP module response instance
+  * @returns {Promise<void>} - The function completed successfully
+  * @throws {CsrfError} - The function encountered a CSRF error
+```
+
+### Classes
+
+```
+CsrfError - A class that inherits from Error and represents CSRF errors
+```
+
+### Methods
+
+```
+createCsrfProtect([, options]) - Create a function that can be used inside Node HTTP handlers
+                                 to implement CSRF protection for requests
+
+  * @param {object} options - The configuration options
+  * @returns {NodeHttpCsrfProtect} - The CSRF protection function
+```
diff --git a/packages/node-http/package.json b/packages/node-http/package.json
new file mode 100644
index 0000000..32b16a8
--- /dev/null
+++ b/packages/node-http/package.json
@@ -0,0 +1,37 @@
+{
+  "name": "@edge-csrf/node-http",
+  "version": "0.0.0",
+  "description": "Edge-CSRF integration library for node's http module",
+  "author": "Andres Morey",
+  "license": "MIT",
+  "repository": "kubetail-org/edge-csrf",
+  "type": "module",
+  "sideEffects": false,
+  "main": "dist/index.cjs",
+  "module": "dist/index.js",
+  "types": "dist/index.d.ts",
+  "files": [
+    "/dist"
+  ],
+  "scripts": {
+    "build": "tsc && vite build",
+    "lint": "eslint \"./src/**/*.ts{,x}\"",
+    "test": "vitest",
+    "test-all": "vitest run --environment node && vitest run --environment edge-runtime && vitest run --environment miniflare"
+  },
+  "keywords": [
+    "csrf",
+    "tokens",
+    "edge",
+    "node",
+    "createServer"
+  ],
+  "dependencies": {
+    "cookie": "^0.6.0"
+  },
+  "devDependencies": {
+    "@types/cookie": "^0.6.0",
+    "@types/supertest": "^6.0.2",
+    "supertest": "^7.0.0"
+  }
+}
diff --git a/packages/node-http/src/index.test.ts b/packages/node-http/src/index.test.ts
new file mode 100644
index 0000000..0de502f
--- /dev/null
+++ b/packages/node-http/src/index.test.ts
@@ -0,0 +1,303 @@
+import { createServer } from 'http';
+
+import request from 'supertest';
+
+import * as util from '@shared/util';
+
+import { CsrfError, createCsrfProtect, NodeHttpConfig, NodeHttpTokenOptions } from './index';
+
+function createApp() {
+  const csrfProtect = createCsrfProtect();
+
+  return createServer(async (req, res) => {
+    // apply csrf protection
+    try {
+      await csrfProtect(req, res);
+    } catch (err) {
+      if (err instanceof CsrfError) {
+        res.writeHead(403);
+        res.end('invalid csrf token');
+        return;
+      }
+      throw err;
+    }
+
+    if (req.url === '/' && ['GET', 'POST'].includes(req.method || '')) {
+      res.writeHead(200);
+      res.end('ok');
+    } else {
+      res.writeHead(404);
+      res.end('not found');
+    }
+  });
+}
+
+describe('NodeHttpTokenOptions tests', () => {
+  it('returns default values when options are absent', () => {
+    const tokenOpts = new NodeHttpTokenOptions();
+    expect(tokenOpts.responseHeader).toEqual('X-CSRF-Token');
+  });
+
+  it('handles overrides', () => {
+    const tokenOpts = new NodeHttpTokenOptions({ responseHeader: 'XXX' });
+    expect(tokenOpts.responseHeader).toEqual('XXX');
+  });
+
+  it('handles overrides of parent attributes', () => {
+    const fn = async () => '';
+    const tokenOpts = new NodeHttpTokenOptions({ value: fn });
+    expect(tokenOpts.value).toBe(fn);
+  });
+});
+
+describe('NodeHttpConfig tests', () => {
+  it('returns default config when options are absent', () => {
+    const config = new NodeHttpConfig();
+    expect(config.excludePathPrefixes).toEqual([]);
+    expect(config.token instanceof NodeHttpTokenOptions).toBe(true);
+  });
+
+  it('handles top-level overrides', () => {
+    const config = new NodeHttpConfig({ excludePathPrefixes: ['/xxx/'] });
+    expect(config.excludePathPrefixes).toEqual(['/xxx/']);
+  });
+
+  it('handles nested token overrides', () => {
+    const config = new NodeHttpConfig({ token: { responseHeader: 'XXX' } });
+    expect(config.token.responseHeader).toEqual('XXX');
+  });
+});
+
+describe('csrfProtect integration tests', async () => {
+  const testApp = createApp();
+
+  it('adds token to response header', async () => {
+    const resp = await request(testApp)
+      .get('/')
+      .expect(200);
+
+    // assertions
+    const token = resp.header['x-csrf-token'];
+    expect(token).toBeDefined();
+    expect(token).not.toBe('');
+  });
+
+  it('should work with application/json', async () => {
+    const secretUint8 = util.createSecret(8);
+    const tokenUint8 = await util.createToken(secretUint8, 8);
+
+    const resp = await request(testApp)
+      .post('/')
+      .set('Content-Type', 'application/json')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secretUint8)}`])
+      .send(JSON.stringify({ csrf_token: util.utoa(tokenUint8) }))
+      .expect(200);
+
+    // assertions
+    const newTokenStr = resp.headers['x-csrf-token'];
+    expect(newTokenStr).toBeDefined();
+    expect(newTokenStr).not.toBe('');
+  });
+
+  it('should work with text/plain', async () => {
+    const secretUint8 = util.createSecret(8);
+    const tokenUint8 = await util.createToken(secretUint8, 8);
+
+    const resp = await request(testApp)
+      .post('/')
+      .set('Content-Type', 'text/plain')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secretUint8)}`])
+      .send(util.utoa(tokenUint8))
+      .expect(200);
+
+    // assertions
+    const newTokenStr = resp.headers['x-csrf-token'];
+    expect(newTokenStr).toBeDefined();
+    expect(newTokenStr).not.toBe('');
+  });
+
+  it('should work with application/x-www-form-urlencoded', async () => {
+    const secretUint8 = util.createSecret(8);
+    const tokenUint8 = await util.createToken(secretUint8, 8);
+
+    const resp = await request(testApp)
+      .post('/')
+      .set('Content-Type', 'application/x-www-form-urlencoded')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secretUint8)}`])
+      .send(`csrf_token=${encodeURIComponent(util.utoa(tokenUint8))}`)
+      .expect(200);
+
+    // assertions
+    const newTokenStr = resp.headers['x-csrf-token'];
+    expect(newTokenStr).toBeDefined();
+    expect(newTokenStr).not.toBe('');
+  });
+
+  it('should work in x-csrf-token header', async () => {
+    const secret = util.createSecret(8);
+    const token = await util.createToken(secret, 8);
+
+    const resp = await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secret)}`])
+      .set('X-CSRF-Token', util.utoa(token))
+      .expect(200);
+
+    // assertions
+    const newTokenStr = resp.headers['x-csrf-token'];
+    expect(newTokenStr).toBeDefined();
+    expect(newTokenStr).not.toBe('');
+  });
+
+  it('should reject token from different secret', async () => {
+    const goodSecret = util.createSecret(8);
+    const evilSecret = util.createSecret(8);
+    const evilToken = await util.createToken(evilSecret, 8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${util.utoa(goodSecret)}`])
+      .set('X-CSRF-Token', util.utoa(evilToken))
+      .expect(403);
+  });
+
+  it('should reject invalid token', async () => {
+    const secret = util.createSecret(8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secret)}`])
+      .set('X-CSRF-Token', btoa(String.fromCharCode(100)))
+      .expect(403);
+  });
+
+  it('should reject non-base64 token', async () => {
+    const secret = util.createSecret(8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secret)}`])
+      .set('X-CSRF-Token', '-')
+      .expect(403);
+  });
+
+  it('should reject no token', async () => {
+    const secret = util.createSecret(8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secret)}`])
+      .expect(403);
+  });
+
+  it('should reject empty token', async () => {
+    const secret = util.createSecret(8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${util.utoa(secret)}`])
+      .set('X-CSRF-Token', '')
+      .expect(403);
+  });
+
+  it('should reject with non-base64 secret', async () => {
+    const secret = util.createSecret(8);
+    const token = await util.createToken(secret, 8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', ['_csrfSecret=-'])
+      .set('X-CSRF-Token', util.utoa(token))
+      .expect(403);
+  });
+
+  it('should reject invalid secret', async () => {
+    const secret = util.createSecret(8);
+    const token = await util.createToken(secret, 8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', [`_csrfSecret=${btoa(String.fromCharCode(100))}`])
+      .set('X-CSRF-Token', util.utoa(token))
+      .expect(403);
+  });
+
+  it('should reject no secret', async () => {
+    const secret = util.createSecret(8);
+    const token = await util.createToken(secret, 8);
+
+    await request(testApp)
+      .post('/')
+      .set('X-CSRF-Token', util.utoa(token))
+      .expect(403);
+  });
+
+  it('should reject empty secret', async () => {
+    const secret = util.createSecret(8);
+    const token = await util.createToken(secret, 8);
+
+    await request(testApp)
+      .post('/')
+      .set('Cookie', ['_csrfSecret='])
+      .set('X-CSRF-Token', util.utoa(token))
+      .expect(403);
+  });
+});
+
+describe('obtaining secrets tests', () => {
+  const testApp = createApp();
+
+  describe('sets new secret when missing from request', () => {
+    it('GET', async () => {
+      const resp = await request(testApp).get('/');
+      const setCookieList = resp.get('Set-Cookie');
+      expect(setCookieList).not.toBeUndefined();
+      if (!setCookieList) return;
+      expect(setCookieList?.length).toEqual(1);
+      expect(setCookieList[0].startsWith('_csrfSecret=')).toEqual(true);
+    });
+
+    it('POST', async () => {
+      const resp = await request(testApp).post('/');
+      const setCookieList = resp.get('Set-Cookie');
+      expect(setCookieList).not.toBeUndefined();
+      if (!setCookieList) return;
+      expect(setCookieList?.length).toEqual(1);
+      expect(setCookieList[0].startsWith('_csrfSecret=')).toEqual(true);
+    });
+  });
+
+  describe('keeps existing secret when present in request', () => {
+    const secretStr = util.utoa(util.createSecret(8));
+
+    it('GET', async () => {
+      const resp = await request(testApp)
+        .get('/')
+        .set('Cookie', [`_csrfSecret=${secretStr}`]);
+      expect(resp.get('Set-Cookie')).toBeUndefined();
+    });
+
+    it('POST', async () => {
+      const resp = await request(testApp)
+        .post('/')
+        .set('Cookie', [`_csrfSecret=${secretStr}`]);
+      expect(resp.get('Set-Cookie')).toBeUndefined();
+    });
+  });
+
+  it('creates unique secret on subsequent empty request', async () => {
+    // 1st request
+    const resp1 = await request(testApp).get('/');
+    const setCookie1 = resp1.get('Set-Cookie');
+
+    // 2nd request
+    const resp2 = await request(testApp).get('/');
+    const setCookie2 = resp2.get('Set-Cookie');
+
+    // compare secrets
+    expect(setCookie1).not.toEqual(undefined);
+    expect(setCookie2).not.toEqual(undefined);
+    if (!setCookie1 || !setCookie2) return;
+    expect(setCookie1[0]).not.toEqual(setCookie2[0]);
+  });
+});
diff --git a/packages/node-http/src/index.ts b/packages/node-http/src/index.ts
new file mode 100644
index 0000000..16ac33a
--- /dev/null
+++ b/packages/node-http/src/index.ts
@@ -0,0 +1,115 @@
+import type { IncomingMessage, ServerResponse } from 'http';
+
+import * as cookielib from 'cookie';
+
+import { CsrfError, createCsrfProtect as _createCsrfProtect, Config, TokenOptions } from '@shared/protect';
+import type { ConfigOptions } from '@shared/protect';
+
+export { CsrfError };
+
+/**
+ * Parse request body as string
+ * @param {IncomingMessage} req - The node http request
+ * @returns Promise that resolves to the body
+ */
+function getRequestBody(req: IncomingMessage): Promise<string> {
+  return new Promise((resolve, reject) => {
+    let body = '';
+    req.on('data', (chunk) => { body += chunk.toString(); });
+    req.on('end', () => resolve(body));
+    req.on('error', (err) => reject(err));
+  });
+}
+
+/**
+ * Represents token options in config
+ */
+export class NodeHttpTokenOptions extends TokenOptions {
+  responseHeader: string = 'X-CSRF-Token';
+
+  constructor(opts?: Partial<NodeHttpTokenOptions>) {
+    super(opts);
+    Object.assign(this, opts);
+  }
+}
+
+/**
+ * Represents configuration object
+ */
+export class NodeHttpConfig extends Config {
+  excludePathPrefixes: string[] = [];
+
+  token: NodeHttpTokenOptions = new NodeHttpTokenOptions();
+
+  constructor(opts?: Partial<NodeHttpConfigOptions>) {
+    super(opts);
+    const newOpts = opts || {};
+    if (newOpts.token) newOpts.token = new NodeHttpTokenOptions(newOpts.token);
+    Object.assign(this, newOpts);
+  }
+}
+
+/**
+ * Represents configuration options object
+ */
+export interface NodeHttpConfigOptions extends Omit<ConfigOptions, 'token'> {
+  token: Partial<NodeHttpTokenOptions>;
+}
+
+/**
+ * Represents signature of CSRF protect function to be used in node-http request handlers
+ */
+export type NodeHttpCsrfProtect = {
+  (request: IncomingMessage, response: ServerResponse): Promise<void>;
+};
+
+/**
+ * Create CSRF protection function for use in node-http request handlers
+ * @param {Partial<NodeHttpConfigOptions>} opts - Configuration options
+ * @returns {NodeHttpCsrfProtect} - The CSRF protect function
+ * @throws {CsrfError} - An error if CSRF validation failed
+ */
+export function createCsrfProtect(opts?: Partial<NodeHttpConfigOptions>): NodeHttpCsrfProtect {
+  const config = new NodeHttpConfig(opts);
+  const _csrfProtect = _createCsrfProtect(config);
+
+  return async (req, res) => {
+    // parse cookies
+    const cookies = cookielib.parse(req.headers.cookie || '');
+
+    // init url
+    const { url: originalUrl, headers: { host } } = req;
+    const url = new URL(`http://${host}${originalUrl || ''}`);
+
+    // init headers
+    const headers = new Headers();
+    Object.entries(req.headers).forEach(([key, value]) => {
+      if (Array.isArray(value)) value.forEach((val) => headers.append(key, val));
+      else if (value !== undefined) headers.append(key, value);
+    });
+
+    // init request object
+    const request = new Request(url, {
+      method: req.method,
+      headers,
+      body: ['GET', 'HEAD'].includes(req.method || '') ? undefined : await getRequestBody(req),
+    });
+
+    // execute protect function
+    const token = await _csrfProtect({
+      request,
+      url,
+      getCookie: (name) => cookies[name],
+      setCookie: (cookie) => {
+        const newCookie = cookielib.serialize(cookie.name, cookie.value, cookie);
+        const existingCookies = res.getHeader('Set-Cookie');
+        if (Array.isArray(existingCookies)) res.setHeader('Set-Cookie', [...existingCookies, newCookie]);
+        else if (typeof existingCookies === 'string') res.setHeader('Set-Cookie', [existingCookies, newCookie]);
+        else res.setHeader('Set-Cookie', newCookie);
+      },
+    });
+
+    // add token to response header
+    if (token) res.setHeader(config.token.responseHeader, token);
+  };
+}
diff --git a/packages/node-http/tsconfig.json b/packages/node-http/tsconfig.json
new file mode 100644
index 0000000..47a1b59
--- /dev/null
+++ b/packages/node-http/tsconfig.json
@@ -0,0 +1,6 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": [
+    "./src/**/*.ts"
+  ]
+}
diff --git a/packages/node-http/vite.config.ts b/packages/node-http/vite.config.ts
new file mode 100644
index 0000000..2831692
--- /dev/null
+++ b/packages/node-http/vite.config.ts
@@ -0,0 +1,27 @@
+/// <reference types="vitest" />
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+
+import dts from '../../shared/src/vite-plugin-dts';
+
+export default defineConfig({
+  resolve: {
+    alias: {
+      '@shared': resolve(__dirname, '../../shared/src'),
+    },
+  },
+  plugins: [dts()],
+  build: {
+    lib: {
+      entry: [
+        resolve(__dirname, 'src/index.ts'),
+      ],
+      name: '@edge-csrf/node-http',
+      formats: ['es', 'cjs'],
+    }
+  },
+  test: {
+    environment: 'edge-runtime',
+    globals: true,
+  },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 28c1808..64cbdab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,14 +48,11 @@ importers:
         specifier: ^2.14.2
         version: 2.14.2(vitest@1.5.0(@edge-runtime/vm@3.2.0)(@types/node@20.12.7))
 
-  examples/express:
+  examples/node-http:
     dependencies:
-      '@edge-csrf/express':
-        specifier: 2.1.0-rc1
-        version: 2.1.0-rc1(express@4.19.2)
-      express:
-        specifier: ^4.19.2
-        version: 4.19.2
+      '@edge-csrf/node-http':
+        specifier: ^0.0.0
+        version: link:../../packages/node-http
 
   packages/core: {}
 
@@ -87,11 +84,27 @@ importers:
         specifier: ^14.2.0
         version: 14.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
 
+  packages/node-http:
+    dependencies:
+      cookie:
+        specifier: ^0.6.0
+        version: 0.6.0
+    devDependencies:
+      '@types/cookie':
+        specifier: ^0.6.0
+        version: 0.6.0
+      '@types/supertest':
+        specifier: ^6.0.2
+        version: 6.0.2
+      supertest:
+        specifier: ^7.0.0
+        version: 7.0.0
+
   packages/sveltekit:
     devDependencies:
       '@sveltejs/kit':
         specifier: ^2.5.6
-        version: 2.5.6(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))
+        version: 2.5.6(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))
 
   shared: {}
 
@@ -120,11 +133,6 @@ packages:
   '@cloudflare/workers-types@4.20240419.0':
     resolution: {integrity: sha512-UM16sr4HEe0mDj6C5OFcodzdj/CnEp0bfncAq3g7OpDsoZ1sBrfsMrb7Yc4f8J81EemvmQZyE6sSanpURtVkcQ==}
 
-  '@edge-csrf/express@2.1.0-rc1':
-    resolution: {integrity: sha512-LHEe3WhOAcGSASbuoGDDJxcyYJFw37XToOp9/KTSPL6mmnYiDLcKKSKgO09XLnec3y/UfgtHgO1v+O6GLKL8Cw==}
-    peerDependencies:
-      express: ^4.0.0
-
   '@edge-runtime/primitives@4.1.0':
     resolution: {integrity: sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==}
     engines: {node: '>=16'}
@@ -786,6 +794,9 @@ packages:
   '@types/node@20.12.7':
     resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==}
 
+  '@types/node@20.14.2':
+    resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==}
+
   '@types/qs@6.9.15':
     resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==}
 
@@ -2726,11 +2737,6 @@ snapshots:
 
   '@cloudflare/workers-types@4.20240419.0': {}
 
-  '@edge-csrf/express@2.1.0-rc1(express@4.19.2)':
-    dependencies:
-      cookie: 0.6.0
-      express: 4.19.2
-
   '@edge-runtime/primitives@4.1.0': {}
 
   '@edge-runtime/vm@3.2.0':
@@ -3165,9 +3171,9 @@ snapshots:
 
   '@sinclair/typebox@0.27.8': {}
 
-  '@sveltejs/kit@2.5.6(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))':
+  '@sveltejs/kit@2.5.6(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))':
     dependencies:
-      '@sveltejs/vite-plugin-svelte': 3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))
+      '@sveltejs/vite-plugin-svelte': 3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))
       '@types/cookie': 0.6.0
       cookie: 0.6.0
       devalue: 4.3.3
@@ -3181,28 +3187,28 @@ snapshots:
       sirv: 2.0.4
       svelte: 4.2.15
       tiny-glob: 0.2.9
-      vite: 5.2.9(@types/node@20.12.7)
+      vite: 5.2.9(@types/node@20.14.2)
 
-  '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))':
+  '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))':
     dependencies:
-      '@sveltejs/vite-plugin-svelte': 3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))
+      '@sveltejs/vite-plugin-svelte': 3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))
       debug: 4.3.4
       svelte: 4.2.15
-      vite: 5.2.9(@types/node@20.12.7)
+      vite: 5.2.9(@types/node@20.14.2)
     transitivePeerDependencies:
       - supports-color
 
-  '@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))':
+  '@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))':
     dependencies:
-      '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.12.7))
+      '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.0(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2)))(svelte@4.2.15)(vite@5.2.9(@types/node@20.14.2))
       debug: 4.3.4
       deepmerge: 4.3.1
       kleur: 4.1.5
       magic-string: 0.30.10
       svelte: 4.2.15
       svelte-hmr: 0.16.0(svelte@4.2.15)
-      vite: 5.2.9(@types/node@20.12.7)
-      vitefu: 0.2.5(vite@5.2.9(@types/node@20.12.7))
+      vite: 5.2.9(@types/node@20.14.2)
+      vitefu: 0.2.5(vite@5.2.9(@types/node@20.14.2))
     transitivePeerDependencies:
       - supports-color
 
@@ -3275,6 +3281,10 @@ snapshots:
     dependencies:
       undici-types: 5.26.5
 
+  '@types/node@20.14.2':
+    dependencies:
+      undici-types: 5.26.5
+
   '@types/qs@6.9.15': {}
 
   '@types/range-parser@1.2.7': {}
@@ -3298,7 +3308,7 @@ snapshots:
     dependencies:
       '@types/cookiejar': 2.1.5
       '@types/methods': 1.1.4
-      '@types/node': 20.12.7
+      '@types/node': 20.14.2
 
   '@types/supertest@6.0.2':
     dependencies:
@@ -5361,9 +5371,18 @@ snapshots:
       '@types/node': 20.12.7
       fsevents: 2.3.3
 
-  vitefu@0.2.5(vite@5.2.9(@types/node@20.12.7)):
+  vite@5.2.9(@types/node@20.14.2):
+    dependencies:
+      esbuild: 0.20.2
+      postcss: 8.4.38
+      rollup: 4.14.3
     optionalDependencies:
-      vite: 5.2.9(@types/node@20.12.7)
+      '@types/node': 20.14.2
+      fsevents: 2.3.3
+
+  vitefu@0.2.5(vite@5.2.9(@types/node@20.14.2)):
+    optionalDependencies:
+      vite: 5.2.9(@types/node@20.14.2)
 
   vitest-environment-miniflare@2.14.2(vitest@1.5.0(@edge-runtime/vm@3.2.0)(@types/node@20.12.7)):
     dependencies: