luci-mod-system: sshkeys.js: prevent XSS through pubkey comments
[project/luci.git] / modules / luci-mod-system / htdocs / luci-static / resources / view / system / sshkeys.js
1 'use strict';
2 'require view';
3 'require fs';
4 'require ui';
5
6 var SSHPubkeyDecoder = L.Class.singleton({
7 lengthDecode: function(s, off)
8 {
9 var l = (s.charCodeAt(off++) << 24) |
10 (s.charCodeAt(off++) << 16) |
11 (s.charCodeAt(off++) << 8) |
12 s.charCodeAt(off++);
13
14 if (l < 0 || (off + l) > s.length)
15 return -1;
16
17 return l;
18 },
19
20 decode: function(s)
21 {
22 var parts = s.trim().match(/^((?:(?:^|,)[^ =,]+(?:=(?:[^ ",]+|"(?:[^"\\]|\\.)*"))?)+ +)?(ssh-dss|ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+) +([^ ]+)( +.*)?$/);
23
24 if (!parts)
25 return null;
26
27 var key = null;
28 try { key = atob(parts[3]); } catch(e) {}
29 if (!key)
30 return null;
31
32 var off, len;
33
34 off = 0;
35 len = this.lengthDecode(key, off);
36
37 if (len <= 0)
38 return null;
39
40 var type = key.substr(off + 4, len);
41 if (type !== parts[2])
42 return null;
43
44 off += 4 + len;
45
46 var len1 = off < key.length ? this.lengthDecode(key, off) : 0;
47 if (len1 <= 0)
48 return null;
49
50 var curve = null;
51 if (type.indexOf('ecdsa-sha2-') === 0) {
52 curve = key.substr(off + 4, len1);
53
54 if (!len1 || type.substr(11) !== curve)
55 return null;
56
57 type = 'ecdsa-sha2';
58 curve = curve.replace(/^nistp(\d+)$/, 'NIST P-$1');
59 }
60
61 off += 4 + len1;
62
63 var len2 = off < key.length ? this.lengthDecode(key, off) : 0;
64 if (len2 < 0)
65 return null;
66
67 if (len1 & 1)
68 len1--;
69
70 if (len2 & 1)
71 len2--;
72
73 var comment = (parts[4] || '').trim(),
74 fprint = parts[3].length > 68 ? parts[3].substr(0, 33) + '…' + parts[3].substr(-34) : parts[3];
75
76 var options = null;
77 (parts[1] || '').trim().replace(/(?:^|,)([^ =,]+)(?:=(?:([^ ",]+)|"((?:[^"\\]|\\.)*)"))?/g, function(m, k, p, q) {
78 options = options || {};
79
80 if (options.hasOwnProperty(k))
81 options[k] += ',' + (q || p || true);
82 else
83 options[k] = (q || p || true);
84 });
85
86 switch (type)
87 {
88 case 'ssh-rsa':
89 return { type: 'RSA', bits: len2 * 8, comment: comment, options: options, fprint: fprint, src: s };
90
91 case 'ssh-dss':
92 return { type: 'DSA', bits: len1 * 8, comment: comment, options: options, fprint: fprint, src: s };
93
94 case 'ssh-ed25519':
95 return { type: 'ECDH', curve: 'Curve25519', comment: comment, options: options, fprint: fprint, src: s };
96
97 case 'ecdsa-sha2':
98 return { type: 'ECDSA', curve: curve, comment: comment, options: options, fprint: fprint, src: s };
99
100 default:
101 return null;
102 }
103 }
104 });
105
106 function renderKeyItem(pubkey) {
107 return E('div', {
108 class: 'item',
109 click: removeKey,
110 'data-key': pubkey.src
111 }, [
112 E('strong', [ pubkey.comment || _('Unnamed key') ]), E('br'),
113 E('small', [
114 '%s, %s'.format(pubkey.type, pubkey.curve || _('%d Bit').format(pubkey.bits)),
115 pubkey.options ? E([], [
116 ' / ', _('Options:'), ' ',
117 E('code', Object.keys(pubkey.options).sort().join(', '))
118 ]) : '',
119 E('br'), E('code', pubkey.fprint)
120 ])
121 ]);
122 }
123
124 function renderKeys(keys) {
125 var list = document.querySelector('.cbi-dynlist');
126
127 while (!matchesElem(list.firstElementChild, '.add-item'))
128 list.removeChild(list.firstElementChild);
129
130 keys.forEach(function(key) {
131 var pubkey = SSHPubkeyDecoder.decode(key);
132 if (pubkey)
133 list.insertBefore(renderKeyItem(pubkey), list.lastElementChild);
134 });
135
136 if (list.firstElementChild === list.lastElementChild)
137 list.insertBefore(E('p', _('No public keys present yet.')), list.lastElementChild);
138 }
139
140 function saveKeys(keys) {
141 return fs.write('/etc/dropbear/authorized_keys', keys.join('\n') + '\n', 384 /* 0600 */)
142 .then(renderKeys.bind(this, keys))
143 .catch(function(e) { ui.addNotification(null, E('p', e.message)) })
144 .finally(ui.hideModal);
145 }
146
147 function addKey(ev) {
148 var list = findParent(ev.target, '.cbi-dynlist'),
149 input = list.querySelector('input[type="text"]'),
150 key = input.value.trim(),
151 pubkey = SSHPubkeyDecoder.decode(key),
152 keys = [];
153
154 if (!key.length)
155 return;
156
157 list.querySelectorAll('.item').forEach(function(item) {
158 keys.push(item.getAttribute('data-key'));
159 });
160
161 if (keys.indexOf(key) !== -1) {
162 ui.showModal(_('Add key'), [
163 E('div', { class: 'alert-message warning' }, _('The given SSH public key has already been added.')),
164 E('div', { class: 'right' }, E('div', { class: 'btn', click: L.hideModal }, _('Close')))
165 ]);
166 }
167 else if (!pubkey) {
168 ui.showModal(_('Add key'), [
169 E('div', { class: 'alert-message warning' }, _('The given SSH public key is invalid. Please supply proper public RSA or ECDSA keys.')),
170 E('div', { class: 'right' }, E('div', { class: 'btn', click: L.hideModal }, _('Close')))
171 ]);
172 }
173 else {
174 keys.push(key);
175 input.value = '';
176
177 return saveKeys(keys).then(function() {
178 var added = list.querySelector('[data-key="%s"]'.format(key.replace(/["\\]/g, '\\$&')));
179 if (added)
180 added.classList.add('flash');
181 });
182 }
183 }
184
185 function removeKey(ev) {
186 var list = findParent(ev.target, '.cbi-dynlist'),
187 delkey = ev.target.getAttribute('data-key'),
188 keys = [];
189
190 list.querySelectorAll('.item').forEach(function(item) {
191 var key = item.getAttribute('data-key');
192 if (key !== delkey)
193 keys.push(key);
194 });
195
196 L.showModal(_('Delete key'), [
197 E('div', _('Do you really want to delete the following SSH key?')),
198 E('pre', delkey),
199 E('div', { class: 'right' }, [
200 E('div', { class: 'btn', click: L.hideModal }, _('Cancel')),
201 ' ',
202 E('div', { class: 'btn danger', click: ui.createHandlerFn(this, saveKeys, keys) }, _('Delete key')),
203 ])
204 ]);
205 }
206
207 function dragKey(ev) {
208 ev.stopPropagation();
209 ev.preventDefault();
210 ev.dataTransfer.dropEffect = 'copy';
211 }
212
213 function dropKey(ev) {
214 var file = ev.dataTransfer.files[0],
215 input = ev.currentTarget.querySelector('input[type="text"]'),
216 reader = new FileReader();
217
218 if (file) {
219 reader.onload = function(rev) {
220 input.value = rev.target.result.trim();
221 addKey(ev);
222 input.value = '';
223 };
224
225 reader.readAsText(file);
226 }
227
228 ev.stopPropagation();
229 ev.preventDefault();
230 }
231
232 function handleWindowDragDropIgnore(ev) {
233 ev.preventDefault()
234 }
235
236 return view.extend({
237 load: function() {
238 return fs.lines('/etc/dropbear/authorized_keys').then(function(lines) {
239 return lines.map(function(line) {
240 return SSHPubkeyDecoder.decode(line);
241 }).filter(function(line) {
242 return line != null;
243 });
244 });
245 },
246
247 render: function(keys) {
248 var list = E('div', { 'class': 'cbi-dynlist', 'dragover': dragKey, 'drop': dropKey }, [
249 E('div', { 'class': 'add-item' }, [
250 E('input', {
251 'class': 'cbi-input-text',
252 'type': 'text',
253 'placeholder': _('Paste or drag SSH key file…') ,
254 'keydown': function(ev) { if (ev.keyCode === 13) addKey(ev) }
255 }),
256 E('button', {
257 'class': 'cbi-button',
258 'click': ui.createHandlerFn(this, addKey)
259 }, _('Add key'))
260 ])
261 ]);
262
263 keys.forEach(L.bind(function(pubkey) {
264 list.insertBefore(renderKeyItem(pubkey), list.lastElementChild);
265 }, this));
266
267 if (list.firstElementChild === list.lastElementChild)
268 list.insertBefore(E('p', _('No public keys present yet.')), list.lastElementChild);
269
270 window.addEventListener('dragover', handleWindowDragDropIgnore);
271 window.addEventListener('drop', handleWindowDragDropIgnore);
272
273 return E('div', {}, [
274 E('h2', _('SSH-Keys')),
275 E('div', { 'class': 'cbi-section-descr' }, _('Public keys allow for the passwordless SSH logins with a higher security compared to the use of plain passwords. In order to upload a new key to the device, paste an OpenSSH compatible public key line or drag a <code>.pub</code> file into the input field.')),
276 E('div', { 'class': 'cbi-section-node' }, list)
277 ]);
278 },
279
280 handleSaveApply: null,
281 handleSave: null,
282 handleReset: null
283 });