Skip to content

Commit

Permalink
added tests, made fieldName configurable
Browse files Browse the repository at this point in the history
  • Loading branch information
amorey committed Sep 22, 2024
1 parent 12f57f4 commit 590e991
Show file tree
Hide file tree
Showing 15 changed files with 256 additions and 187 deletions.
1 change: 1 addition & 0 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ app.listen(port, () => {
saltByteLength: 8,
secretByteLength: 18,
token: {
fieldName: 'csrf_token',
responseHeader: 'X-CSRF-Token'
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/express/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as cookielib from 'cookie';
import type { Request as ExpressRequest, Response as ExpressResponse, RequestHandler as ExpressRequestHandler } from 'express';

import { CsrfError, createCsrfProtect as _createCsrfProtect, Config, TokenOptions } from '@shared/protect';
import type { ConfigOptions } from '@shared/protect';
import { Config, TokenOptions } from '@shared/config';
import type { ConfigOptions } from '@shared/config';
import { CsrfError, createCsrfProtect as _createCsrfProtect } from '@shared/protect';

export { CsrfError };

Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export const middleware = async (request: NextRequest) => {
saltByteLength: 8,
secretByteLength: 18,
token: {
fieldName: 'csrf_token',
responseHeader: 'X-CSRF-Token',
value: undefined
}
Expand Down
22 changes: 21 additions & 1 deletion packages/nextjs/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('csrfProtect integration tests', () => {
expect(newTokenStr).not.toBe('');
});

it('should handle server action non-form submissions', async () => {
it('should handle server action non-form submissions with string arg0', async () => {
const secret = util.createSecret(8);
const token = await util.createToken(secret, 8);

Expand All @@ -153,6 +153,26 @@ describe('csrfProtect integration tests', () => {
expect(newTokenStr).not.toBe('');
});

it('should handle server action non-form submissions with object arg0', async () => {
const secret = util.createSecret(8);
const token = await util.createToken(secret, 8);

const request = new NextRequest('http://example.com', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: JSON.stringify([{ csrf_token: util.utoa(token) }, 'arg']),
});
request.cookies.set('_csrfSecret', util.utoa(secret));

const response = NextResponse.next();
await csrfProtectDefault(request, response);

// assertions
const newTokenStr = response.headers.get('X-CSRF-Token');
expect(newTokenStr).toBeDefined();
expect(newTokenStr).not.toBe('');
});

it('should fail with token from different secret', async () => {
const evilSecret = util.createSecret(8);
const goodSecret = util.createSecret(8);
Expand Down
5 changes: 3 additions & 2 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { NextRequest } from 'next/server';
// eslint-disable-next-line import/no-extraneous-dependencies
import { NextResponse } from 'next/server';

import { CsrfError, createCsrfProtect as _createCsrfProtect, Config, TokenOptions } from '@shared/protect';
import type { ConfigOptions } from '@shared/protect';
import { Config, TokenOptions } from '@shared/config';
import type { ConfigOptions } from '@shared/config';
import { CsrfError, createCsrfProtect as _createCsrfProtect } from '@shared/protect';

export { CsrfError };

Expand Down
1 change: 1 addition & 0 deletions packages/node-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Check out the example Node-HTTP server in this repository: [Node-HTTP example](e
saltByteLength: 8,
secretByteLength: 18,
token: {
fieldName: 'csrf_token',
responseHeader: 'X-CSRF-Token'
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/node-http/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ 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';
import { Config, TokenOptions } from '@shared/config';
import type { ConfigOptions } from '@shared/config';
import { CsrfError, createCsrfProtect as _createCsrfProtect } from '@shared/protect';

export { CsrfError };

Expand Down
1 change: 1 addition & 0 deletions packages/sveltekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export const handle: Handle = async ({ event, resolve }) => {
saltByteLength: 8,
secretByteLength: 18,
token: {
fieldName: 'csrf_token',
value: undefined
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/sveltekit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Handle, RequestEvent, Cookies } from '@sveltejs/kit';

import { CsrfError, createCsrfProtect as _createCsrfProtect, Config, TokenOptions } from '@shared/protect';
import type { ConfigOptions } from '@shared/protect';
import { Config, TokenOptions } from '@shared/config';
import type { ConfigOptions } from '@shared/config';
import { CsrfError, createCsrfProtect as _createCsrfProtect } from '@shared/protect';

export { CsrfError };

Expand Down
90 changes: 90 additions & 0 deletions shared/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Config, CookieOptions, TokenOptions } from './config';
import type { ConfigOptions } from './config';

describe('CookieOptions tests', () => {
it('returns default values when options are absent', () => {
const cookieOpts = new CookieOptions();
expect(cookieOpts.domain).toEqual('');
expect(cookieOpts.httpOnly).toEqual(true);
expect(cookieOpts.maxAge).toEqual(undefined);
expect(cookieOpts.name).toEqual('_csrfSecret');
expect(cookieOpts.partitioned).toEqual(undefined);
expect(cookieOpts.path).toEqual('/');
expect(cookieOpts.sameSite).toEqual('strict');
expect(cookieOpts.secure).toEqual(true);
});

it('handles overrides', () => {
const cookieOpts = new CookieOptions({ domain: 'xxx' });
expect(cookieOpts.domain).toEqual('xxx');
});
});

describe('TokenOptions tests', () => {
it('returns default values when options are absent', () => {
const tokenOpts = new TokenOptions();
expect(tokenOpts.fieldName).toEqual('csrf_token');
expect(tokenOpts.value).toEqual(undefined);
});

it('handles overrides', () => {
const fn = async () => '';
const tokenOpts = new TokenOptions({
fieldName: 'csrfToken',
value: fn,
});
expect(tokenOpts.fieldName).toEqual('csrfToken');
expect(tokenOpts.value).toBe(fn);
});
});

describe('Config tests', () => {
const initConfigFn = (opts: Partial<ConfigOptions>) => () => new Config(opts);

it('returns default config when options are absent', () => {
const config = new Config();
expect(config.excludePathPrefixes).toEqual([]);
expect(config.ignoreMethods).toEqual(['GET', 'HEAD', 'OPTIONS']);
expect(config.saltByteLength).toEqual(8);
expect(config.secretByteLength).toEqual(18);
expect(config.cookie instanceof CookieOptions).toBe(true);
expect(config.token instanceof TokenOptions).toBe(true);
});

it('handles top-level overrides', () => {
const config = new Config({ saltByteLength: 10 });
expect(config.saltByteLength).toEqual(10);
});

it('handles nested cookie overrides', () => {
const config = new Config({ cookie: { domain: 'xxx' } });
expect(config.cookie.domain).toEqual('xxx');
});

it('handles nested token overrides', () => {
const fn = async () => '';
const config = new Config({ token: { fieldName: 'csrfToken', value: fn } });
expect(config.token.fieldName).toEqual('csrfToken');
expect(config.token.value).toBe(fn);
});

it('saltByteLength must be greater than 0', () => {
expect(initConfigFn({ saltByteLength: 0 })).toThrow(Error);
expect(initConfigFn({ saltByteLength: 1 })).not.toThrow(Error);
});

it('saltByteLength must be less than 256', () => {
expect(initConfigFn({ saltByteLength: 256 })).toThrow(Error);
expect(initConfigFn({ saltByteLength: 255 })).not.toThrow(Error);
});

it('secretByteLength must be greater than 0', () => {
expect(initConfigFn({ secretByteLength: 0 })).toThrow(Error);
expect(initConfigFn({ secretByteLength: 1 })).not.toThrow(Error);
});

it('secretByteLength must be less than 256', () => {
expect(initConfigFn({ secretByteLength: 256 })).toThrow(Error);
expect(initConfigFn({ secretByteLength: 255 })).not.toThrow(Error);
});
});
90 changes: 90 additions & 0 deletions shared/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Represents cookie options in config
*/
export class CookieOptions {
domain: string = '';

httpOnly: boolean = true;

maxAge: number | undefined = undefined;

name: string = '_csrfSecret';

partitioned: boolean | undefined = undefined;

path: string = '/';

sameSite: boolean | 'none' | 'strict' | 'lax' = 'strict';

secure: boolean = true;

constructor(opts?: Partial<CookieOptions>) {
Object.assign(this, opts);
}
}

/**
* Represents a function to retrieve token value from a request
*/
export type TokenValueFunction = {
(request: Request): Promise<string>
};

/**
* Represents token options in config
*/
export class TokenOptions {
readonly fieldName: string = 'csrf_token';

value: TokenValueFunction | undefined = undefined;

_fieldNameRegex: RegExp;

constructor(opts?: Partial<TokenOptions>) {
Object.assign(this, opts);

// create fieldname regex
this._fieldNameRegex = new RegExp(`^(\\d+_)*${this.fieldName}$`);
}
}

/**
* Represents CsrfProtect configuration object
*/
export class Config {
excludePathPrefixes: string[] = [];

ignoreMethods: string[] = ['GET', 'HEAD', 'OPTIONS'];

saltByteLength: number = 8;

secretByteLength: number = 18;

cookie: CookieOptions = new CookieOptions();

token: TokenOptions = new TokenOptions();

constructor(opts?: Partial<ConfigOptions>) {
const newOpts = opts || {};
if (newOpts.cookie) newOpts.cookie = new CookieOptions(newOpts.cookie);
if (newOpts.token) newOpts.token = new TokenOptions(newOpts.token);
Object.assign(this, newOpts);

// basic validation
if (this.saltByteLength < 1 || this.saltByteLength > 255) {
throw Error('saltBytLength must be greater than 0 and less than 256');
}

if (this.secretByteLength < 1 || this.secretByteLength > 255) {
throw Error('secretBytLength must be greater than 0 and less than 256');
}
}
}

/**
* Represents CsrfProtect configuration options object
*/
export interface ConfigOptions extends Omit<Config, 'cookie' | 'token'> {
cookie: Partial<CookieOptions>;
token: Partial<TokenOptions>;
}
87 changes: 3 additions & 84 deletions shared/src/protect.test.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,10 @@
import { vi } from 'vitest';

import { Config, CookieOptions, CsrfError, TokenOptions, createCsrfProtect } from './protect';
import type { ConfigOptions, Cookie, CsrfProtectArgs } from './protect';
import { Config, CookieOptions } from './config';
import { CsrfError, createCsrfProtect } from './protect';
import type { Cookie, CsrfProtectArgs } from './protect';
import * as util from './util';

describe('CookieOptions tests', () => {
it('returns default values when options are absent', () => {
const cookieOpts = new CookieOptions();
expect(cookieOpts.domain).toEqual('');
expect(cookieOpts.httpOnly).toEqual(true);
expect(cookieOpts.maxAge).toEqual(undefined);
expect(cookieOpts.name).toEqual('_csrfSecret');
expect(cookieOpts.partitioned).toEqual(undefined);
expect(cookieOpts.path).toEqual('/');
expect(cookieOpts.sameSite).toEqual('strict');
expect(cookieOpts.secure).toEqual(true);
});

it('handles overrides', () => {
const cookieOpts = new CookieOptions({ domain: 'xxx' });
expect(cookieOpts.domain).toEqual('xxx');
});
});

describe('TokenOptions tests', () => {
it('returns default values when options are absent', () => {
const tokenOpts = new TokenOptions();
expect(tokenOpts.value).toEqual(undefined);
});

it('handles overrides', () => {
const fn = async () => '';
const tokenOpts = new TokenOptions({ value: fn });
expect(tokenOpts.value).toBe(fn);
});
});

describe('Config tests', () => {
const initConfigFn = (opts: Partial<ConfigOptions>) => () => new Config(opts);

it('returns default config when options are absent', () => {
const config = new Config();
expect(config.excludePathPrefixes).toEqual([]);
expect(config.ignoreMethods).toEqual(['GET', 'HEAD', 'OPTIONS']);
expect(config.saltByteLength).toEqual(8);
expect(config.secretByteLength).toEqual(18);
expect(config.cookie instanceof CookieOptions).toBe(true);
expect(config.token instanceof TokenOptions).toBe(true);
});

it('handles top-level overrides', () => {
const config = new Config({ saltByteLength: 10 });
expect(config.saltByteLength).toEqual(10);
});

it('handles nested cookie overrides', () => {
const config = new Config({ cookie: { domain: 'xxx' } });
expect(config.cookie.domain).toEqual('xxx');
});

it('handles nested token overrides', () => {
const fn = async () => '';
const config = new Config({ token: { value: fn } });
expect(config.token.value).toBe(fn);
});

it('saltByteLength must be greater than 0', () => {
expect(initConfigFn({ saltByteLength: 0 })).toThrow(Error);
expect(initConfigFn({ saltByteLength: 1 })).not.toThrow(Error);
});

it('saltByteLength must be less than 256', () => {
expect(initConfigFn({ saltByteLength: 256 })).toThrow(Error);
expect(initConfigFn({ saltByteLength: 255 })).not.toThrow(Error);
});

it('secretByteLength must be greater than 0', () => {
expect(initConfigFn({ secretByteLength: 0 })).toThrow(Error);
expect(initConfigFn({ secretByteLength: 1 })).not.toThrow(Error);
});

it('secretByteLength must be less than 256', () => {
expect(initConfigFn({ secretByteLength: 256 })).toThrow(Error);
expect(initConfigFn({ secretByteLength: 255 })).not.toThrow(Error);
});
});

describe('csrfProtect tests', () => {
let createSecretMock = vi.spyOn(util, 'createSecret');
let getTokenStringMock = vi.spyOn(util, 'getTokenString');
Expand Down
Loading

0 comments on commit 590e991

Please sign in to comment.