luci-base: properly handle promise targets in Request.request()
authorJo-Philipp Wich <jo@mein.io>
Mon, 21 Feb 2022 13:59:16 +0000 (14:59 +0100)
committerJo-Philipp Wich <jo@mein.io>
Mon, 21 Feb 2022 14:09:48 +0000 (15:09 +0100)
Under some circumstances, ubus RPC requests may be initiated while LuCI is
still resolving the `rpcBaseURL` value. In this situation, the `target`
argument of the `request()` call will be a pending promise object which
results in an invalid URL when serialized by `expandURL()`, leading to an
`Failed to execute 'open' on 'XMLHttpRequest': Invalid URL` exception.

This commonly occured on the index status page which immediately initiates
ubus RPC calls on load to discover existing status page partials.

Solve the issue by filtering the given `target` argument through
`Promise.resolve()` before expanding the URL and initiating the actual
request.

Fixes: #3747
Signed-off-by: Jo-Philipp Wich <jo@mein.io>
(backported from commit 5663fd596b567d53587fcc4052df3095520c08a7)

modules/luci-base/htdocs/luci-static/resources/luci.js

index 8f8b9673d64aeef00ea2f9bd31b00b68360d6840..7c334fab3c35be04cf6218068236af1f897b21e5 100644 (file)
                 * The resulting HTTP response.
                 */
                request: function(target, options) {
-                       var state = { xhr: new XMLHttpRequest(), url: this.expandURL(target), start: Date.now() },
-                           opt = Object.assign({}, options, state),
-                           content = null,
-                           contenttype = null,
-                           callback = this.handleReadyStateChange;
-
-                       return new Promise(function(resolveFn, rejectFn) {
-                               opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn);
-                               opt.method = String(opt.method || 'GET').toUpperCase();
-
-                               if ('query' in opt) {
-                                       var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) {
-                                               if (opt.query[k] != null) {
-                                                       var v = (typeof(opt.query[k]) == 'object')
-                                                               ? JSON.stringify(opt.query[k])
-                                                               : String(opt.query[k]);
-
-                                                       return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v));
-                                               }
-                                               else {
-                                                       return encodeURIComponent(k);
-                                               }
-                                       }).join('&') : '';
-
-                                       if (q !== '') {
-                                               switch (opt.method) {
-                                               case 'GET':
-                                               case 'HEAD':
-                                               case 'OPTIONS':
-                                                       opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q;
-                                                       break;
-
-                                               default:
-                                                       if (content == null) {
-                                                               content = q;
-                                                               contenttype = 'application/x-www-form-urlencoded';
+                       return Promise.resolve(target).then((function(url) {
+                               var state = { xhr: new XMLHttpRequest(), url: this.expandURL(url), start: Date.now() },
+                                   opt = Object.assign({}, options, state),
+                                   content = null,
+                                   contenttype = null,
+                                   callback = this.handleReadyStateChange;
+
+                               return new Promise(function(resolveFn, rejectFn) {
+                                       opt.xhr.onreadystatechange = callback.bind(opt, resolveFn, rejectFn);
+                                       opt.method = String(opt.method || 'GET').toUpperCase();
+
+                                       if ('query' in opt) {
+                                               var q = (opt.query != null) ? Object.keys(opt.query).map(function(k) {
+                                                       if (opt.query[k] != null) {
+                                                               var v = (typeof(opt.query[k]) == 'object')
+                                                                       ? JSON.stringify(opt.query[k])
+                                                                       : String(opt.query[k]);
+
+                                                               return '%s=%s'.format(encodeURIComponent(k), encodeURIComponent(v));
+                                                       }
+                                                       else {
+                                                               return encodeURIComponent(k);
+                                                       }
+                                               }).join('&') : '';
+
+                                               if (q !== '') {
+                                                       switch (opt.method) {
+                                                       case 'GET':
+                                                       case 'HEAD':
+                                                       case 'OPTIONS':
+                                                               opt.url += ((/\?/).test(opt.url) ? '&' : '?') + q;
+                                                               break;
+
+                                                       default:
+                                                               if (content == null) {
+                                                                       content = q;
+                                                                       contenttype = 'application/x-www-form-urlencoded';
+                                                               }
                                                        }
                                                }
                                        }
-                               }
 
-                               if (!opt.cache)
-                                       opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime();
+                                       if (!opt.cache)
+                                               opt.url += ((/\?/).test(opt.url) ? '&' : '?') + (new Date()).getTime();
 
-                               if (isQueueableRequest(opt)) {
-                                       requestQueue.push([opt, rejectFn, resolveFn]);
-                                       requestAnimationFrame(flushRequestQueue);
-                                       return;
-                               }
+                                       if (isQueueableRequest(opt)) {
+                                               requestQueue.push([opt, rejectFn, resolveFn]);
+                                               requestAnimationFrame(flushRequestQueue);
+                                               return;
+                                       }
 
-                               if ('username' in opt && 'password' in opt)
-                                       opt.xhr.open(opt.method, opt.url, true, opt.username, opt.password);
-                               else
-                                       opt.xhr.open(opt.method, opt.url, true);
+                                       if ('username' in opt && 'password' in opt)
+                                               opt.xhr.open(opt.method, opt.url, true, opt.username, opt.password);
+                                       else
+                                               opt.xhr.open(opt.method, opt.url, true);
 
-                               opt.xhr.responseType = opt.responseType || 'text';
+                                       opt.xhr.responseType = opt.responseType || 'text';
 
-                               if ('overrideMimeType' in opt.xhr)
-                                       opt.xhr.overrideMimeType('application/octet-stream');
+                                       if ('overrideMimeType' in opt.xhr)
+                                               opt.xhr.overrideMimeType('application/octet-stream');
 
-                               if ('timeout' in opt)
-                                       opt.xhr.timeout = +opt.timeout;
+                                       if ('timeout' in opt)
+                                               opt.xhr.timeout = +opt.timeout;
 
-                               if ('credentials' in opt)
-                                       opt.xhr.withCredentials = !!opt.credentials;
+                                       if ('credentials' in opt)
+                                               opt.xhr.withCredentials = !!opt.credentials;
 
-                               if (opt.content != null) {
-                                       switch (typeof(opt.content)) {
-                                       case 'function':
-                                               content = opt.content(xhr);
-                                               break;
+                                       if (opt.content != null) {
+                                               switch (typeof(opt.content)) {
+                                               case 'function':
+                                                       content = opt.content(xhr);
+                                                       break;
 
-                                       case 'object':
-                                               if (!(opt.content instanceof FormData)) {
-                                                       content = JSON.stringify(opt.content);
-                                                       contenttype = 'application/json';
-                                               }
-                                               else {
-                                                       content = opt.content;
-                                               }
-                                               break;
+                                               case 'object':
+                                                       if (!(opt.content instanceof FormData)) {
+                                                               content = JSON.stringify(opt.content);
+                                                               contenttype = 'application/json';
+                                                       }
+                                                       else {
+                                                               content = opt.content;
+                                                       }
+                                                       break;
 
-                                       default:
-                                               content = String(opt.content);
+                                               default:
+                                                       content = String(opt.content);
+                                               }
                                        }
-                               }
 
-                               if ('headers' in opt)
-                                       for (var header in opt.headers)
-                                               if (opt.headers.hasOwnProperty(header)) {
-                                                       if (header.toLowerCase() != 'content-type')
-                                                               opt.xhr.setRequestHeader(header, opt.headers[header]);
-                                                       else
-                                                               contenttype = opt.headers[header];
-                                               }
+                                       if ('headers' in opt)
+                                               for (var header in opt.headers)
+                                                       if (opt.headers.hasOwnProperty(header)) {
+                                                               if (header.toLowerCase() != 'content-type')
+                                                                       opt.xhr.setRequestHeader(header, opt.headers[header]);
+                                                               else
+                                                                       contenttype = opt.headers[header];
+                                                       }
 
-                               if ('progress' in opt && 'upload' in opt.xhr)
-                                       opt.xhr.upload.addEventListener('progress', opt.progress);
+                                       if ('progress' in opt && 'upload' in opt.xhr)
+                                               opt.xhr.upload.addEventListener('progress', opt.progress);
 
-                               if (contenttype != null)
-                                       opt.xhr.setRequestHeader('Content-Type', contenttype);
+                                       if (contenttype != null)
+                                               opt.xhr.setRequestHeader('Content-Type', contenttype);
 
-                               try {
-                                       opt.xhr.send(content);
-                               }
-                               catch (e) {
-                                       rejectFn.call(opt, e);
-                               }
-                       });
+                                       try {
+                                               opt.xhr.send(content);
+                                       }
+                                       catch (e) {
+                                               rejectFn.call(opt, e);
+                                       }
+                               });
+                       }).bind(this));
                },
 
                handleReadyStateChange: function(resolveFn, rejectFn, ev) {