diff --git a/README.md b/README.md index f8539a8c..008da171 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ There's also a JavaScript API, taglib and a collection of plugins to make your j - [Complete Configuration](#complete-configuration) - [Node.js-style Module Support](#nodejs-style-module-support) - [No Conflict Builds](#no-conflict-builds) +- [Content Security Policy Support](#content-security-policy-support) - [Available Plugins](#available-plugins) - [Extending Lasso.js](#extending-lassojs) - [Custom Plugins](#custom-plugins) @@ -161,7 +162,6 @@ There's also a JavaScript API, taglib and a collection of plugins to make your j * Optional Base64 image encoding inside CSS files * Custom output transforms * Declarative browser-side package dependencies using simple `browser.json` files - * Generates the HTML markup required to include bundled resources * Conditional dependencies * Image minification * etc. @@ -174,6 +174,7 @@ There's also a JavaScript API, taglib and a collection of plugins to make your j * Full support for [browserify](http://browserify.org/) shims and transforms * Maintains line numbers in wrapped code * Developer Friendly + * Generates the HTML markup required to include bundled resources * Disable bundling and minification in development * Line numbers are maintained for Node.js modules source * Extremely fast _incremental builds_! @@ -199,6 +200,8 @@ There's also a JavaScript API, taglib and a collection of plugins to make your j * Integrate with build tools * Use with Express or any other web development framework * JavaScript API, CLI and taglib +* Security + * Supports the [nonce attribute](https://www.w3.org/TR/CSP2/#script-src-the-nonce-attribute) when using Content Security Policy for extra security. * _Future_ * Automatic image sprites @@ -1359,6 +1362,79 @@ require('lasso').create({ See [Configuration](#configuration) for full list of configuration options. +# Content Security Policy Support + +Newer browsers support a web standard called Content Security Policy that prevents, among other things, cross-site scripting attacks by whitelisting inline ` + + + + +``` + +NOTE: A `nonce` attribute is only added to inline ` + +``` + +The output HTML will be similar to the following: + +```html + + +``` # Available Plugins diff --git a/lib/Config.js b/lib/Config.js index 6d304cb9..37509e7c 100644 --- a/lib/Config.js +++ b/lib/Config.js @@ -150,6 +150,7 @@ function Config(params) { this.cacheDir = null; this._requirePluginConfig = {}; this._imagePluginConfig = {}; + this.cspNonceProvider = null; if (params) { extend(this.params, params); @@ -383,6 +384,10 @@ Config.prototype = { getBundleReadTimeout: function() { return this.bundleReadTimeout; + }, + + setCSPNonceProvider: function(func) { + this.cspNonceProvider = func; } }; diff --git a/lib/Lasso.js b/lib/Lasso.js index 6e71f5f9..220eabea 100644 --- a/lib/Lasso.js +++ b/lib/Lasso.js @@ -576,11 +576,11 @@ Lasso.prototype = { }, getJavaScriptDependencyHtml: function(url) { - return ''; + return ''; }, getCSSDependencyHtml: function(url) { - return ''; + return ''; }, _resolveflags: function(options) { diff --git a/lib/LassoPageResult.js b/lib/LassoPageResult.js index 6561b12f..429321e6 100644 --- a/lib/LassoPageResult.js +++ b/lib/LassoPageResult.js @@ -1,12 +1,30 @@ var extend = require('raptor-util/extend'); +var marko = require('marko'); +var nodePath = require('path'); +var EMPTY_OBJECT = {}; + +function generateTempFilename(slotName) { + // Generates a unique filename based on date/time and, process ID and a random number + var now = new Date(); + return [ + slotName, + now.getYear(), + now.getMonth(), + now.getDate(), + process.pid, + (Math.random() * 0x100000000 + 1).toString(36) + ].join('-') + '.marko'; +} function LassoPageResult() { - this.htmlBySlot = {}; this.urlsBySlot = {}; this.urlsByContentType = {}; this.filesByContentType = {}; this.infoByBundleName = {}; this.infoByAsyncBundleName = {}; + this._htmlBySlot = {}; + + this._htmlTemplatesBySlot = {}; } LassoPageResult.deserialize = function(reader, callback) { @@ -51,8 +69,16 @@ LassoPageResult.prototype = { * * @return {Object} An object with slot names as property names and slot HTML as property values. */ - getHtmlBySlot: function() { - return this.htmlBySlot; + get htmlBySlot() { + var htmlBySlot = {}; + for (var slotName in this._htmlBySlot) { + if (this._htmlBySlot.hasOwnProperty(slotName)) { + var slotHtml = this.getHtmlForSlot(slotName); + htmlBySlot[slotName] = slotHtml; + } + } + + return htmlBySlot; }, /** @@ -64,26 +90,54 @@ LassoPageResult.prototype = { * * * @param {String} slotName The name of the slot (e.g. "head" or "body") + * @param {Object} data Input data to the slot that is used to render the actual slot HTML * @return {String} The HTML for the slot or an empty string if there is no HTML defined for the slot. */ - getHtmlForSlot: function(slotName) { - return this.htmlBySlot[slotName] || ''; + getHtmlForSlot: function(slotName, data) { + var template = this._getSlotTemplate(slotName); + if (!template) { + return ''; + } + return template.renderSync(data || EMPTY_OBJECT); }, + _getSlotTemplate: function(slotName) { + var template = this._htmlTemplatesBySlot[slotName]; + if (!template) { + var templateSrc = this._htmlBySlot[slotName]; + if (!templateSrc) { + return null; + } - getHeadHtml: function() { - return this.getHtmlForSlot('head'); + // In order to compile the HTML for the slot into a Marko template, we need to provide a faux + // template path. The path doesn't really matter unless the compiled template needs to import + // external tags or templates. + var templatePath = nodePath.resolve(__dirname, '..', generateTempFilename(slotName)); + template = marko.load(templatePath, templateSrc, { preserveWhitespace: true }); + // Cache the loaded template: + this._htmlTemplatesBySlot[slotName] = template; + + // The Marko template compiled to JS and required. Let's delete it out of the require cache + // to avoid a memory leak + delete require.cache[templatePath + '.js']; + } + + return template; }, - getBodyHtml: function() { - return this.getHtmlForSlot('body'); + getHeadHtml: function(data) { + return this.getHtmlForSlot('head', data); + }, + + getBodyHtml: function(data) { + return this.getHtmlForSlot('body', data); }, /** * Synonym for {@Link raptor/lasso/LassoPageResult#getHtmlForSlot} */ - getSlotHtml: function(slot) { - return this.getHtmlForSlot(slot); + getSlotHtml: function(slotName, data) { + return this.getHtmlForSlot(slotName, data); }, /** @@ -94,8 +148,15 @@ LassoPageResult.prototype = { return JSON.stringify(this.htmlBySlot, null, indentation); }, + toJSON: function() { + var clone = extend({}, this); + // Don't include the loaded templates when generating a JSON string + delete clone._htmlTemplatesBySlot; + return clone; + }, + setHtmlBySlot: function(htmlBySlot) { - this.htmlBySlot = htmlBySlot; + this._htmlBySlot = htmlBySlot; }, registerBundle: function(bundle, async, lassoContext) { diff --git a/lib/Slot.js b/lib/Slot.js index 0b085a9a..c90bd68a 100644 --- a/lib/Slot.js +++ b/lib/Slot.js @@ -4,7 +4,6 @@ function Slot(contentType) { ok(contentType, 'contentType is required'); this.contentType = contentType; this.content = []; - } Slot.prototype = { @@ -30,16 +29,16 @@ Slot.prototype = { code: content }); }, - + buildHtml: function() { var output = []; for (var i=0, len=this.content.length; i' + content.code + ''); + output.push(''); } else if (this.contentType === 'css') { - output.push(''); + output.push(''); } } else { output.push(content.code); diff --git a/lib/config-loader.js b/lib/config-loader.js index 19a1cc0f..54dd73f2 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -364,6 +364,14 @@ function load(options, baseDir, filename, configDefaults) { // dependencies. config._requirePluginConfig.unbundledTargetPrefix = value; } + }, + + cspNonceProvider: function(value) { + if (typeof value !== 'function') { + throw new Error('"cspNonceProvider" should be a function'); + } + + config.setCSPNonceProvider(value); } }; diff --git a/marko-taglib.json b/marko-taglib.json index 7e718a3a..8380a001 100644 --- a/marko-taglib.json +++ b/marko-taglib.json @@ -90,6 +90,16 @@ "transformer": { "path": "./taglib/lasso-resource-tag-transformer" } + }, + "*": { + "attributes": { + "lasso-nonce": { + "ignore": true + } + }, + "transformer": { + "path": "./taglib/lasso-nonce-attr-transformer" + } } } } diff --git a/package.json b/package.json index c3012945..b2400131 100644 --- a/package.json +++ b/package.json @@ -1,71 +1,71 @@ { - "name": "lasso", - "description": "Lasso.js is a build tool and runtime library for building and bundling all of the resources needed by a web application", - "repository": { - "type": "git", - "url": "https://github.com/lasso-js/lasso.git" - }, - "scripts": { - "test": "rm -rf .cache && node_modules/.bin/mocha --timeout 4000 --ui bdd --reporter spec ./test && node_modules/.bin/mocha --timeout 4000 --ui bdd --reporter spec ./test && node_modules/.bin/jshint lib/ taglib/" - }, - "author": "Patrick Steele-Idem ", - "maintainers": "Patrick Steele-Idem ", - "dependencies": { - "app-module-path": "^1.0.0", - "app-root-dir": "^1.0.0", - "async": "^0.9.0", - "browser-refresh-client": "^1.0.0", - "glob": "^4.0.6", - "jsonminify": "~0.2.3", - "lasso-image": "^1.0.9", - "lasso-minify-css": "^1.0.0", - "lasso-minify-js": "^1.1.4", - "lasso-require": "^1.4.0", - "lasso-resolve-css-urls": "^1.0.0", - "mime": "~1.2.7", - "mkdirp": "^0.5.0", - "property-handlers": "^1.0.0", - "raptor-async": "^1.1.0", - "raptor-cache": "^1.1.1", - "raptor-detect": "^1.0.0", - "raptor-dust": "^1.1.2", - "raptor-logging": "^1.0.1", - "raptor-modules": "^1.0.5", - "raptor-objects": "^1.0.1", - "raptor-polyfill": "^1.0.0", - "raptor-promises": "^1.0.1", - "raptor-regexp": "^1.0.0", - "raptor-strings": "^1.0.0", - "raptor-util": "^1.0.0", - "resolve-from": "^1.0.0", - "send": "^0.13.0", - "through": "^2.3.4" - }, - "devDependencies": { - "babel-core": "^6.0.20", - "babel-preset-es2015": "^6.0.15", - "chai": "~1.8.1", - "deamdify": "^0.1.1", - "dustjs-linkedin": "^2.4.0", - "fs-extra": "~0.12.0", - "jshint": "~2.3.0", - "lasso-dust": "^1.1.3", - "lasso-marko": "^2.0.1", - "marko": "^2.7.25", - "mocha": "~1.15.1" - }, - "license": "Apache License v2.0", - "main": "lib/index.js", - "publishConfig": { - "registry": "https://registry.npmjs.org/" - }, - "keywords": [ - "bundler", - "build", - "css", - "javascript", - "concat", - "minify" - ], - "version": "1.13.0" -} \ No newline at end of file + "name": "lasso", + "description": "Lasso.js is a build tool and runtime library for building and bundling all of the resources needed by a web application", + "repository": { + "type": "git", + "url": "https://github.com/lasso-js/lasso.git" + }, + "scripts": { + "test": "rm -rf .cache && node_modules/.bin/mocha --timeout 4000 --ui bdd --reporter spec ./test && node_modules/.bin/mocha --timeout 4000 --ui bdd --reporter spec ./test && node_modules/.bin/jshint lib/ taglib/" + }, + "author": "Patrick Steele-Idem ", + "maintainers": "Patrick Steele-Idem ", + "dependencies": { + "app-module-path": "^1.0.0", + "app-root-dir": "^1.0.0", + "async": "^0.9.0", + "browser-refresh-client": "^1.0.0", + "glob": "^4.0.6", + "jsonminify": "~0.2.3", + "lasso-image": "^1.0.9", + "lasso-minify-css": "^1.0.0", + "lasso-minify-js": "^1.1.4", + "lasso-require": "^1.4.0", + "lasso-resolve-css-urls": "^1.0.0", + "marko": "^2.8.0", + "mime": "~1.2.7", + "mkdirp": "^0.5.0", + "property-handlers": "^1.0.0", + "raptor-async": "^1.1.0", + "raptor-cache": "^1.1.1", + "raptor-detect": "^1.0.0", + "raptor-dust": "^1.1.2", + "raptor-logging": "^1.0.1", + "raptor-modules": "^1.0.5", + "raptor-objects": "^1.0.1", + "raptor-polyfill": "^1.0.0", + "raptor-promises": "^1.0.1", + "raptor-regexp": "^1.0.0", + "raptor-strings": "^1.0.0", + "raptor-util": "^1.0.0", + "resolve-from": "^1.0.0", + "send": "^0.13.0", + "through": "^2.3.4" + }, + "devDependencies": { + "babel-core": "^6.0.20", + "babel-preset-es2015": "^6.0.15", + "chai": "~1.8.1", + "deamdify": "^0.1.1", + "dustjs-linkedin": "^2.4.0", + "fs-extra": "~0.12.0", + "jshint": "~2.3.0", + "lasso-dust": "^1.1.3", + "lasso-marko": "^2.0.1", + "mocha": "~1.15.1" + }, + "license": "Apache License v2.0", + "main": "lib/index.js", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "keywords": [ + "bundler", + "build", + "css", + "javascript", + "concat", + "minify" + ], + "version": "1.14.0" +} diff --git a/taglib/LassoRenderContext.js b/taglib/LassoRenderContext.js index c8f0ec02..cf345da7 100644 --- a/taglib/LassoRenderContext.js +++ b/taglib/LassoRenderContext.js @@ -44,6 +44,11 @@ LassoRenderContext.prototype = { getWaitFor: function() { return this._waitFor; + }, + + getLassoConfig: function() { + var theLasso = this.data.lasso; + return theLasso.config; } }; diff --git a/taglib/helper-getNonce.js b/taglib/helper-getNonce.js new file mode 100644 index 00000000..42b8ca41 --- /dev/null +++ b/taglib/helper-getNonce.js @@ -0,0 +1,11 @@ +var util = require('./util'); + +module.exports = function(out) { + var lassoRenderContext = util.getLassoRenderContext(out); + var lassoConfig = lassoRenderContext.getLassoConfig(); + + var cspNonceProvider = lassoConfig.cspNonceProvider; + if (cspNonceProvider) { + return cspNonceProvider(out, lassoRenderContext); + } +}; \ No newline at end of file diff --git a/taglib/lasso-nonce-attr-transformer.js b/taglib/lasso-nonce-attr-transformer.js new file mode 100644 index 00000000..eaad0299 --- /dev/null +++ b/taglib/lasso-nonce-attr-transformer.js @@ -0,0 +1,16 @@ +var getNonceHelperPath = require.resolve('./helper-getNonce'); + +module.exports = function transform(node, compiler, template) { + if (node.hasAttribute('lasso-nonce')) { + + node.removeAttribute('lasso-nonce'); + + var getNonceRequirePath = template.getRequirePath(getNonceHelperPath); + + template.addStaticVar('__getNonce', + + 'require("' + getNonceRequirePath + '")'); + + node.setAttribute('nonce', template.makeExpression('__getNonce(out)')); + } +}; \ No newline at end of file diff --git a/taglib/page-tag.js b/taglib/page-tag.js index 25cded27..baa4b4da 100644 --- a/taglib/page-tag.js +++ b/taglib/page-tag.js @@ -7,14 +7,14 @@ var fs = require('fs'); var AsyncValue = require('raptor-async/AsyncValue'); var extend = require('raptor-util/extend'); -module.exports = function render(input, context) { +module.exports = function render(input, out) { var theLasso = input.lasso; if (!theLasso) { theLasso = lasso.defaultLasso; } - var lassoRenderContext = util.getLassoRenderContext(context); + var lassoRenderContext = util.getLassoRenderContext(out); var pageName = input.name || input.pageName; var cacheKey = input.cacheKey; @@ -40,7 +40,7 @@ module.exports = function render(input, context) { // may be needed to build the page correctly. Ultimately, during the optimization // phase, this data can be access using the "lassoContext.data" property var lassoContextData = { - renderContext: context + renderContext: out }; // The user of the tag may have also provided some additional data to add diff --git a/taglib/slot-tag.js b/taglib/slot-tag.js index 06aff958..a40f050a 100644 --- a/taglib/slot-tag.js +++ b/taglib/slot-tag.js @@ -1,18 +1,35 @@ var util = require('./util'); -function renderSlot(slotName, lassoPageResult, context, lassoContext) { - var slotHtml = lassoPageResult.getSlotHtml(slotName); + +function renderSlot(slotName, lassoPageResult, out, lassoRenderContext) { + + var lassoConfig = lassoRenderContext.getLassoConfig(); + + var cspNonceProvider = lassoConfig.cspNonceProvider; + var slotData = null; + + if (cspNonceProvider) { + var cspAttrs = { + nonce: cspNonceProvider(out) + }; + + slotData = { + inlineScriptAttrs: cspAttrs, + inlineStyleAttrs: cspAttrs + }; + } + var slotHtml = lassoPageResult.getSlotHtml(slotName, slotData); if (slotHtml) { - context.write(slotHtml); + out.write(slotHtml); } - lassoContext.emitAfterSlot(slotName, context); + lassoRenderContext.emitAfterSlot(slotName, out); } -module.exports = function render(input, context) { +module.exports = function render(input, out) { var slotName = input.name; - var lassoRenderContext = util.getLassoRenderContext(context); + var lassoRenderContext = util.getLassoRenderContext(out); var lassoPageResultAsyncValue = lassoRenderContext.data.lassoPageResult; var timeout = lassoRenderContext.data.timeout; @@ -20,12 +37,12 @@ module.exports = function render(input, context) { throw new Error('Lasso page result not found for slot "' + slotName + '". The tag should be used to lasso the page.'); } - lassoRenderContext.emitBeforeSlot(slotName, context); + lassoRenderContext.emitBeforeSlot(slotName, out); if (lassoPageResultAsyncValue.isResolved()) { - renderSlot(slotName, lassoPageResultAsyncValue.data, context, lassoRenderContext); + renderSlot(slotName, lassoPageResultAsyncValue.data, out, lassoRenderContext); } else { - var asyncContext = context.beginAsync({ + var asyncContext = out.beginAsync({ name: 'lasso-slot:' + slotName, timeout: timeout }); diff --git a/test/lasso-test.js b/test/lasso-test.js index 0ee1b5d0..d9c3f968 100644 --- a/test/lasso-test.js +++ b/test/lasso-test.js @@ -1510,4 +1510,34 @@ describe('lasso/index', function() { }) .done(); }); + + it('should allow for CSP nonce on inline script tags', function(done) { + var lasso = require('../'); + var theLasso = lasso.create({ + outputDir: outputDir, + urlPrefix: '/', + fingerprintsEnabled: false, + }, __dirname, __filename); + var writerTracker = require('./WriterTracker').create(theLasso.writer); + theLasso.lassoPage({ + pageName: 'testPage', + dependencies: [ + {path: './src/moduleA/moduleA.js', inline: 'end'}, + './src/moduleB/moduleB.js', + ], + from: module, + basePath: __dirname + }) + .then(function(lassoPageResult) { + var body = lassoPageResult.getSlotHtml('body', { + inlineScriptAttrs: { + nonce: 'abc' + } + }).replace(/\\/g, "/"); + expect(writerTracker.getOutputFilenames()).to.deep.equal(['testPage.js']); + expect(body).to.equal('\n'); + lasso.flushAllCaches(done); + }) + .done(); + }); }); diff --git a/test/taglib-test.js b/test/taglib-test.js index 3166f2d6..79dcaa39 100644 --- a/test/taglib-test.js +++ b/test/taglib-test.js @@ -81,4 +81,24 @@ describe('lasso/taglib' , function() { testRender('test-project/src/pages/page1/template.marko', {}, done); }); + it('should render a page with CSP nonce enabled', function(done) { + require('../').configure({ + outputDir: nodePath.join(__dirname, 'build'), + urlPrefix: '/static', + includeSlotNames: false, + fingerprintsEnabled: true, + flags: ['browser'], + cspNonceProvider: function(out, lassoContext) { + return out.global.cspNonce; + } + }, __dirname); + + + testRender('test-project/src/pages/csp-test/template.marko', { + $global: { + cspNonce: 'abc' + } + }, done); + }); + }); diff --git a/test/test-project/src/pages/csp-test/browser.json b/test/test-project/src/pages/csp-test/browser.json new file mode 100644 index 00000000..9a68697f --- /dev/null +++ b/test/test-project/src/pages/csp-test/browser.json @@ -0,0 +1,8 @@ +{ + "dependencies": [ + "style.css", + "test.js", + { "path": "style-inline.css", "inline": true }, + { "path": "test-inline.js", "inline": true } + ] +} \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/favicon.ico b/test/test-project/src/pages/csp-test/favicon.ico new file mode 100644 index 00000000..b34c9c79 Binary files /dev/null and b/test/test-project/src/pages/csp-test/favicon.ico differ diff --git a/test/test-project/src/pages/csp-test/marko-logo.png b/test/test-project/src/pages/csp-test/marko-logo.png new file mode 100644 index 00000000..d366c8f8 Binary files /dev/null and b/test/test-project/src/pages/csp-test/marko-logo.png differ diff --git a/test/test-project/src/pages/csp-test/style-inline.css b/test/test-project/src/pages/csp-test/style-inline.css new file mode 100644 index 00000000..f4ca3aa2 --- /dev/null +++ b/test/test-project/src/pages/csp-test/style-inline.css @@ -0,0 +1,3 @@ +body .inline { + background-color: red; +} \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/style.css b/test/test-project/src/pages/csp-test/style.css new file mode 100644 index 00000000..438954d3 --- /dev/null +++ b/test/test-project/src/pages/csp-test/style.css @@ -0,0 +1,3 @@ +body { + background-color: red; +} \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/template.dust b/test/test-project/src/pages/csp-test/template.dust new file mode 100644 index 00000000..5fca9094 --- /dev/null +++ b/test/test-project/src/pages/csp-test/template.dust @@ -0,0 +1,10 @@ +{@lasso-page name="page1" packagePath=packagePath /} + + + + {@lasso-head /} + + + {@lasso-body /} + + \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/template.dust.expected.html b/test/test-project/src/pages/csp-test/template.dust.expected.html new file mode 100644 index 00000000..55cd78f6 --- /dev/null +++ b/test/test-project/src/pages/csp-test/template.dust.expected.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/template.marko b/test/test-project/src/pages/csp-test/template.marko new file mode 100644 index 00000000..f150b710 --- /dev/null +++ b/test/test-project/src/pages/csp-test/template.marko @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/template.marko.expected.html b/test/test-project/src/pages/csp-test/template.marko.expected.html new file mode 100644 index 00000000..fc0f5e09 --- /dev/null +++ b/test/test-project/src/pages/csp-test/template.marko.expected.html @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/test-inline.js b/test/test-project/src/pages/csp-test/test-inline.js new file mode 100644 index 00000000..7df80eb7 --- /dev/null +++ b/test/test-project/src/pages/csp-test/test-inline.js @@ -0,0 +1 @@ +console.log('hello-inline'); \ No newline at end of file diff --git a/test/test-project/src/pages/csp-test/test.js b/test/test-project/src/pages/csp-test/test.js new file mode 100644 index 00000000..ea17b22e --- /dev/null +++ b/test/test-project/src/pages/csp-test/test.js @@ -0,0 +1 @@ +console.log('hello'); \ No newline at end of file