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 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 --}}
-
-
+
+
{{res 'openCaps'}}
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(`${type}>`, index);
+ if (endIndex < 0) {
+ grunt.warn(`Not found: ${type}>`);
+ }
+ 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