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',
|
||||
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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -81,11 +81,6 @@
|
|||
&-icon {
|
||||
width: 1.4em;
|
||||
text-align: center;
|
||||
&--icon {
|
||||
background-position: center center;
|
||||
background-size: 28px 28px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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',
|
||||
'inline',
|
||||
'htmlmin',
|
||||
'csp-hashes',
|
||||
'copy:html-dist',
|
||||
'string-replace:service-worker',
|
||||
'string-replace:manifest',
|
||||
'copy:dist-icons',
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue