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: