treewide: rework rollback/apply workflow

Rework the apply confirmation mechanism to be session agnostic in order to
circumvent cross domain restrictions which prevent the JS code from issuing
apply confirm requests in some cases, e.g. when changing the LAN IP.

Confirmation calls may now be done from unauthenticated pages, as long as a
matching confirmation token is sent along with the request.

The reasoning behind this is that there is little security impact in
confirming pending apply sessions, especially since those sessions can only
be initiated while being authenticated.

After this change, LuCI will now launch a confirmation process on every
rendered page when a rollback is pending. The confirmation will happen
regardless of whether the user is logged in or not, or if the current page
is a CBI form or static template.

A confirmation request now also requires a random one-time token which is
rendered along with the confirmation JavaScript code in order to succeed.

This token is not meant to provide security but to ensure that the confirm
was triggered from an interactive browser session and not some background
HTTP requests that happened to end up in the admin ui.

As a consequence, the different apply/confirm/rollback code paths in CBI
maps and the UCI change/revert pages have been consolidated into one common
implementation residing in the common global theme agnostic footer template.

Signed-off-by: Jo-Philipp Wich <jo@mein.io>
This commit is contained in:
Jo-Philipp Wich 2018-07-26 22:12:45 +02:00
parent 98217f8f8d
commit e5a1ac0228
8 changed files with 129 additions and 75 deletions

View file

