fix #1438: content security policy

This commit is contained in:
antelle 2020-04-02 16:27:10 +02:00
parent ea20740581
commit 280ed45199
No known key found for this signature in database
GPG Key ID: 094A2F2D6136A4EE
12 changed files with 142 additions and 51 deletions

View File

@ -76,6 +76,11 @@ module.exports = function(grunt) {
dest: 'tmp/index.html', dest: 'tmp/index.html',
nonull: true nonull: true
}, },
'html-dist': {
src: 'tmp/app.html',
dest: 'dist/index.html',
nonull: true
},
favicon: { favicon: {
src: 'app/favicon.png', src: 'app/favicon.png',
dest: 'tmp/favicon.png', dest: 'tmp/favicon.png',
@ -189,6 +194,19 @@ module.exports = function(grunt) {
dest: 'tmp/app.html' dest: 'tmp/app.html'
} }
}, },
'csp-hashes': {
options: {
algo: 'sha512',
expected: {
style: 1,
script: 3
}
},
app: {
src: 'tmp/app.html',
dest: 'tmp/app.html'
}
},
htmlmin: { htmlmin: {
options: { options: {
removeComments: true, removeComments: true,
@ -196,7 +214,7 @@ module.exports = function(grunt) {
}, },
app: { app: {
files: { files: {
'dist/index.html': 'tmp/app.html' 'tmp/app.html': 'tmp/app.html'
} }
} }
}, },

View File

@ -16,6 +16,20 @@
<meta name="theme-color" content="#6386ec" /> <meta name="theme-color" content="#6386ec" />
<meta name="msapplication-config" content="browserconfig.xml" /> <meta name="msapplication-config" content="browserconfig.xml" />
<meta name="msapplication-TileColor" content="#6386ec" /> <meta name="msapplication-TileColor" content="#6386ec" />
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
font-src data:;
script-src 'self' 'unsafe-eval';
style-src 'self' blob:;
connect-src 'self' ws: https:;
child-src blob:;
img-src 'self' data:;
object-src 'none';
form-action 'none';
"
/>
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />

View File

@ -300,9 +300,14 @@ class Plugin extends Model {
applyCss(name, data, theme) { applyCss(name, data, theme) {
return Promise.resolve().then(() => { 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; 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) { if (theme) {
const locKey = this.getThemeLocaleKey(theme.name); const locKey = this.getThemeLocaleKey(theme.name);
SettingsManager.allThemes[theme.name] = locKey; SettingsManager.allThemes[theme.name] = locKey;
@ -353,7 +358,8 @@ class Plugin extends Model {
}; };
text = `(function(require, module){${text}})(window["${id}"].require,window["${id}"].module);`; text = `(function(require, module){${text}})(window["${id}"].require,window["${id}"].module);`;
const ts = this.logger.ts(); 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) => { return new Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
delete global[id]; 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); let el = document.getElementById(id);
if (el) { if (el) {
el.parentNode.removeChild(el); el.parentNode.removeChild(el);
} }
el = document.createElement(tagName); el = document.createElement(tagName);
el.appendChild(document.createTextNode(text));
el.setAttribute('id', id); el.setAttribute('id', id);
el.setAttribute('type', type); for (const [name, value] of Object.entries(attrs)) {
el.setAttribute(name, value);
}
document.head.appendChild(el); document.head.appendChild(el);
return el;
} }
removeElement(id) { removeElement(id) {
@ -478,7 +486,6 @@ class Plugin extends Model {
} }
if (manifest.resources.js) { if (manifest.resources.js) {
this.uninstallPluginCode(); this.uninstallPluginCode();
this.removeElement('plugin-js-' + this.name);
} }
if (manifest.resources.loc) { if (manifest.resources.loc) {
this.removeLoc(this.manifest.locale); this.removeLoc(this.manifest.locale);

View File

@ -2,26 +2,9 @@ import 'util/kdbxweb/protected-value-ex';
import { shuffle } from 'util/fn'; import { shuffle } from 'util/fn';
class RandomNameGenerator { class RandomNameGenerator {
usedNames = {};
randomCharCode() { randomCharCode() {
return 97 + Math.floor(Math.random() * 26); 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) { function charCodeToHtml(char) {
@ -44,35 +27,41 @@ const PasswordPresenter = {
return result; return result;
}, },
asHtml(value) { asDOM(value) {
const html = []; const items = [];
const style = [];
const gen = new RandomNameGenerator(); const gen = new RandomNameGenerator();
const wrapperClass = gen.randomElementName();
let ix = 0; let ix = 0;
value.forEachChar(char => { value.forEachChar(char => {
const className = gen.randomElementName();
const charHtml = charCodeToHtml(char); const charHtml = charCodeToHtml(char);
html.push(`<span class="${className}">${charHtml}</span>`); items.push({ html: charHtml, order: ix });
style.push(`.${className}{order:${ix}}`);
if (Math.random() > 0.5) { if (Math.random() > 0.5) {
const fakeClassName = gen.randomElementName();
const fakeChar = gen.randomCharCode(); const fakeChar = gen.randomCharCode();
const fakeCharHtml = charCodeToHtml(fakeChar); const fakeCharHtml = charCodeToHtml(fakeChar);
html.push(`<span class="${fakeClassName}">${fakeCharHtml}</span>`); items.push({ html: fakeCharHtml, order: -1 });
style.push(`.${wrapperClass} .${fakeClassName}{display:none}`);
} }
ix++; ix++;
}); });
const everything = html.concat(style.map(s => `<style>${s}</style>`)); shuffle(items);
const innerHtml = shuffle(everything).join('');
return `<div class="${wrapperClass}" style="display: flex">${innerHtml}</div>`; 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;
} }
}; };

View File

@ -263,8 +263,9 @@ class FieldView extends View {
} }
revealValue() { revealValue() {
const valueHtml = PasswordPresenter.asHtml(this.value); const revealedEl = PasswordPresenter.asDOM(this.value);
this.valueEl.addClass('details__field-value--revealed').html(valueHtml); this.valueEl.addClass('details__field-value--revealed').html('');
this.valueEl.append(revealedEl);
} }
hideValue() { hideValue() {

View File

@ -81,11 +81,6 @@
&-icon { &-icon {
width: 1.4em; width: 1.4em;
text-align: center; text-align: center;
&--icon {
background-position: center center;
background-size: 28px 28px;
background-repeat: no-repeat;
}
} }
} }

View File

@ -15,7 +15,9 @@
</i> </i>
<h1 class="details__header-title">{{#if title}}{{title}}{{else}}(no title){{/if}}</h1> <h1 class="details__header-title">{{#if title}}{{title}}{{else}}(no title){{/if}}</h1>
{{#if customIcon}} {{#if customIcon}}
<div class="details__header-icon details__header-icon--icon" style="background-image: url({{{customIcon}}})" title="{{res 'detSetIcon'}}"></div> <div class="details__header-icon details__header-icon--icon" title="{{res 'detSetIcon'}}">
<img class="details__header-icon-img" src="{{{customIcon}}}" />
</div>
{{else}} {{else}}
<i class="details__header-icon fa fa-{{icon}}" title="{{res 'detSetIcon'}}"></i> <i class="details__header-icon fa fa-{{icon}}" title="{{res 'detSetIcon'}}"></i>
{{/if}} {{/if}}

View File

@ -65,8 +65,8 @@
<div class="open__pass-area"> <div class="open__pass-area">
<div class="hide"> <div class="hide">
{{!-- we need these inputs to screw browsers passwords autocompletion --}} {{!-- we need these inputs to screw browsers passwords autocompletion --}}
<input type="text" style="display:none" name="username"> <input type="text" name="username">
<input type="password" style="display:none" name="password"> <input type="password" name="password">
</div> </div>
<div class="open__pass-warn-wrap"> <div class="open__pass-warn-wrap">
<div class="open__pass-warning muted-color invisible"><i class="fa fa-exclamation-triangle"></i> {{res 'openCaps'}}</div> <div class="open__pass-warning muted-color invisible"><i class="fa fa-exclamation-triangle"></i> {{res 'openCaps'}}</div>

View File

@ -64,8 +64,8 @@
</label> </label>
<div class="hide"> <div class="hide">
{{!-- we need these inputs to screw browsers passwords autocompletion --}} {{!-- we need these inputs to screw browsers passwords autocompletion --}}
<input type="text" style="display:none" name="username"> <input type="text" name="username">
<input type="password" style="display:none" name="password"> <input type="password" name="password">
</div> </div>
<input type="password" class="settings__input input-base" id="settings__file-master-pass" value="{{password}}" autocomplete="new-password" /> <input type="password" class="settings__input input-base" id="settings__file-master-pass" value="{{password}}" autocomplete="new-password" />
<div id="settings__file-confirm-master-pass-group"> <div id="settings__file-confirm-master-pass-group">

View File

@ -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'));
}
});
};

View File

@ -11,6 +11,8 @@ module.exports = function(grunt) {
'webpack:app', 'webpack:app',
'inline', 'inline',
'htmlmin', 'htmlmin',
'csp-hashes',
'copy:html-dist',
'string-replace:service-worker', 'string-replace:service-worker',
'string-replace:manifest', 'string-replace:manifest',
'copy:dist-icons', 'copy:dist-icons',

View File

@ -20,6 +20,7 @@ Release notes
`*` signature key rotated `*` signature key rotated
`*` new Windows code signing certificate `*` new Windows code signing certificate
`+` startup time profiling `+` startup time profiling
`+` #1438: content security policy
`*` fix #890: deb will no longer install to /opt `*` fix #890: deb will no longer install to /opt
`-` fix #1396: fixed hyperlinks in notes `-` fix #1396: fixed hyperlinks in notes
`-` fix #1323: version in the About dialog `-` fix #1323: version in the About dialog