UNPKG

assetgraph

Version:

An auto discovery dependency graph based optimization framework for web pages and applications

485 lines (466 loc) 19.4 kB
const urlModule = require('url'); const _ = require('lodash'); const crypto = require('crypto'); const ContentSecurityPolicy = require('../assets/ContentSecurityPolicy'); const directiveByRelationType = { HtmlStyle: 'styleSrc', CssImport: 'styleSrc', HtmlStyleAttribute: 'styleSrc', HtmlScript: 'scriptSrc', HtmlInlineEventHandler: 'scriptSrc', CssFontFaceSrc: 'fontSrc', HtmlObject: 'objectSrc', HtmlApplet: 'objectSrc', HtmlImage: 'imgSrc', CssImage: 'imgSrc', HtmlShortcutIcon: 'imgSrc', HtmlPictureSource: 'imgSrc', HtmlFluidIconLink: 'imgSrc', SrcSetEntry: 'imgSrc', HtmlLogo: 'imgSrc', HtmlAppleTouchStartupImage: 'imgSrc', HtmlVideoPoster: 'imgSrc', HtmlVideo: 'mediaSrc', HtmlAudio: 'mediaSrc', HtmlFrame: 'frameSrc', HtmlIFrame: 'frameSrc', HtmlApplicationManifest: 'manifestSrc', JavaScriptFetch: 'connectSrc' }; function toCamelCase(str) { return str.replace(/-([a-z])/g, ($0, ch) => ch.toUpperCase()); } function fromCamelCase(str) { return str.replace(/[A-Z]/g, $0 => `-${$0.toLowerCase()}`); } function isNonceOrHash(sourceExpression) { return /^'nonce-|^'sha\d+-/.test(sourceExpression); } /* eslint-disable no-inner-declarations */ module.exports = (queryObj, { includePath = false, update = false, level }) => { return function reviewContentSecurityPolicy(assetGraph) { const infoObject = {}; const includePathByDirective = {}; if (includePath) { for (const directive of _.flatten(includePath)) { includePathByDirective[toCamelCase(directive)] = true; } } function originFromUrl(url, directive) { if (assetGraph.root && url.startsWith(assetGraph.root)) { // TODO: What about the canonical root? return "'self'"; } else if (includePath === true || includePathByDirective[directive]) { return url.replace(/^https?:\/\//, ''); } else { const urlObj = new urlModule.URL(url); let host = urlObj.hostname; if ( urlObj.port && parseInt(urlObj.port, 10) !== { 'https:': 443, 'http:': 80 }[urlObj.protocol] ) { host += `:${urlObj.port}`; } return host; } } for (const htmlAsset of assetGraph.findAssets( queryObj || { type: 'Html', isInline: false, isFragment: false, isLoaded: true } )) { const htmlContentSecurityPolicies = assetGraph.findRelations({ from: htmlAsset, type: 'HtmlContentSecurityPolicy' }); for (const htmlContentSecurityPolicy of htmlContentSecurityPolicies) { const contentSecurityPolicy = htmlContentSecurityPolicy.to; const defaultSrc = contentSecurityPolicy.parseTree.defaultSrc; function supportsUnsafeInline(directive) { return contentSecurityPolicy.parseTree[directive] ? contentSecurityPolicy.parseTree[directive].includes( "'unsafe-inline'" ) : defaultSrc && ContentSecurityPolicy.directiveFallsBackToDefaultSrc( directive ) && defaultSrc.includes("'unsafe-inline'"); } function supportsUnsafeHashedAttributes(directive) { return contentSecurityPolicy.parseTree[directive] ? contentSecurityPolicy.parseTree[directive].includes( "'unsafe-hashes'" ) : defaultSrc && ContentSecurityPolicy.directiveFallsBackToDefaultSrc( directive ) && defaultSrc.includes("'unsafe-hashes'"); } function supportsHashSource(directive, hashSource) { return contentSecurityPolicy.parseTree[directive] ? contentSecurityPolicy.parseTree[directive].includes(hashSource) : defaultSrc && ContentSecurityPolicy.directiveFallsBackToDefaultSrc( directive ) && defaultSrc.includes(hashSource); } function supportsData(directive) { return contentSecurityPolicy.parseTree[directive] ? contentSecurityPolicy.parseTree[directive].includes('data:') : defaultSrc && ContentSecurityPolicy.directiveFallsBackToDefaultSrc( directive ) && defaultSrc.includes('data:'); } function hasNonceOrHash(directive) { return contentSecurityPolicy.parseTree[directive] ? contentSecurityPolicy.parseTree[directive].some(isNonceOrHash) : defaultSrc && ContentSecurityPolicy.directiveFallsBackToDefaultSrc( directive ) && defaultSrc.some(isNonceOrHash); } const disallowedRelationsByDirectiveAndOrigin = {}; const seenNoncesByDirective = {}; function noteAsset(asset, incomingRelation, directive) { directive = directive || (incomingRelation && directiveByRelationType[incomingRelation.type]); if (directive) { if (asset.isInline) { if ( (directive === 'styleSrc' || directive === 'scriptSrc') && (!supportsUnsafeInline(directive) || hasNonceOrHash(directive)) ) { const incomingInlineRelation = asset.incomingInlineRelation; const isUnsafeHashedAttribute = incomingInlineRelation && (incomingInlineRelation.type === 'HtmlStyleAttribute' || incomingInlineRelation.type === 'HtmlInlineEventHandler'); if (isUnsafeHashedAttribute && !(level >= 3)) { disallowedRelationsByDirectiveAndOrigin[directive] = disallowedRelationsByDirectiveAndOrigin[directive] || {}; (disallowedRelationsByDirectiveAndOrigin[directive][ "'unsafe-inline'" ] = disallowedRelationsByDirectiveAndOrigin[directive][ "'unsafe-inline'" ] || []).push(incomingRelation); } else { // This is a smell, find out if we can get bogusselector and function bogus stripped from asset.text: let rawSrc; let encoding; if (isUnsafeHashedAttribute) { rawSrc = incomingInlineRelation.href; encoding = incomingInlineRelation.from.nonInlineAncestor.encoding; } else { rawSrc = asset.rawSrc; } const hashSource = `'sha256-${crypto .createHash('sha256') .update(rawSrc, encoding) .digest('base64')}'`; if (!supportsHashSource(directive, hashSource)) { disallowedRelationsByDirectiveAndOrigin[directive] = disallowedRelationsByDirectiveAndOrigin[directive] || {}; (disallowedRelationsByDirectiveAndOrigin[directive][ hashSource ] = disallowedRelationsByDirectiveAndOrigin[directive][ hashSource ] || []).push(incomingRelation); } if ( isUnsafeHashedAttribute && !supportsUnsafeHashedAttributes(directive) ) { disallowedRelationsByDirectiveAndOrigin[directive] = disallowedRelationsByDirectiveAndOrigin[directive] || {}; (disallowedRelationsByDirectiveAndOrigin[directive][ "'unsafe-hashes'" ] = disallowedRelationsByDirectiveAndOrigin[directive][ "'unsafe-hashes'" ] || []).push(incomingRelation); } } } else if ( /^data:/.test(incomingRelation.href) && !supportsData(directive) ) { disallowedRelationsByDirectiveAndOrigin[directive] = disallowedRelationsByDirectiveAndOrigin[directive] || {}; (disallowedRelationsByDirectiveAndOrigin[directive]['data:'] = disallowedRelationsByDirectiveAndOrigin[directive]['data:'] || []).push(incomingRelation); } } else if (!contentSecurityPolicy.allows(directive, asset.url)) { disallowedRelationsByDirectiveAndOrigin[directive] = disallowedRelationsByDirectiveAndOrigin[directive] || {}; const origin = originFromUrl(asset.url, directive); (disallowedRelationsByDirectiveAndOrigin[directive][origin] = disallowedRelationsByDirectiveAndOrigin[directive][origin] || []).push(incomingRelation); } if ( incomingRelation.from.type === 'Html' && incomingRelation.node // Avoid HttpRedirect etc. ) { const nonce = incomingRelation.node.getAttribute('nonce'); if (nonce) { (seenNoncesByDirective[directive] = seenNoncesByDirective[directive] || []).push( `'nonce-${nonce}'` ); if (update) { incomingRelation.node.removeAttribute('nonce'); incomingRelation.from.markDirty(); } } } } } const seenAssets = new Set(); seenAssets.add(htmlAsset); assetGraph._traverse( htmlAsset, { type: { $nin: [ 'HtmlAnchor', 'HtmlMetaRefresh', 'JavaScriptSourceUrl', 'JavaScriptSourceMappingUrl', 'CssSourceUrl', 'CssSourceMappingUrl' ] } }, (asset, incomingRelation) => { seenAssets.add(asset.id); if (incomingRelation && incomingRelation.type === 'HttpRedirect') { // Work backwards through all the seen assets to find all relevant paths through this redirect: const seenRelations = new Set(); const queue = [ ...incomingRelation.from.incomingRelations.filter(relation => seenAssets.has(relation.from) ) ]; while (queue.length > 0) { const relation = queue.shift(); if (!seenRelations.has(relation)) { seenRelations.add(relation.id); if (relation.type === 'HttpRedirect') { queue.push( ...relation.from.incomingRelations.filter(relation => { return ( seenAssets.has(relation.from) && !seenRelations.has(relation) ); }) ); } else { noteAsset( asset, incomingRelation, directiveByRelationType[relation.type] ); } } } } else { noteAsset(asset, incomingRelation); } } ); const directives = _.uniq([ ...Object.keys(disallowedRelationsByDirectiveAndOrigin), ...Object.keys(seenNoncesByDirective), ...Object.keys(contentSecurityPolicy.parseTree) ]); infoObject[contentSecurityPolicy.id] = { additions: disallowedRelationsByDirectiveAndOrigin }; for (const directive of directives) { const originsToAdd = Object.keys( disallowedRelationsByDirectiveAndOrigin[directive] || {} ); let existingOrigins; if (contentSecurityPolicy.parseTree[directive]) { existingOrigins = contentSecurityPolicy.parseTree[directive]; } else if ( ContentSecurityPolicy.directiveFallsBackToDefaultSrc(directive) && contentSecurityPolicy.parseTree.defaultSrc ) { existingOrigins = contentSecurityPolicy.parseTree.defaultSrc; } else { existingOrigins = []; } let origins = [...originsToAdd]; if (update) { if (originsToAdd.includes("'unsafe-inline'")) { assetGraph.warn( new Error( `${htmlAsset.urlOrDescription} contains one or more inline ${ directive === 'styleSrc' ? 'style attributes' : 'event handlers' }, which cannot be whitelisted with CSP level 2 except via 'unsafe-inline', which almost defeats the purpose of having a CSP\n` + "The 'unsafe-hashes' CSP3 keyword will allow it, but at the time of writing the spec is not finalized and no browser implements it." ) ); } origins = _.uniq([...origins, ...existingOrigins]); let noncesWereRemoved = false; const seenNonces = seenNoncesByDirective[directive] || []; const originsWithNoncesSubtracted = _.difference(origins, [ "'nonce-developmentonly'", ...seenNonces ]); if (originsWithNoncesSubtracted.length < origins.length) { noncesWereRemoved = true; } origins = originsWithNoncesSubtracted; // If we're removing 'nonce-developmentonly' when 'unsafe-inline' is in effect and there are no other // nonces or hashes whitelisted, add the sha256 hash of the empty string to the list of allowed origins. // This prevents 'unsafe-inline' from taking effect in CSP2+ compliant browsers that would otherwise // ignore 'unsafe-inline' when at least one nonce or hash is present for the directive. // https://www.w3.org/TR/CSP3/#match-element-to-source-list if ( noncesWereRemoved && origins.includes("'unsafe-inline'") && !origins.some(isNonceOrHash) ) { origins.push( "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" ); } else { const nonceOrHashOrigins = origins.filter(isNonceOrHash); // Add 'unsafe-inline' unless all the nonceOrHashOrigins were added // because of relations that require 'unsafe-hashes' if ( nonceOrHashOrigins.length > 0 && !origins.includes("'unsafe-inline'") ) { if ( !nonceOrHashOrigins.every(hashSource => { const relations = disallowedRelationsByDirectiveAndOrigin[directive] && disallowedRelationsByDirectiveAndOrigin[directive][ hashSource ]; return ( relations && relations.every( relation => relation.type === 'HtmlInlineEventHandler' || relation.type === 'HtmlStyleAttribute' ) ); }) ) { origins.push("'unsafe-inline'"); } } } if (level < 2) { origins = origins.filter(origin => !isNonceOrHash(origin)); } if (origins.length > 1) { const indexOfNoneToken = origins.indexOf("'none'"); if (indexOfNoneToken !== -1) { origins.splice(indexOfNoneToken, 1); } } if ( directive !== 'defaultSrc' && defaultSrc && ContentSecurityPolicy.directiveFallsBackToDefaultSrc(directive) && _.difference(defaultSrc, origins).length === 0 && _.difference(origins, defaultSrc).length === 0 ) { delete contentSecurityPolicy.parseTree[directive]; } else { contentSecurityPolicy.parseTree[directive] = origins.sort(); } contentSecurityPolicy.markDirty(); } else { let allowedOrigins = contentSecurityPolicy.parseTree[directive]; let directiveInEffect = directive; if (!allowedOrigins) { if ( directive !== 'frameAncestors' && contentSecurityPolicy.parseTree.defaultSrc ) { directiveInEffect = 'defaultSrc'; allowedOrigins = contentSecurityPolicy.parseTree.defaultSrc; } else { allowedOrigins = ["'none'"]; } } for (const nonWhitelistedOrigin of origins) { let relations = disallowedRelationsByDirectiveAndOrigin[directive][ nonWhitelistedOrigin ]; if (relations) { relations = relations.filter(relation => { let nonce; if (relation.from.type === 'Html') { nonce = relation.node.getAttribute('nonce'); } return !nonce || !allowedOrigins.includes(`'nonce-${nonce}'`); }); if (relations.length > 0) { if (nonWhitelistedOrigin === "'unsafe-hashes'") { if (level >= 3) { assetGraph.warn( new Error( `${ htmlAsset.urlOrDescription }: Missing ${fromCamelCase( directive )} 'unsafe-hashes':\n ${relations .map(asset => asset.to.urlOrDescription) .join('\n ')}` ) ); } } else { assetGraph.warn( new Error( `${htmlAsset.urlOrDescription}: ${ relations.length === 1 ? 'An asset violates' : `${relations.length} relations violate` } the ${fromCamelCase( directiveInEffect )} ${allowedOrigins.join( ' ' )} Content-Security-Policy directive:\n ${relations .map(asset => asset.to.urlOrDescription) .join('\n ')}` ) ); } } } } } } } } return infoObject; }; };