Mercurial > prosody-modules
view mod_http_admin_api/mod_http_admin_api.lua @ 5401:c8d04ac200fc
mod_http_oauth2: Reject loopback URIs as client_uri
This really should be a proper website with info, https://localhost is
not good enough. Ideally we'd validate that it's got proper DNS and is
actually reachable, but triggering HTTP or even DNS lookups seems like
it would carry abuse potential that would best to avoid.
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Tue, 02 May 2023 16:20:55 +0200 |
parents | 5178c13deb78 |
children | 4c84cfb586c1 |
line wrap: on
line source
local usermanager = require "core.usermanager"; local jid = require "util.jid"; local it = require "util.iterators"; local json = require "util.json"; local st = require "util.stanza"; local array = require "util.array"; local statsmanager = require "core.statsmanager"; module:depends("http"); local announce = module:depends("announce"); local invites = module:depends("invites"); local tokens = module:depends("tokenauth"); local mod_pep = module:depends("pep"); local mod_groups = module:depends("groups_internal"); local push_errors = module:shared("cloud_notify/push_errors"); local site_name = module:get_option_string("site_name", module.host); local manual_stats_collection = module:context("*"):get_option("statistics_interval") == "manual"; local json_content_type = "application/json"; local www_authenticate_header = ("Bearer realm=%q"):format(module.host.."/"..module.name); local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; local xmlns_nick = "http://jabber.org/protocol/nick"; local function check_credentials(request) local auth_type, auth_data = string.match(request.headers.authorization or "", "^(%S+)%s(.+)$"); if not (auth_type and auth_data) then return false; end if auth_type == "Bearer" then return tokens.get_token_session(auth_data); end return nil; end module:default_permission("prosody:admin", ":access-admin-api"); function check_auth(routes) local function check_request_auth(event) local session = check_credentials(event.request); if not session then event.response.headers.authorization = www_authenticate_header; return false, 401; end -- FIXME this should probably live in mod_tokenauth or similar session.type = "c2s"; session.full_jid = jid.join(session.username, session.host, session.resource); event.session = session; if not module:may(":access-admin-api", event) then return false, 403; end return true; end for route, handler in pairs(routes) do routes[route] = function (event, ...) local permit, code = check_request_auth(event); if not permit then return code; end return handler(event, ...); end; end return routes; end local function token_info_to_invite_info(token_info) local additional_data = token_info.additional_data; local groups = additional_data and additional_data.groups or nil; local source = additional_data and additional_data.source or nil; local reset = not not (additional_data and additional_data.allow_reset or nil); return { id = token_info.token; type = token_info.type; reusable = not not token_info.reusable; inviter = token_info.inviter; jid = token_info.jid; uri = token_info.uri; landing_page = token_info.landing_page; created_at = token_info.created_at; expires = token_info.expires; groups = groups; source = source; reset = reset; }; end function list_invites(event) local invites_list = {}; for token, invite in invites.pending_account_invites() do --luacheck: ignore 213/token table.insert(invites_list, token_info_to_invite_info(invite)); end table.sort(invites_list, function (a, b) return a.created_at < b.created_at; end); event.response.headers["Content-Type"] = json_content_type; return json.encode_array(invites_list); end function get_invite_by_id(event, invite_id) local invite = invites.get_account_invite_info(invite_id); if not invite then return 404; end event.response.headers["Content-Type"] = json_content_type; return json.encode(token_info_to_invite_info(invite)); end function create_invite_type(event, invite_type) local options; local request = event.request; if request.body and #request.body > 0 then if request.headers.content_type ~= json_content_type then module:log("warn", "Invalid content type"); return 400; end options = json.decode(event.request.body); if not options then module:log("warn", "Invalid JSON"); return 400; end else options = {}; end local source = event.session.username .. "@" .. module.host .. "/admin_api"; local invite; if invite_type == "reset" then if not options.username then return 400; end invite = invites.create_account_reset(options.username, options.ttl); elseif invite_type == "group" then if not options.groups then return 400; end invite = invites.create_group(options.groups, { source = source; }, options.ttl); elseif invite_type == "account" then invite = invites.create_account(options.username, { source = source; groups = options.groups; }, options.ttl); else return 400; end if not invite then return 500; end event.response.headers["Content-Type"] = json_content_type; return json.encode(token_info_to_invite_info(invite)); end function delete_invite(event, invite_id) --luacheck: ignore 212/event if not invites.delete_account_invite(invite_id) then return 404; end return 200; end local function get_user_info(username) if not usermanager.user_exists(username, module.host) then return nil; end local display_name; do local pep_service = mod_pep.get_pep_service(username); local ok, _, nick_item = pep_service:get_last_item(xmlns_nick, true); if ok and nick_item then display_name = nick_item:get_child_text("nick", xmlns_nick); end end local primary_role, secondary_roles, legacy_roles; if usermanager.get_user_role then primary_role = usermanager.get_user_role(username, module.host); secondary_roles = array.collect(it.keys(usermanager.get_user_secondary_roles(username, module.host))); elseif usermanager.get_user_roles then -- COMPAT w/0.12 legacy_roles = array(); local roles_map = usermanager.get_user_roles(username, module.host); for role_name in pairs(roles_map) do legacy_roles:push(role_name); end end return { username = username; display_name = display_name; role = primary_role and primary_role.name or nil; secondary_roles = secondary_roles; roles = legacy_roles; -- COMPAT w/0.12 }; end local function get_session_debug_info(session) local info = { full_jid = session.full_jid; ip = session.ip; since = math.floor(session.conntime); status = { connected = not not session.conn; hibernating = not not session.hibernating; }; features = { carbons = not not session.want_carbons; encrypted = not not session.secure; acks = not not session.smacks; resumption = not not session.resumption_token; mobile_optimization = not not session.csi_counter; push_notifications = not not session.push_identifier; history = not not session.mam_requested; }; queues = {}; }; -- CSI if session.state then info.status.active = session.state == "active"; info.queues.held_stanzas = session.csi_counter or 0; end -- Smacks queue if session.last_requested_h and session.last_acknowledged_stanza then info.queues.awaiting_acks = session.last_requested_h - session.last_acknowledged_stanza; elseif session.outgoing_stanza_queue then -- New mod_smacks info.queues.awaiting_acks = session.outgoing_stanza_queue:count_unacked(); end if session.push_identifier then info.push_info = { id = session.push_identifier; wakeup_push_sent = session.first_hibernated_push; }; end return info; end local function get_user_omemo_info(username) local everything_valid = true; local any_device = false; local omemo_status = {}; local omemo_devices; local pep_service = mod_pep.get_pep_service(username); if pep_service and pep_service.nodes then local ok, _, device_list = pep_service:get_last_item("eu.siacs.conversations.axolotl.devicelist", true); if ok and device_list then device_list = device_list:get_child("list", "eu.siacs.conversations.axolotl"); end if device_list then omemo_devices = {}; for device_entry in device_list:childtags("device") do any_device = true; local device_info = {}; local device_id = tonumber(device_entry.attr.id or ""); if device_id then device_info.id = device_id; local bundle_id = ("eu.siacs.conversations.axolotl.bundles:%d"):format(device_id); local have_bundle, _, bundle = pep_service:get_last_item(bundle_id, true); if have_bundle and bundle and bundle:get_child("bundle", "eu.siacs.conversations.axolotl") then device_info.have_bundle = true; local config_ok, bundle_config = pep_service:get_node_config(bundle_id, true); if config_ok and bundle_config then device_info.bundle_config = bundle_config; if bundle_config.max_items == 1 and bundle_config.access_model == "open" and bundle_config.persist_items == true and bundle_config.publish_model == "publishers" then device_info.valid = true; end end end end if device_info.valid == nil then device_info.valid = false; everything_valid = false; end table.insert(omemo_devices, device_info); end local config_ok, list_config = pep_service:get_node_config("eu.siacs.conversations.axolotl.devicelist", true); if config_ok and list_config then omemo_status.config = list_config; if list_config.max_items == 1 and list_config.access_model == "open" and list_config.persist_items == true and list_config.publish_model == "publishers" then omemo_status.config_valid = true; end end if omemo_status.config_valid == nil then omemo_status.config_valid = false; everything_valid = false; end end end omemo_status.valid = everything_valid and any_device; return { status = omemo_status; devices = omemo_devices; }; end local function get_user_debug_info(username) local debug_info = { time = os.time(); }; -- Online sessions do local user_sessions = prosody.hosts[module.host].sessions[username]; if user_sessions then user_sessions = user_sessions.sessions end local sessions = {}; if user_sessions then for _, session in pairs(user_sessions) do table.insert(sessions, get_session_debug_info(session)); end end debug_info.sessions = sessions; end -- Push registrations do local store = module:open_store("cloud_notify"); local services = store:get(username); local push_registrations = {}; if services then for identifier, push_info in pairs(services) do push_registrations[identifier] = { since = push_info.timestamp; service = push_info.jid; node = push_info.node; error_count = push_errors[identifier] or 0; client_id = push_info.client_id; encryption = not not push_info.encryption; }; end end debug_info.push_registrations = push_registrations; end -- OMEMO debug_info.omemo = get_user_omemo_info(username); return debug_info; end function list_users(event) local user_list = {}; for username in usermanager.users(module.host) do table.insert(user_list, get_user_info(username)); end event.response.headers["Content-Type"] = json_content_type; return json.encode_array(user_list); end function get_user_by_name(event, username) local property do local name, sub_path = username:match("^([^/]+)/(%w+)$"); if name then username = name; property = sub_path; end end if property == "groups" then event.response.headers["Content-Type"] = json_content_type; return json.encode(mod_groups.get_user_groups(username)); elseif property == "debug" then event.response.headers["Content-Type"] = json_content_type; return json.encode(get_user_debug_info(username)); end local user_info = get_user_info(username); if not user_info then return 404; end event.response.headers["Content-Type"] = json_content_type; return json.encode(user_info); end function update_user(event, username) local current_user = get_user_info(username); local request = event.request; if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then return 400; end local new_user = json.decode(event.request.body); if not new_user then return 400; end if new_user.username and new_user.username ~= username then return 400; end local final_user = {}; if new_user.display_name then local pep_service = mod_pep.get_pep_service(username); -- TODO: publish local nick_item = st.stanza("item", { xmlns = xmlns_pubsub, id = "current" }) :text_tag("nick", new_user.display_name, { xmlns = xmlns_nick }); if pep_service:publish(xmlns_nick, true, "current", nick_item, { access_model = "open"; _defaults_only = true; }) then final_user.display_name = new_user.display_name; end end if new_user.role then if not usermanager.set_user_role then return 500, "feature-not-implemented"; end if not usermanager.set_user_role(username, module.host, new_user.role) then module:log("error", "failed to set role %s for %s", new_user.role, username); return 500; end end if new_user.roles then -- COMPAT w/0.12 if not usermanager.set_user_roles then return 500, "feature-not-implemented" end local backend_roles = {}; for _, role in ipairs(new_user.roles) do backend_roles[role] = true; end local jid = username.."@"..module.host; if not usermanager.set_user_roles(username, module.host, backend_roles) then module:log("error", "failed to set roles %q for %s", backend_roles, jid) return 500 end end return 200; end function delete_user(event, username) --luacheck: ignore 212/event if not usermanager.delete_user(username, module.host) then return 404; end return 200; end function list_groups(event) local group_list = {}; for group_id in mod_groups.groups() do local group_info = mod_groups.get_info(group_id); table.insert(group_list, { id = group_id; name = group_info.name; muc_jid = group_info.muc_jid; members = mod_groups.get_members(group_id); }); end event.response.headers["Content-Type"] = json_content_type; return json.encode_array(group_list); end function get_group_by_id(event, group_id) local group = mod_groups.get_info(group_id); if not group then return 404; end event.response.headers["Content-Type"] = json_content_type; return json.encode({ id = group_id; name = group.name; muc_jid = group.muc_jid; members = mod_groups.get_members(group_id); }); end function create_group(event) local request = event.request; if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then return 400; end local group = json.decode(event.request.body); if not group then return 400; end if not group.name then module:log("warn", "Group missing name property"); return 400; end local create_muc = group.create_muc and true or false; local group_id = mod_groups.create( { name = group.name; }, create_muc ); if not group_id then return 500; end event.response.headers["Content-Type"] = json_content_type; local info = mod_groups.get_info(group_id); return json.encode({ id = group_id; name = info.name; muc_jid = info.muc_jid or nil; members = {}; }); end function update_group(event, group) --luacheck: ignore 212/event -- Add member local group_id, member_name = group:match("^([^/]+)/members/([^/]+)$"); if group_id and member_name then if not mod_groups.add_member(group_id, member_name) then return 500; end return 204; end local group_id = group:match("^([^/]+)$") if group_id then local request = event.request; if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then return 400; end local update = json.decode(event.request.body); if not update then return 400; end local group_info = mod_groups.get_info(group_id); if not group_info then return 404; end if update.name then group_info["name"] = update.name; end if mod_groups.set_info(group_id, group_info) then return 204; else return 500; end end return 404; end function delete_group(event, subpath) --luacheck: ignore 212/event -- Check if this is a membership deletion and handle it local group_id, member_name = subpath:match("^([^/]+)/members/([^/]+)$"); if group_id and member_name then if mod_groups.remove_member(group_id, member_name) then return 204; else return 500; end else -- Action refers to the group group_id = subpath; end if not group_id then return 400; end if not mod_groups.exists(group_id) then return 404; end if not mod_groups.delete(group_id) then return 500; end return 204; end local function get_server_info(event) event.response.headers["Content-Type"] = json_content_type; return json.encode({ site_name = site_name; version = prosody.version; }); end local function maybe_export_plain_gauge(mf) if mf == nil then return nil end return mf.data.value end local function maybe_export_plain_counter(mf) if mf == nil then return nil end return { since = mf.data._created, value = mf.data.value, } end local function maybe_export_summed_gauge(mf) if mf == nil then return nil end local sum = 0; for _, metric in mf:iter_metrics() do sum = sum + metric.value; end return sum; end local function get_server_metrics(event) event.response.headers["Content-Type"] = json_content_type; local result = {}; if manual_stats_collection then statsmanager.collect(); end local families = statsmanager.get_metric_registry():get_metric_families(); result.memory = maybe_export_plain_gauge(families.process_resident_memory_bytes); result.cpu = maybe_export_plain_counter(families.process_cpu_seconds); result.c2s = maybe_export_summed_gauge(families["prosody_mod_c2s/connections"]) result.uploads = maybe_export_summed_gauge(families["prosody_mod_http_file_share/total_storage_bytes"]); return json.encode(result); end local function post_server_announcement(event) local request = event.request; if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then return 400; end local body = json.decode(event.request.body); if not body then return 400; end if type(body.recipients) ~= "table" and body.recipients ~= "online" and body.recipients ~= "all" then return 400; end if not body.body or #body.body == 0 then return 400; end local message = st.message():tag("body"):text(body.body):up(); local host = module.host message.attr.from = host if body.recipients == "online" then announce.send_to_online(message, host); elseif body.recipients == "all" then for username in usermanager.users(host) do message.attr.to = username .. "@" .. host module:send(st.clone(message)) end else for _, addr in ipairs(body.recipients) do message.attr.to = addr module:send(message) end end return 201; end module:provides("http", { route = check_auth { ["GET /invites"] = list_invites; ["GET /invites/*"] = get_invite_by_id; ["POST /invites/*"] = create_invite_type; ["DELETE /invites/*"] = delete_invite; ["GET /users"] = list_users; ["GET /users/*"] = get_user_by_name; ["PUT /users/*"] = update_user; ["DELETE /users/*"] = delete_user; ["GET /groups"] = list_groups; ["GET /groups/*"] = get_group_by_id; ["POST /groups"] = create_group; ["PUT /groups/*"] = update_group; ["DELETE /groups/*"] = delete_group; ["GET /server/info"] = get_server_info; ["GET /server/metrics"] = get_server_metrics; ["POST /server/announcement"] = post_server_announcement; }; });