Skip to content

Commit

Permalink
feat: implement CSRF
Browse files Browse the repository at this point in the history
* feat(csrf): setup csrf token generation. Add basic tests

Install the csrf package, create csrf middleware function, handle disabled csrf cases, add tests for
csrf generation on every request'

fix #12

* feat(csrf): exclude csrf checks using request methods

When a user defines request methods in the csrf config, we run csrf checks only on those request
methods.

f#12

* feat(csrf): refactor csrf implementation to class

Remove generic exceptions and install @poppins/utils, add sessions package

f#12

* chore(csrf): add peer dependency for @adonisjs/session

* chore(csrf): add comments to classes and functions

* feat(csrf): implement filterUris for csrf verification

Install path-to-regexp package, create method to verify url does not match any defined in filter

fix #12

* feat(csrf): share csrf field and token with view

Install @adonisjs/view as a dev package, add tests to make sure view local data is correctly shared,
and tests to make sure secure cookie is added to response'

fix #12

* chore(csrf): remove path-to-regexp package

* feat(csrf): change filterUris option in csrf config to exceptRoutes

fix #12

* feat(csrf): add meta helper for meta tag with csrf token

fix #12

* feat(csrf): change cookie name from x-xsrf-token to xsrf-token

fix #12'

* chore(csrf): remove edge.js from dev dependency list
  • Loading branch information
thetutlage committed Mar 5, 2020
1 parent 46222a9 commit 4b45cf7
Show file tree
Hide file tree
Showing 11 changed files with 455 additions and 4 deletions.
6 changes: 6 additions & 0 deletions adonis-typings/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ declare module '@ioc:Adonis/Core/Response' {
nonce: string,
}
}

declare module '@ioc:Adonis/Core/Request' {
interface RequestContract {
csrfToken: string
}
}
12 changes: 12 additions & 0 deletions adonis-typings/shield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,16 @@ declare module '@ioc:Adonis/Addons/Shield' {
export type CspOptions = {
enabled: boolean,
} & HelmetCspOptions

export type CsrfOptions = {
enabled: boolean,
exceptRoutes?: string[],
methods?: ReadonlyArray<string>,
cookieOptions?: {
httpOnly?: boolean,
sameSite?: boolean,
path?: string,
maxAge?: number
}
}
}
4 changes: 3 additions & 1 deletion japaFile.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
process.env.TS_NODE_FILES = true

require('ts-node/register')

const { configure } = require('japa')
configure({
files: ['test/**/*.spec.ts']
files: ['test/**/*.spec.ts'],
})
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@
"csrf",
"csp"
],
"peerDependencies": {
"@adonisjs/session": "^2.x.x"
},
"author": "virk",
"license": "MIT",
"devDependencies": {
"@adonisjs/core": "^5.0.0-preview.2",
"@adonisjs/fold": "^6.3.4",
"@adonisjs/mrm-preset": "^2.2.4",
"@adonisjs/session": "^2.3.3",
"@adonisjs/view": "^1.0.10",
"@poppinss/dev-utils": "^1.0.4",
"@types/csrf": "^1.3.2",
"@types/node": "^13.7.7",
"commitizen": "^4.0.3",
"cz-conventional-changelog": "^3.1.0",
Expand All @@ -40,6 +48,8 @@
"typescript": "^3.8.3"
},
"dependencies": {
"@poppinss/utils": "^2.1.2",
"csrf": "^3.1.0",
"helmet-csp": "^2.9.5",
"ms": "^2.1.2"
},
Expand Down
208 changes: 208 additions & 0 deletions src/csrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
* @adonisjs/shield
*
* (c) ? (Please advice before merge. Thanks !)
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

/// <reference path="../adonis-typings/index.ts" />

import Tokens from 'csrf'
import { unpack } from '@poppinss/cookie'
import { Exception } from '@poppinss/utils'
import { CsrfOptions } from '@ioc:Adonis/Addons/Shield'
import { RequestContract } from '@ioc:Adonis/Core/Request'
import { SessionContract } from '@ioc:Adonis/Addons/Session'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

import { noop } from './noop'

const Csrf = new Tokens()

/**
* A wrapper around all the functionality
* for handling csrf verification
*
*/
export class CsrfMiddleware {
/**
* The session instance of the application.
* This would be injected from the
* http context.
*/
public session: SessionContract

/**
* Csrf configurations defined by the
* user in the shield.js file.
*/
private options: CsrfOptions

/**
* The application key defined as APP_SECRET
* in environment variables. This would
* be injected from the config.
*/
private applicationKey: string

constructor (session: SessionContract, options: CsrfOptions, applicationKey: string) {
this.session = session
this.options = options
this.applicationKey = applicationKey
}

/**
* Get the request method, check if the user defined
* methods allowed for csrf verification in the
* config. If it did, check if the request
* method is one of the allowed. If not,
* return false.
*/
private requestMethodShouldEnforceCsrf (request: RequestContract): boolean {
const method = request.method().toLowerCase()

if (!this.options.methods || this.options.methods.length === 0) {
return true
}

return this.options.methods
.filter(definedMethod => definedMethod.toLowerCase() === method)
.length > 0
}

/**
* Check if the current request url has been
* excluded from csrf protection.
*/
private requestUrlShouldEnforceCsrf (ctx: HttpContextContract): boolean {
if (!this.options.exceptRoutes || this.options.exceptRoutes.length === 0) {
return true
}

return !this.options.exceptRoutes.includes(ctx.route!.pattern)
}

/**
* Check if csrf secret has been saved to
* session. If not, generate a new one,
* save it to session, and return it.
*/
public async getCsrfSecret (): Promise<string> {
let csrfSecret = this.session.get('csrf-secret')

if (!csrfSecret) {
csrfSecret = await Csrf.secret()

this.session.put('csrf-secret', csrfSecret)
}

return csrfSecret
}

/**
* Extract the csrf token from the request by
* checking headers and inputs. Decode the
* token if it was encrypted.
*/
private getCsrfTokenFromRequest (request: RequestContract): string|null {
const token = request.input('_csrf') || request.header('x-csrf-token')

if (token) {
return token
}

const encryptedToken = request.header('x-xsrf-token')
const unpackedToken = encryptedToken ? unpack(token, this.applicationKey) : null

return unpackedToken ? unpackedToken.value : null
}

/**
* Generate a new csrf token using
* the csrf secret extracted
* from session.
*/
public generateCsrfToken (csrfSecret): string {
return Csrf.create(csrfSecret)
}

/**
* Set the xsrf cookie on
* response
*/
private setXsrfCookie (ctx: HttpContextContract): void {
ctx.response.cookie('xsrf-token', ctx.request.csrfToken)
}

/**
* Set the csrf token on
* request
*/
private setCsrfToken (ctx: HttpContextContract, csrfSecret: string): void {
ctx.request.csrfToken = this.generateCsrfToken(csrfSecret)
}

/**
* This would make a csrfToken variable available to
* the edge view templates. This would also create
* a helpful method called csrfField to be used
* on the frontend to generate a hidden
* field called _csrf
*/
private shareCsrfViewLocals (ctx: HttpContextContract): void {
if (!ctx.view) {
return
}

ctx.view.share({
csrfToken: ctx.request.csrfToken,
csrfMeta: (compilerContext) => compilerContext.safe(`<meta name='csrf-token' content='${ctx.request.csrfToken}'>`),
csrfField: (compilerContext) => compilerContext.safe(`<input type='hidden' name='_csrf' value='${ctx.request.csrfToken}'>`),
})
}

/**
* Handle csrf verification. First, get the secret,
* next, check if the request method should be
* verified. Next, attach the newly generated
* csrf token to the request object.
*/
public async handle (ctx: HttpContextContract): Promise<void> {
const { request } = ctx

const csrfSecret = await this.getCsrfSecret()

if (this.requestMethodShouldEnforceCsrf(request) && this.requestUrlShouldEnforceCsrf(ctx)) {
const csrfToken = this.getCsrfTokenFromRequest(request)

if (!csrfToken || !Csrf.verify(csrfSecret, csrfToken)) {
throw new Exception('Invalid CSRF Token', 403, 'E_BAD_CSRF_TOKEN')
}
}

this.setCsrfToken(ctx, csrfSecret)

this.setXsrfCookie(ctx)

this.shareCsrfViewLocals(ctx)
}
}

/**
* Check if csrf is enabled. If yes, verifies the
* old token and generates a new one for
* the next request.
*/
export function csrf (options: CsrfOptions, applicationKey: string) {
if (!options.enabled) {
return noop
}

return async function csrfMiddlewareFn (ctx: HttpContextContract) {
const csrfMiddleware = new CsrfMiddleware(ctx.session, options, applicationKey)

return csrfMiddleware.handle(ctx)
}
}
85 changes: 83 additions & 2 deletions test-helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,94 @@
* file that was distributed with this source code.
*/

import { HttpContext } from '@adonisjs/http-server/build/standalone'
import { Socket } from 'net'
import { join } from 'path'
import { Ioc } from '@adonisjs/fold'
import { CsrfMiddleware } from '../src/csrf'
import { Filesystem } from '@poppinss/dev-utils'
import { CsrfOptions } from '@ioc:Adonis/Addons/Shield'
import { IncomingMessage, IncomingHttpHeaders } from 'http'
import { FakeLogger } from '@adonisjs/logger/build/standalone'
import { Profiler } from '@adonisjs/profiler/build/standalone'
import { SessionConfigContract } from '@ioc:Adonis/Addons/Session'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { HttpContext } from '@adonisjs/http-server/build/standalone'
import ViewProvider from '@adonisjs/view/build/providers/ViewProvider'
import { SessionManager } from '@adonisjs/session/build/src/SessionManager'

export const Fs = new Filesystem(join(__dirname, 'views'))

const logger = new FakeLogger({ level: 'trace', enabled: false, name: 'adonisjs' })
const profiler = new Profiler(__dirname, logger, {})
const sessionConfig: SessionConfigContract = {
driver: 'cookie',
cookieName: 'adonis-session',
clearWithBrowser: false,
age: '2h',
cookie: {
path: '/',
},
}

export function getCtx () {
return HttpContext.create('/', {}, logger, profiler.create(''), {} as any)
return HttpContext.create('/', {}, logger, profiler.create(''), {} as any) as HttpContextContract
}

function getContainerWithViews () {
const container = new Ioc()

const viewProvider = new ViewProvider(container)

viewProvider.register()

container.bind('Adonis/Core/Env', () => ({
get () {
return true
},
}))

container.bind('Adonis/Core/Application', () => ({
viewsPath () {
return Fs.basePath
},
}))

return container
}

export async function getCtxWithSession (routePath: string = '/', routeParams = {}, request?: IncomingMessage) {
const container = getContainerWithViews()
HttpContext.getter('session', function session () {
const sessionManager = new SessionManager(container, sessionConfig)

return sessionManager.create(this)
}, true)

HttpContext.getter('view', function view () {
return container.use('Adonis/Core/View').share({ request: this.request, route: this.route })
}, true)

const httpContext = HttpContext.create(
routePath,
routeParams,
logger,
profiler.create(''),
{} as any,
request
) as HttpContextContract

await httpContext.session.initiate(false)

return httpContext
}

export function getCtxFromIncomingMessage (headers: IncomingHttpHeaders = {}, routePath = '/', routeParams = {}) {
const request = new IncomingMessage(new Socket())
request.headers = headers

return getCtxWithSession(routePath, routeParams, request)
}

export async function getCsrfMiddlewareInstance (options: CsrfOptions, applicationKey: string) {
return new CsrfMiddleware((await getCtxWithSession()).session, options, applicationKey)
}
1 change: 1 addition & 0 deletions test-helpers/views/token-function.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Csrf Field: {{ csrfField() }}
1 change: 1 addition & 0 deletions test-helpers/views/token-meta.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Csrf Meta: {{ csrfMeta() }}
1 change: 1 addition & 0 deletions test-helpers/views/token.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Csrf Token: {{ csrfToken }}

0 comments on commit 4b45cf7

Please sign in to comment.