@ -893,8 +893,6 @@ local function _cbi(self, ...)
local pageaction = true local pageaction = true
local parsechain = { } local parsechain = { }
local is_rollback, time_remaining = uci:rollback_pending()
for i, res in ipairs(maps) do for i, res in ipairs(maps) do
if res.apply_needed and res.parsechain then if res.apply_needed and res.parsechain then
local c local c
@ -921,8 +919,6 @@ local function _cbi(self, ...)
for i, res in ipairs(maps) do for i, res in ipairs(maps) do
res:render({ res:render({
firstmap = (i == 1), firstmap = (i == 1),
applymap = applymap,
confirmmap = (is_rollback and time_remaining or nil),
redirect = redirect, redirect = redirect,
messages = messages, messages = messages,
pageaction = pageaction, pageaction = pageaction,
@ -932,11 +928,12 @@ local function _cbi(self, ...)
if not config.nofooter then if not config.nofooter then
tpl.render("cbi/footer", { tpl.render("cbi/footer", {
flow = config, flow = config,
pageaction = pageaction, pageaction = pageaction,
redirect = redirect, redirect = redirect,
state = state, state = state,
autoapply = config.autoapply autoapply = config.autoapply,
trigger_apply = applymap
}) })
end end
end end

View file

@ -147,19 +147,31 @@ function apply(self, rollback)
local _, err local _, err
if rollback then if rollback then
local sys = require "luci.sys"
local conf = require "luci.config" local conf = require "luci.config"
local timeout = tonumber(conf and conf.apply and conf.apply.rollback or "") or 0 local timeout = tonumber(conf and conf.apply and conf.apply.rollback or 30) or 0
_, err = call("apply", { _, err = call("apply", {
timeout = (timeout > 30) and timeout or 30, timeout = (timeout > 30) and timeout or 30,
rollback = true rollback = true
}) })
if not err then if not err then
local now = os.time()
local token = sys.uniqueid(16)
util.ubus("session", "set", { util.ubus("session", "set", {
ubus_rpc_session = session_id, ubus_rpc_session = "00000000000000000000000000000000",
values = { rollback = os.time() + timeout } values = {
rollback = {
token = token,
session = session_id,
timeout = now + timeout
}
}
}) })
return token
end end
else else
_, err = call("changes", {}) _, err = call("changes", {})
@ -184,40 +196,72 @@ function apply(self, rollback)
return (err == nil), ERRSTR[err] return (err == nil), ERRSTR[err]
end end
function confirm(self) function confirm(self, token)
local _, err = call("confirm", {}) local is_pending, time_remaining, rollback_sid, rollback_token = self:rollback_pending()
if not err then
util.ubus("session", "set", { if is_pending then
ubus_rpc_session = session_id, if token ~= rollback_token then
values = { rollback = 0 } return false, "Permission denied"
end
local _, err = util.ubus("uci", "confirm", {
ubus_rpc_session = rollback_sid
}) })
if not err then
util.ubus("session", "set", {
ubus_rpc_session = "00000000000000000000000000000000",
values = { rollback = {} }
})
end
return (err == nil), ERRSTR[err]
end end
return (err == nil), ERRSTR[err]
return false, "No data"
end end
function rollback(self) function rollback(self)
local _, err = call("rollback", {}) local is_pending, time_remaining, rollback_sid = self:rollback_pending()
if not err then
util.ubus("session", "set", { if is_pending then
ubus_rpc_session = session_id, local _, err = util.ubus("uci", "rollback", {
values = { rollback = 0 } ubus_rpc_session = rollback_sid
}) })
if not err then
util.ubus("session", "set", {
ubus_rpc_session = "00000000000000000000000000000000",
values = { rollback = {} }
})
end
return (err == nil), ERRSTR[err]
end end
return (err == nil), ERRSTR[err]
return false, "No data"
end end
function rollback_pending(self) function rollback_pending(self)
local deadline, err = util.ubus("session", "get", { local rv, err = util.ubus("session", "get", {
ubus_rpc_session = session_id, ubus_rpc_session = "00000000000000000000000000000000",
keys = { "rollback" } keys = { "rollback" }
}) })
if type(deadline) == "table" and local now = os.time()
type(deadline.values) == "table" and
type(deadline.values.rollback) == "number" and if type(rv) == "table" and
deadline.values.rollback > os.time() type(rv.values) == "table" and
type(rv.values.rollback) == "table" and
type(rv.values.rollback.token) == "string" and
type(rv.values.rollback.session) == "string" and
type(rv.values.rollback.timeout) == "number" and
rv.values.rollback.timeout > now
then then
return true, deadline.values.rollback - os.time() return true,
rv.values.rollback.timeout - now,
rv.values.rollback.session,
rv.values.rollback.token
end end
return false, ERRSTR[err] return false, ERRSTR[err]

View file

@ -1,4 +1,4 @@
<% export("cbi_apply_widget", function(redirect_ok) -%> <% export("cbi_apply_widget", function(redirect_ok, rollback_token) -%>
<style type="text/css"> <style type="text/css">
#cbi_apply_overlay { #cbi_apply_overlay {
position: absolute; position: absolute;
@ -51,6 +51,7 @@
uci_apply_holdoff = <%=math.max(luci.config and luci.config.apply and luci.config.apply.holdoff or 4, 1)%>, uci_apply_holdoff = <%=math.max(luci.config and luci.config.apply and luci.config.apply.holdoff or 4, 1)%>,
uci_apply_timeout = <%=math.max(luci.config and luci.config.apply and luci.config.apply.timeout or 5, 1)%>, uci_apply_timeout = <%=math.max(luci.config and luci.config.apply and luci.config.apply.timeout or 5, 1)%>,
uci_apply_display = <%=math.max(luci.config and luci.config.apply and luci.config.apply.display or 1.5, 1)%>, uci_apply_display = <%=math.max(luci.config and luci.config.apply and luci.config.apply.display or 1.5, 1)%>,
uci_confirm_auth = <% if rollback_token then %>{ token: '<%=rollback_token%>' }<% else %>null<% end %>,
was_xhr_poll_running = false; was_xhr_poll_running = false;
function uci_status_message(type, content) { function uci_status_message(type, content) {
@ -148,7 +149,7 @@
var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0); var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
window.setTimeout(function() { window.setTimeout(function() {
xhr.post('<%=url("admin/uci/confirm")%>', uci_apply_auth, call, uci_apply_timeout * 1000); xhr.post('<%=url("admin/uci/confirm")%>', uci_confirm_auth, call, uci_apply_timeout * 1000);
}, delay); }, delay);
}; };
@ -177,8 +178,11 @@
'<img src="<%=resource%>/icons/loading.gif" alt="" style="vertical-align:middle" /> ' + '<img src="<%=resource%>/icons/loading.gif" alt="" style="vertical-align:middle" /> ' +
'<%:Starting configuration apply…%>'); '<%:Starting configuration apply…%>');
xhr.post('<%=url("admin/uci")%>/' + (checked ? 'apply_rollback' : 'apply_unchecked'), uci_apply_auth, function(r) { xhr.post('<%=url("admin/uci")%>/' + (checked ? 'apply_rollback' : 'apply_unchecked'), uci_apply_auth, function(r, tok) {
if (r.status === (checked ? 200 : 204)) { if (r.status === (checked ? 200 : 204)) {
if (checked && tok !== null && typeof(tok) === 'object' && typeof(tok.token) === 'string')
uci_confirm_auth = tok;
uci_confirm(checked, Date.now() + uci_apply_rollback * 1000); uci_confirm(checked, Date.now() + uci_apply_rollback * 1000);
} }
else if (checked && r.status === 204) { else if (checked && r.status === 204) {

View file

@ -5,21 +5,6 @@
<div class="cbi-map" id="cbi-<%=self.config%>"> <div class="cbi-map" id="cbi-<%=self.config%>">
<% if self.title and #self.title > 0 then %><h2 name="content"><%=self.title%></h2><% end %> <% if self.title and #self.title > 0 then %><h2 name="content"><%=self.title%></h2><% end %>
<% if self.description and #self.description > 0 then %><div class="cbi-map-descr"><%=self.description%></div><% end %> <% if self.description and #self.description > 0 then %><div class="cbi-map-descr"><%=self.description%></div><% end %>
<%- if firstmap and (applymap or confirmmap) then -%>
<%+cbi/apply_widget%>
<% cbi_apply_widget(redirect) %>
<div class="alert-message" id="cbi_apply_status" style="display:none"></div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", function() {
<% if confirmmap then -%>
uci_confirm(true, Date.now() + <%=confirmmap%> * 1000);
<%- else -%>
uci_apply(true);
<%- end %>
});
</script>
<%- end -%>
<% if self.tabbed then %> <% if self.tabbed then %>
<ul class="cbi-tabmenu map"> <ul class="cbi-tabmenu map">
<%- self.selected_tab = luci.http.formvalue("tab.m-" .. self.config) %> <%- self.selected_tab = luci.http.formvalue("tab.m-" .. self.config) %>

View file

@ -4,4 +4,27 @@
Licensed to the public under the Apache License 2.0. Licensed to the public under the Apache License 2.0.
-%> -%>
<% include("themes/" .. theme .. "/footer") %> <%
include("themes/" .. theme .. "/footer")
local is_rollback_pending, rollback_time_remaining, rollback_session, rollback_token = luci.model.uci:rollback_pending()
if is_rollback_pending or trigger_apply or trigger_revert then
include("cbi/apply_widget")
cbi_apply_widget(redirect, rollback_token)
%>
<div class="alert-message" id="cbi_apply_status" style="display:none"></div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", function() {
<% if trigger_apply then -%>
uci_apply(true);
<%- elseif trigger_revert then -%>
uci_revert();
<%- else -%>
uci_confirm(true, Date.now() + <%=rollback_time_remaining%> * 1000);
<%- end %>
});
</script>
<%
end
%>

View file

@ -9,7 +9,7 @@ function index()
or table.concat(luci.dispatcher.context.request, "/") or table.concat(luci.dispatcher.context.request, "/")
entry({"admin", "uci"}, nil, _("Configuration")) entry({"admin", "uci"}, nil, _("Configuration"))
entry({"admin", "uci", "changes"}, call("action_changes"), _("Changes"), 40).query = {redir=redir} entry({"admin", "uci", "changes"}, post_on({ trigger_apply = true }, "action_changes"), _("Changes"), 40).query = {redir=redir}
entry({"admin", "uci", "revert"}, post("action_revert"), _("Revert"), 30).query = {redir=redir} entry({"admin", "uci", "revert"}, post("action_revert"), _("Revert"), 30).query = {redir=redir}
local node local node
@ -25,9 +25,9 @@ function index()
node.cors = true node.cors = true
node.sysauth_authenticator = authen node.sysauth_authenticator = authen
node = entry({"admin", "uci", "confirm"}, post("action_confirm"), nil) node = entry({"admin", "uci", "confirm"}, call("action_confirm"), nil)
node.cors = true node.cors = true
node.sysauth_authenticator = authen node.sysauth = false
end end
@ -36,8 +36,9 @@ function action_changes()
local changes = uci:changes() local changes = uci:changes()
luci.template.render("admin_uci/changes", { luci.template.render("admin_uci/changes", {
changes = next(changes) and changes, changes = next(changes) and changes,
timeout = timeout timeout = timeout,
trigger_apply = luci.http.formvalue("trigger_apply") and true or false
}) })
end end
@ -52,7 +53,8 @@ function action_revert()
end end
luci.template.render("admin_uci/revert", { luci.template.render("admin_uci/revert", {
changes = next(changes) and changes changes = next(changes) and changes,
trigger_revert = true
}) })
end end
@ -84,8 +86,13 @@ end
function action_apply_rollback() function action_apply_rollback()
local uci = require "luci.model.uci" local uci = require "luci.model.uci"
local _, errstr = uci:apply(true) local token, errstr = uci:apply(true)
ubus_state_to_http(errstr) if token then
luci.http.prepare_content("application/json")
luci.http.write_json({ token = token })
else
ubus_state_to_http(errstr)
end
end end
function action_apply_unchecked() function action_apply_unchecked()
@ -96,6 +103,7 @@ end
function action_confirm() function action_confirm()
local uci = require "luci.model.uci" local uci = require "luci.model.uci"
local _, errstr = uci:confirm() local token = luci.http.formvalue("token")
local _, errstr = uci:confirm(token)
ubus_state_to_http(errstr) ubus_state_to_http(errstr)
end end

View file

@ -8,11 +8,9 @@
<%- <%-
local node, redir_url = luci.dispatcher.lookup(luci.http.formvalue("redir")) local node, redir_url = luci.dispatcher.lookup(luci.http.formvalue("redir"))
export("redirect", redir_url or url("admin/uci/changes"))
include("cbi/apply_widget")
include("admin_uci/changelog") include("admin_uci/changelog")
cbi_apply_widget(redir_url or url("admin/uci/changes"))
-%> -%>
<h2 name="content"><%:Configuration%> / <%:Changes%></h2> <h2 name="content"><%:Configuration%> / <%:Changes%></h2>
@ -32,7 +30,11 @@
</form> </form>
<% end %> <% end %>
<input class="cbi-button cbi-button-save" type="button" id="apply_button" value="<%:Save & Apply%>" onclick="uci_apply(true); this.blur()" /> <form method="post" action="<%=url("admin/uci/changes")%>">
<input type="hidden" name="token" value="<%=token%>" />
<input type="hidden" name="redir" value="<%=pcdata(luci.http.formvalue("redir"))%>" />
<input class="cbi-button cbi-button-save" type="submit" name="trigger_apply" value="<%:Save & Apply%>" />
</form>
<form method="post" action="<%=url("admin/uci/revert")%>"> <form method="post" action="<%=url("admin/uci/revert")%>">
<input type="hidden" name="token" value="<%=token%>" /> <input type="hidden" name="token" value="<%=token%>" />
<input type="hidden" name="redir" value="<%=pcdata(luci.http.formvalue("redir"))%>" /> <input type="hidden" name="redir" value="<%=pcdata(luci.http.formvalue("redir"))%>" />

View file

@ -8,11 +8,9 @@
<%- <%-
local node, redir_url = luci.dispatcher.lookup(luci.http.formvalue("redir")) local node, redir_url = luci.dispatcher.lookup(luci.http.formvalue("redir"))
export("redirect", redir_url or url("admin/uci/changes"))
include("cbi/apply_widget")
include("admin_uci/changelog") include("admin_uci/changelog")
cbi_apply_widget(redir_url or url("admin/uci/revert"))
-%> -%>
<h2 name="content"><%:Configuration%> / <%:Revert%></h2> <h2 name="content"><%:Configuration%> / <%:Revert%></h2>
@ -24,13 +22,6 @@
<p><strong><%:There are no pending changes to revert!%></strong></p> <p><strong><%:There are no pending changes to revert!%></strong></p>
<% end %> <% end %>
<div class="alert-message" id="cbi_apply_status" style="display:none"></div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", function() {
uci_revert();
});
</script>
<% if redir_url then %> <% if redir_url then %>
<div class="cbi-page-actions"> <div class="cbi-page-actions">
<form class="inline" method="get" action="<%=luci.util.pcdata(redir_url)%>"> <form class="inline" method="get" action="<%=luci.util.pcdata(redir_url)%>">