diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..4661027 --- /dev/null +++ b/.eslintrc @@ -0,0 +1 @@ +extends: fxa/server diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..e8f9abb --- /dev/null +++ b/.jscsrc @@ -0,0 +1,19 @@ +{ + "disallowKeywords": ["with", "eval"], + "disallowKeywordsOnNewLine": ["else"], + "disallowMultipleLineStrings": true, + "disallowSpaceAfterObjectKeys": true, + "disallowSpaceAfterPrefixUnaryOperators": ["++", "--", "+", "-"], + "disallowSpaceBeforePostfixUnaryOperators": ["++", "--"], + "maximumLineLength": 160, + "requireCapitalizedConstructors": true, + "requireCurlyBraces": ["for", "while", "do"], + "requireLineFeedAtFileEnd": true, + "requireSpaceAfterKeywords": ["if", "else", "for", "while", "do", "switch", "return"], + "requireSpaceAfterBinaryOperators": ["=", ",", "+", "-", "/", "*", "==", "===", "!=", "!=="], + "requireSpaceAfterPrefixUnaryOperators": ["~"], + "requireSpacesInConditionalExpression": true, + "requireSpaceBeforeBinaryOperators": ["+", "-", "/", "*", "=", "==", "===", "!=", "!=="], + "validateIndentation": 2, + "validateQuoteMarks": "'" +} \ No newline at end of file diff --git a/.nsprc b/.nsprc new file mode 100644 index 0000000..fb1e6bb --- /dev/null +++ b/.nsprc @@ -0,0 +1,4 @@ +{ + "exceptions": [ + ] +} diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..98666f9 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,13 @@ +module.exports = function (grunt) { + require('load-grunt-tasks')(grunt) + + grunt.initConfig({ + pkg: grunt.file.readJSON('./package.json'), + // .js files for ESLint, JSHint, JSCS, etc. + mainJsFiles: '{,grunttasks/,lib/**/,test/**/}*.js' + }) + + grunt.loadTasks('grunttasks') + + grunt.registerTask('default', ['lint', 'nsp']) +} diff --git a/README.md b/README.md index 1473ad4..1571882 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,29 @@ # hapi-hpkp -Hapi module to add HPKP headers to request +Inspired by [Helmetjs](https://github.com/helmetjs/hpkp), this is a Hapi module to add HPKP headers to all requests. + +## Example + +```javascript +var hpkp = require('./index') +var Hapi = require('hapi') + +var hpkpOptions = { + maxAge: 1, // In seconds + sha256s: ["orlando=", "magic="], // Array of sha256 + includeSubdomains: true, // optional + reportUri: 'http://test.site', // optional + reportOnly: false // optional +} + +var server = new Hapi.Server() + +// Register HPKP plugin +server.register({ + register: hpkp, + options: hpkpOptions +}, function (err) { + if (err) { + console.error('Failed to load plugin:', err) + } +}) +``` diff --git a/grunttasks/eslint.js b/grunttasks/eslint.js new file mode 100644 index 0000000..392d560 --- /dev/null +++ b/grunttasks/eslint.js @@ -0,0 +1,13 @@ +module.exports = function (grunt) { + 'use strict' + + grunt.config('eslint', { + options: { + eslintrc: '.eslintrc' + }, + files: [ + '{,grunttasks/,lib/**/,test/**/}*.js' + ] + }) + grunt.registerTask('quicklint', 'lint the modified files', 'newer:eslint') +} diff --git a/grunttasks/jscs.js b/grunttasks/jscs.js new file mode 100644 index 0000000..3876dbe --- /dev/null +++ b/grunttasks/jscs.js @@ -0,0 +1,12 @@ +module.exports = function (grunt) { + 'use strict' + + grunt.config('jscs', { + app: [ + '<%= mainJsFiles %>' + ], + options: { + config: '.jscsrc' + } + }) +} diff --git a/grunttasks/lint.js b/grunttasks/lint.js new file mode 100644 index 0000000..804f0a3 --- /dev/null +++ b/grunttasks/lint.js @@ -0,0 +1,10 @@ +// meta grunt task to run other linters. + +module.exports = function (grunt) { + 'use strict' + + grunt.registerTask('lint', [ + 'eslint', + 'jscs' + ]) +} diff --git a/grunttasks/nsp.js b/grunttasks/nsp.js new file mode 100644 index 0000000..e61828b --- /dev/null +++ b/grunttasks/nsp.js @@ -0,0 +1,8 @@ +module.exports = function (grunt) { + 'use strict' + + grunt.config('nsp', { + output: 'summary', + package: grunt.file.readJSON('package.json') + }) +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..f057227 --- /dev/null +++ b/index.js @@ -0,0 +1,17 @@ +/** + * Hapi middleware to append HPKP headers to all responses. + * + */ +'use strict' + +var hpkp = require('./lib/hpkp') + +exports.register = function (server, options, next) { + server.ext('onPreResponse', hpkp(options)) + + next() +} + +exports.register.attributes = { + pkg: require('./package.json') +} diff --git a/lib/hpkp.js b/lib/hpkp.js new file mode 100644 index 0000000..b1abe3e --- /dev/null +++ b/lib/hpkp.js @@ -0,0 +1,60 @@ +/** + * Hapi middleware to append HPKP headers to all responses. + * + */ +var Joi = require('joi') + +module.exports = function (options) { + + var optionsSchema = Joi.object().keys({ + maxAge: Joi.number().min(0).required(), + sha256s: Joi.array().min(1).items(Joi.string()).required(), + reportUri: Joi.string().uri().optional(), + reportOnly: Joi.boolean().optional(), + includeSubdomains: Joi.boolean().optional() + }) + + var error = optionsSchema.validate(options).error + if (error) { + throw new Error(error) + } + + var sha256s = options.sha256s + var maxAge = options.maxAge + var includeSubdomains = options.includeSubdomains + var reportOnly = options.reportOnly + var reportUri = options.reportUri + + var hpkpParts = [] + + sha256s.forEach(function (shaPin) { + hpkpParts.push('pin-sha256="' + shaPin + '"') + }) + + hpkpParts.push('max-age=' + maxAge) + + if (includeSubdomains) { + hpkpParts.push('includeSubdomains') + } + + if (reportUri) { + hpkpParts.push('report-uri="' + reportUri + '"') + } + + var hpkpHeaderKey = 'Public-Key-Pins' + if (reportOnly) { + hpkpHeaderKey = 'Public-Key-Pins-Report-Only' + } + + var hpkpHeader = hpkpParts.join('; ') + + return function (request, reply) { + var response = request.response + + if (response.header) { + response.header(hpkpHeaderKey, hpkpHeader) + } + + return reply.continue() + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a71a0e5 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "hapi-hpkp", + "version": "1.0.0", + "description": "Hapi module to add HPKP headers", + "main": "index.js", + "scripts": { + "test": "node ./node_modules/.bin/mocha tests/index.js", + "lint": "grunt lint" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vbudhram/hapi-hpkp.git" + }, + "keywords": [ + "hapi", + "hpkp" + ], + "author": "Vijay Budhram", + "license": "MIT", + "bugs": { + "url": "https://github.com/vbudhram/hapi-hpkp/issues" + }, + "homepage": "https://github.com/vbudhram/hapi-hpkp#readme", + "dependencies": { + "joi": "9.0.4" + }, + "devDependencies": { + "chai": "3.5.0", + "eslint-config-fxa": "2.1.0", + "hapi": "15.0.2", + "grunt": "1.0.1", + "grunt-eslint": "19.0.0", + "grunt-jscs": "3.0.1", + "grunt-nsp": "2.3.1", + "load-grunt-tasks": "3.5.2", + "mocha": "3.0.2" + } +} diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..5006642 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,136 @@ +var assert = require('chai').assert +var hpkp = require('../lib/hpkp') +var Hapi = require('hapi') +var server + +function createServer(port, hpkpOptions) { + server = new Hapi.Server() + server.connection({ + port: port + }) + + server.register({ + register: require('../index.js'), + options: hpkpOptions + }, function (err) { + if (err) { + console.error('Failed to load plugin:', err) + } + }); + + server.route({ + method: 'GET', + path: '/', + handler: function (request, reply) { + return reply('HPKP!') + } + }) + + server.start() + + return server +} + +var sha256s = [ + 'orlando=', + 'magic=' +] + +var passingTestCases = [ + { + name: 'should process minimum HPKP header', + options: { + maxAge: 1, + sha256s: sha256s + }, + expectedKey: 'public-key-pins', + expectedHeader: 'pin-sha256="orlando="; pin-sha256="magic="; max-age=1' + }, + { + name: 'should process with includeSubdomains', + options: { + maxAge: 1, + sha256s: sha256s, + includeSubdomains: true + }, + expectedKey: 'public-key-pins', + expectedHeader: 'pin-sha256="orlando="; pin-sha256="magic="; max-age=1; includeSubdomains' + }, + { + name: 'should process with reportOnly', + options: { + maxAge: 1, + sha256s: sha256s, + reportOnly: true + }, + expectedKey: 'public-key-pins-report-only', + expectedHeader: 'pin-sha256="orlando="; pin-sha256="magic="; max-age=1' + }, + { + name: 'should process with report-uri', + options: { + maxAge: 1, + sha256s: sha256s, + reportOnly: true, + reportUri: 'http://test.site' + }, + expectedKey: 'public-key-pins-report-only', + expectedHeader: 'pin-sha256="orlando="; pin-sha256="magic="; max-age=1; report-uri="http://test.site"' + } +] + +describe('HPKP Headers', function () { + passingTestCases.forEach(function (testCase) { + var server + var requestOptions = { + method: "GET", + url: "/" + } + before(function () { + server = createServer(3000, testCase.options) + }) + + after(function () { + return server.stop() + }) + it(testCase.name, function (done) { + server.inject(requestOptions, function (response) { + assert.equal(response.headers[testCase.expectedKey], testCase.expectedHeader) + done() + }) + }) + }) +}) + +var failingTestCases = [ + { + name: 'should throw without any options', + options: {}, + message: 'ValidationError: child "maxAge" fails because ["maxAge" is required]' + }, + { + name: 'should throw without sha256s', + options: { + maxAge: 1 + }, + message: 'ValidationError: child "sha256s" fails because ["sha256s" is required]' + }, + { + name: 'should throw with empty sha256s', + options: { + maxAge: 1, + sha256s: [] + }, + message: 'ValidationError: child "sha256s" fails because ["sha256s" must contain at least 1 items]' + } +] + +describe('HPKP Config', function () { + failingTestCases.forEach(function (testCase) { + it(testCase.name, function () { + assert.throws(function () { + hpkp(testCase.options) + }, testCase.message, 'threw error') + }) + }) +})