diff --git a/Gruntfile.js b/Gruntfile.js index 2bfc73db..e94c9549 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -76,6 +76,11 @@ module.exports = function(grunt) { dest: 'tmp/index.html', nonull: true }, + 'html-dist': { + src: 'tmp/app.html', + dest: 'dist/index.html', + nonull: true + }, favicon: { src: 'app/favicon.png', dest: 'tmp/favicon.png', @@ -189,6 +194,19 @@ module.exports = function(grunt) { dest: 'tmp/app.html' } }, + 'csp-hashes': { + options: { + algo: 'sha512', + expected: { + style: 1, + script: 3 + } + }, + app: { + src: 'tmp/app.html', + dest: 'tmp/app.html' + } + }, htmlmin: { options: { removeComments: true, @@ -196,7 +214,7 @@ module.exports = function(grunt) { }, app: { files: { - 'dist/index.html': 'tmp/app.html' + 'tmp/app.html': 'tmp/app.html' } } }, diff --git a/app/index.html b/app/index.html index 669a2cce..04e79beb 100644 --- a/app/index.html +++ b/app/index.html @@ -16,6 +16,20 @@ + diff --git a/app/scripts/plugins/plugin.js b/app/scripts/plugins/plugin.js index 4850085c..a6f72902 100644 --- a/app/scripts/plugins/plugin.js +++ b/app/scripts/plugins/plugin.js @@ -300,9 +300,14 @@ class Plugin extends Model { applyCss(name, data, theme) { return Promise.resolve().then(() => { - const text = kdbxweb.ByteUtils.bytesToString(data); + const blob = new Blob([data], { type: 'text/css' }); + const objectUrl = URL.createObjectURL(blob); const id = 'plugin-css-' + name; - this.createElementInHead('style', id, 'text/css', text); + const el = this.createElementInHead('link', id, { + rel: 'stylesheet', + href: objectUrl + }); + el.addEventListener('load', () => URL.revokeObjectURL(objectUrl)); if (theme) { const locKey = this.getThemeLocaleKey(theme.name); SettingsManager.allThemes[theme.name] = locKey; @@ -353,7 +358,8 @@ class Plugin extends Model { }; text = `(function(require, module){${text}})(window["${id}"].require,window["${id}"].module);`; const ts = this.logger.ts(); - this.createElementInHead('script', 'plugin-js-' + name, 'text/javascript', text); + // eslint-disable-next-line no-eval + eval(text); return new Promise((resolve, reject) => { setTimeout(() => { delete global[id]; @@ -369,16 +375,18 @@ class Plugin extends Model { }); } - createElementInHead(tagName, id, type, text) { + createElementInHead(tagName, id, attrs) { let el = document.getElementById(id); if (el) { el.parentNode.removeChild(el); } el = document.createElement(tagName); - el.appendChild(document.createTextNode(text)); el.setAttribute('id', id); - el.setAttribute('type', type); + for (const [name, value] of Object.entries(attrs)) { + el.setAttribute(name, value); + } document.head.appendChild(el); + return el; } removeElement(id) { @@ -478,7 +486,6 @@ class Plugin extends Model { } if (manifest.resources.js) { this.uninstallPluginCode(); - this.removeElement('plugin-js-' + this.name); } if (manifest.resources.loc) { this.removeLoc(this.manifest.locale); diff --git a/app/scripts/util/formatting/password-presenter.js b/app/scripts/util/formatting/password-presenter.js index 95f9f002..4408908a 100644 --- a/app/scripts/util/formatting/password-presenter.js +++ b/app/scripts/util/formatting/password-presenter.js @@ -2,26 +2,9 @@ import 'util/kdbxweb/protected-value-ex'; import { shuffle } from 'util/fn'; class RandomNameGenerator { - usedNames = {}; - randomCharCode() { return 97 + Math.floor(Math.random() * 26); } - - randomElementName() { - for (let i = 0; i < 1000; i++) { - let result = ''; - const length = 3 + Math.floor(Math.random() * 3); - for (let i = 0; i < length; i++) { - result += String.fromCharCode(this.randomCharCode()); - } - if (!this.usedNames[result]) { - this.usedNames[result] = true; - return result; - } - } - throw new Error('Failed to generate a random name'); - } } function charCodeToHtml(char) { @@ -44,35 +27,41 @@ const PasswordPresenter = { return result; }, - asHtml(value) { - const html = []; - const style = []; + asDOM(value) { + const items = []; const gen = new RandomNameGenerator(); - const wrapperClass = gen.randomElementName(); - let ix = 0; value.forEachChar(char => { - const className = gen.randomElementName(); const charHtml = charCodeToHtml(char); - html.push(`${charHtml}`); - style.push(`.${className}{order:${ix}}`); + items.push({ html: charHtml, order: ix }); if (Math.random() > 0.5) { - const fakeClassName = gen.randomElementName(); const fakeChar = gen.randomCharCode(); const fakeCharHtml = charCodeToHtml(fakeChar); - html.push(`${fakeCharHtml}`); - style.push(`.${wrapperClass} .${fakeClassName}{display:none}`); + items.push({ html: fakeCharHtml, order: -1 }); } ix++; }); - const everything = html.concat(style.map(s => ``)); - const innerHtml = shuffle(everything).join(''); + shuffle(items); - return `
${innerHtml}
`; + const topEl = document.createElement('div'); + topEl.style.display = 'flex'; + + for (const item of items) { + const el = document.createElement('div'); + el.innerHTML = item.html; + if (item.order >= 0) { + el.style.order = item.order; + } else { + el.style.display = 'none'; + } + topEl.appendChild(el); + } + + return topEl; } }; diff --git a/app/scripts/views/fields/field-view.js b/app/scripts/views/fields/field-view.js index da2a6cb4..53b42cba 100644 --- a/app/scripts/views/fields/field-view.js +++ b/app/scripts/views/fields/field-view.js @@ -263,8 +263,9 @@ class FieldView extends View { } revealValue() { - const valueHtml = PasswordPresenter.asHtml(this.value); - this.valueEl.addClass('details__field-value--revealed').html(valueHtml); + const revealedEl = PasswordPresenter.asDOM(this.value); + this.valueEl.addClass('details__field-value--revealed').html(''); + this.valueEl.append(revealedEl); } hideValue() { diff --git a/app/styles/areas/_details.scss b/app/styles/areas/_details.scss index 4cf0b7cd..f25d873c 100644 --- a/app/styles/areas/_details.scss +++ b/app/styles/areas/_details.scss @@ -81,11 +81,6 @@ &-icon { width: 1.4em; text-align: center; - &--icon { - background-position: center center; - background-size: 28px 28px; - background-repeat: no-repeat; - } } } diff --git a/app/templates/details/details.hbs b/app/templates/details/details.hbs index 0a2eeecb..569c014d 100644 --- a/app/templates/details/details.hbs +++ b/app/templates/details/details.hbs @@ -15,7 +15,9 @@

{{#if title}}{{title}}{{else}}(no title){{/if}}

{{#if customIcon}} -
+
+ +
{{else}} {{/if}} diff --git a/app/templates/open.hbs b/app/templates/open.hbs index c5427121..03befb72 100644 --- a/app/templates/open.hbs +++ b/app/templates/open.hbs @@ -65,8 +65,8 @@
{{!-- we need these inputs to screw browsers passwords autocompletion --}} - - + +
diff --git a/app/templates/settings/settings-file.hbs b/app/templates/settings/settings-file.hbs index 01765a18..77034a1d 100644 --- a/app/templates/settings/settings-file.hbs +++ b/app/templates/settings/settings-file.hbs @@ -64,8 +64,8 @@
{{!-- we need these inputs to screw browsers passwords autocompletion --}} - - + +
diff --git a/build/tasks/grunt-csp-hashes.js b/build/tasks/grunt-csp-hashes.js new file mode 100644 index 00000000..e961fb92 --- /dev/null +++ b/build/tasks/grunt-csp-hashes.js @@ -0,0 +1,62 @@ +module.exports = function(grunt) { + grunt.registerMultiTask('csp-hashes', 'Adds CSP hashes for inline JS and CSS', function() { + const opt = this.options(); + for (const file of this.files) { + const html = grunt.file.read(file.src[0], { encoding: null }); + const { algo } = opt; + + const crypto = require('crypto'); + + const hashes = {}; + + for (const type of ['style', 'script']) { + let index = 0; + while (index >= 0) { + index = html.indexOf(`><${type}>`, index); + if (index > 0) { + index += `><${type}>`.length; + const endIndex = html.indexOf(``, index); + if (endIndex < 0) { + grunt.warn(`Not found: `); + } + const slice = html.slice(index, endIndex); + index = endIndex; + + const hasher = crypto.createHash(algo); + hasher.update(slice); + const digest = hasher.digest('base64'); + + hashes[type] = hashes[type] || []; + hashes[type].push(digest); + } + } + } + + for (const [type, expected] of Object.entries(opt.expected)) { + const actual = hashes[type] ? hashes[type].length : 0; + if (actual !== expected) { + grunt.warn(`Expected ${expected} ${type}(s), found ${actual}`); + } + } + + let htmlStr = html.toString('latin1'); + for (const [type, digests] of Object.entries(hashes)) { + const cspIndex = htmlStr.indexOf(`${type}-src`); + if (cspIndex < 0) { + grunt.warn(`Not found: ${type}-src`); + } + const digestsList = digests.map(digest => `'${algo}-${digest}'`).join(' '); + htmlStr = htmlStr.replace(`${type}-src`, `${type}-src ${digestsList}`); + } + + grunt.log.writeln( + 'Added CSP hashes:', + Object.entries(hashes) + .map(([k, v]) => `${v.length} ${k}(s)`) + .join(', ') + ); + + grunt.file.write(file.dest, Buffer.from(htmlStr, 'latin1')); + } + }); +}; diff --git a/grunt.tasks.js b/grunt.tasks.js index ba81c6be..ea2f5b30 100644 --- a/grunt.tasks.js +++ b/grunt.tasks.js @@ -11,6 +11,8 @@ module.exports = function(grunt) { 'webpack:app', 'inline', 'htmlmin', + 'csp-hashes', + 'copy:html-dist', 'string-replace:service-worker', 'string-replace:manifest', 'copy:dist-icons', diff --git a/release-notes.md b/release-notes.md index f79bc510..60b87e98 100644 --- a/release-notes.md +++ b/release-notes.md @@ -20,6 +20,7 @@ Release notes `*` signature key rotated `*` new Windows code signing certificate `+` startup time profiling +`+` #1438: content security policy `*` fix #890: deb will no longer install to /opt `-` fix #1396: fixed hyperlinks in notes `-` fix #1323: version in the About dialog