mirror of https://github.com/keeweb/keeweb.git
fix #1438: content security policy
This commit is contained in:
parent
ea20740581
commit
280ed45199
20
Gruntfile.js
20
Gruntfile.js
|
@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue