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',
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'
}
}
},

View File

@ -16,6 +16,20 @@
<meta name="theme-color" content="#6386ec" />
<meta name="msapplication-config" content="browserconfig.xml" />
<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="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.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) {
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);

View File

@ -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(`<span class="${className}">${charHtml}</span>`);
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(`<span class="${fakeClassName}">${fakeCharHtml}</span>`);
style.push(`.${wrapperClass} .${fakeClassName}{display:none}`);
items.push({ html: fakeCharHtml, order: -1 });
}
ix++;
});
const everything = html.concat(style.map(s => `<style>${s}</style>`));
const innerHtml = shuffle(everything).join('');
shuffle(items);
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() {
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() {

View File

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

View File

@ -15,7 +15,9 @@
</i>
<h1 class="details__header-title">{{#if title}}{{title}}{{else}}(no title){{/if}}</h1>
{{#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}}
<i class="details__header-icon fa fa-{{icon}}" title="{{res 'detSetIcon'}}"></i>
{{/if}}

View File

@ -65,8 +65,8 @@
<div class="open__pass-area">
<div class="hide">
{{!-- we need these inputs to screw browsers passwords autocompletion --}}
<input type="text" style="display:none" name="username">
<input type="password" style="display:none" name="password">
<input type="text" name="username">
<input type="password" name="password">
</div>
<div class="open__pass-warn-wrap">
<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>
<div class="hide">
{{!-- we need these inputs to screw browsers passwords autocompletion --}}
<input type="text" style="display:none" name="username">
<input type="password" style="display:none" name="password">
<input type="text" name="username">
<input type="password" name="password">
</div>
<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">

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',
'inline',
'htmlmin',
'csp-hashes',
'copy:html-dist',
'string-replace:service-worker',
'string-replace:manifest',
'copy:dist-icons',

View File

@ -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