Software / code / prosody
File
core/configmanager.lua @ 13801:a5d5fefb8b68 13.0
mod_tls: Enable Prosody's certificate checking for incoming s2s connections (fixes #1916) (thanks Damian, Zash)
Various options in Prosody allow control over the behaviour of the certificate
verification process For example, some deployments choose to allow falling
back to traditional "dialback" authentication (XEP-0220), while others verify
via DANE, hard-coded fingerprints, or other custom plugins.
Implementing this flexibility requires us to override OpenSSL's default
certificate verification, to allow Prosody to verify the certificate itself,
apply custom policies and make decisions based on the outcome.
To enable our custom logic, we have to suppress OpenSSL's default behaviour of
aborting the connection with a TLS alert message. With LuaSec, this can be
achieved by using the verifyext "lsec_continue" flag.
We also need to use the lsec_ignore_purpose flag, because XMPP s2s uses server
certificates as "client" certificates (for mutual TLS verification in outgoing
s2s connections).
Commit 99d2100d2918 moved these settings out of the defaults and into mod_s2s,
because we only really need these changes for s2s, and they should be opt-in,
rather than automatically applied to all TLS services we offer.
That commit was incomplete, because it only added the flags for incoming
direct TLS connections. StartTLS connections are handled by mod_tls, which was
not applying the lsec_* flags. It previously worked because they were already
in the defaults.
This resulted in incoming s2s connections with "invalid" certificates being
aborted early by OpenSSL, even if settings such as `s2s_secure_auth = false`
or DANE were present in the config.
Outgoing s2s connections inherit verify "none" from the defaults, which means
OpenSSL will receive the cert but will not terminate the connection when it is
deemed invalid. This means we don't need lsec_continue there, and we also
don't need lsec_ignore_purpose (because the remote peer is a "server").
Wondering why we can't just use verify "none" for incoming s2s? It's because
in that mode, OpenSSL won't request a certificate from the peer for incoming
connections. Setting verify "peer" is how you ask OpenSSL to request a
certificate from the client, but also what triggers its built-in verification.
| author | Matthew Wild <mwild1@gmail.com> |
|---|---|
| date | Tue, 01 Apr 2025 17:26:56 +0100 |
| parent | 13745:994ea8d54b72 |
| child | 13840:1b4f2d010141 |
line wrap: on
line source
-- Prosody IM -- Copyright (C) 2008-2010 Matthew Wild -- Copyright (C) 2008-2010 Waqas Hussain -- -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- local _G = _G; local setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs = setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs; local format, math_max, t_insert = string.format, math.max, table.insert; local envload = require"prosody.util.envload".envload; local deps = require"prosody.util.dependencies"; local it = require"prosody.util.iterators"; local resolve_relative_path = require"prosody.util.paths".resolve_relative_path; local glob_to_pattern = require"prosody.util.paths".glob_to_pattern; local path_sep = package.config:sub(1,1); local get_traceback_table = require "prosody.util.debug".get_traceback_table; local errors = require "prosody.util.error"; local log = require "prosody.util.logger".init("config"); local encodings = deps.softreq"prosody.util.encodings"; local nameprep = encodings and encodings.stringprep.nameprep or function (host) return host:lower(); end local _M = {}; local _ENV = nil; -- luacheck: std none _M.resolve_relative_path = resolve_relative_path; -- COMPAT local parser = nil; local config_mt = { __index = function (t, _) return rawget(t, "*"); end}; local config = setmetatable({ ["*"] = { } }, config_mt); local files = {}; local credentials_directory = nil; local credential_fallback_fatal = true; -- When host not found, use global local host_mt = { __index = function(_, k) return config["*"][k] end } function _M.getconfig() return config; end function _M.get(host, key) local v = config[host][key]; if v and errors.is_error(v) then log("warn", "%s:%d: %s", v.context.filename, v.context.fileline, v.text); return nil; end return v; end function _M.rawget(host, key) local hostconfig = rawget(config, host); if hostconfig then return rawget(hostconfig, key); end end local function set(config_table, host, key, value) if host and key then local hostconfig = rawget(config_table, host); if not hostconfig then hostconfig = rawset(config_table, host, setmetatable({}, host_mt))[host]; end hostconfig[key] = value; return true; end return false; end local function rawget_option(config_table, host, key) if host and key then local hostconfig = rawget(config_table, host); if not hostconfig then return nil; end return rawget(hostconfig, key); end end function _M.set(host, key, value) return set(config, host, key, value); end function _M.load(filename, config_format) config_format = config_format or filename:match("%w+$"); if config_format == "lua" then local f, err = io.open(filename); if f then local new_config = setmetatable({ ["*"] = { } }, config_mt); local ok, err = parser.load(f:read("*a"), filename, new_config); f:close(); if ok then config = new_config; end return ok, "parser", err; end return f, "file", err; end if not config_format then return nil, "file", "no parser specified"; else return nil, "file", "no parser for "..(config_format); end end function _M.files() return files; end -- Built-in Lua parser do local pcall = _G.pcall; local function get_line_number(config_file) local tb = get_traceback_table(nil, 2); for i = 1, #tb do if tb[i].info.short_src == config_file then return tb[i].info.currentline; end end end local config_option_proxy_mt = { __index = setmetatable({ append = function (self, value) local original_option = self:value(); if original_option == nil then original_option = {}; end if type(value) ~= "table" then error("'append' operation expects a list of values to append to the existing list", 2); end if value[1] ~= nil then for _, v in ipairs(value) do t_insert(original_option, v); end else for k, v in pairs(value) do original_option[k] = v; end end set(self.config_table, self.host, self.option_name, original_option); return self; end; value = function (self) return rawget_option(self.config_table, self.host, self.option_name); end; values = function (self) return it.values(self:value()); end; }, { __index = function (t, k) --luacheck: ignore 212/t error("Unknown config option operation: '"..k.."'", 2); end; }); __call = function (self, v2) local v = self:value() or {}; if type(v) == "table" and type(v2) == "table" then return self:append(v2); end error("Invalid syntax - missing '=' perhaps?", 2); end; }; -- For reading config values out of files. local function filereader(basepath, defaultmode) return function(filename, mode) local f, err = io.open(resolve_relative_path(basepath, filename)); if not f then error(err, 2); end local content, err = f:read(mode or defaultmode); f:close(); if not content then error(err, 2); end return content; end end -- Collect lines into an array local function linereader(basepath) return function(filename) local ret = {}; for line in io.lines(resolve_relative_path(basepath, filename)) do t_insert(ret, line); end return ret; end end parser = {}; function parser.load(data, config_file, config_table) local set_options = {}; -- set_options[host.."/"..option_name] = true (when the option has been set already in this file) local warnings = {}; local env; local config_path = config_file:gsub("[^"..path_sep.."]+$", ""); -- The ' = true' are needed so as not to set off __newindex when we assign the functions below env = setmetatable({ Host = true, host = true, VirtualHost = true, Component = true, component = true, FileContents = true, FileLine = true, FileLines = true, Credential = true, Include = true, include = true, RunScript = true }, { __index = function (_, k) if k:match("^ENV_") then return os.getenv(k:sub(5)); end if k == "Lua" then return _G; end local val = rawget_option(config_table, env.__currenthost or "*", k); local g_val = rawget(_G, k); if val ~= nil or g_val == nil then if type(val) == "table" then return setmetatable({ config_table = config_table; host = env.__currenthost or "*"; option_name = k; }, config_option_proxy_mt); end return val; end if g_val ~= nil then t_insert( warnings, ("%s:%d: direct usage of the Lua API is deprecated - replace `%s` with `Lua.%s`"):format( config_file, get_line_number(config_file), k, k ) ); end return g_val; end, __newindex = function (_, k, v) local host = env.__currenthost or "*"; local option_path = host.."/"..k; if set_options[option_path] then t_insert(warnings, ("%s:%d: Duplicate option '%s'"):format(config_file, get_line_number(config_file), k)); end set_options[option_path] = true; set(config_table, env.__currenthost or "*", k, v); end }); rawset(env, "__currenthost", "*") -- Default is global function env.VirtualHost(name) if not name then error("Host must have a name", 2); end local prepped_name = nameprep(name); if not prepped_name then error(format("Name of Host %q contains forbidden characters", name), 0); end name = prepped_name; if rawget(config_table, name) and rawget(config_table[name], "component_module") then error(format("Host %q clashes with previously defined %s Component %q, for services use a sub-domain like conference.%s", name, config_table[name].component_module:gsub("^%a+$", { component = "external", muc = "MUC"}), name, name), 0); end rawset(env, "__currenthost", name); -- Needs at least one setting to logically exist :) set(config_table, name or "*", "defined", true); return function (config_options) rawset(env, "__currenthost", "*"); -- Return to global scope if type(config_options) == "string" then error(format("VirtualHost entries do not accept a module name (module '%s' provided for host '%s')", config_options, name), 2); elseif type(config_options) ~= "table" then error("Invalid syntax following VirtualHost, expected options but received a "..type(config_options), 2); end for option_name, option_value in pairs(config_options) do set(config_table, name or "*", option_name, option_value); end end; end env.Host, env.host = env.VirtualHost, env.VirtualHost; function env.Component(name) if not name then error("Component must have a name", 2); end local prepped_name = nameprep(name); if not prepped_name then error(format("Name of Component %q contains forbidden characters", name), 0); end name = prepped_name; if rawget(config_table, name) and rawget(config_table[name], "defined") and not rawget(config_table[name], "component_module") then error(format("Component %q clashes with previously defined VirtualHost %q, for services use a sub-domain like conference.%s", name, name, name), 0); end set(config_table, name, "component_module", "component"); -- Don't load the global modules by default set(config_table, name, "load_global_modules", false); rawset(env, "__currenthost", name); local function handle_config_options(config_options) rawset(env, "__currenthost", "*"); -- Return to global scope for option_name, option_value in pairs(config_options) do set(config_table, name or "*", option_name, option_value); end end return function (module) if type(module) == "string" then set(config_table, name, "component_module", module); return handle_config_options; end return handle_config_options(module); end end env.component = env.Component; function env.Include(file) -- Check whether this is a wildcard Include if file:match("[*?]") then local lfs = deps.softreq "lfs"; if not lfs then error(format("Error expanding wildcard pattern in Include %q - LuaFileSystem not available", file)); end local path_pos, glob = file:match("()([^"..path_sep.."]+)$"); local path = file:sub(1, math_max(path_pos-2,0)); if #path > 0 then path = resolve_relative_path(config_path, path); else path = config_path; end local patt = glob_to_pattern(glob); for f in lfs.dir(path) do if f:sub(1,1) ~= "." and f:match(patt) then env.Include(path..path_sep..f); end end return; end -- Not a wildcard, so resolve (potentially) relative path and run through config parser file = resolve_relative_path(config_path, file); local f, err = io.open(file); if f then local ret, err = parser.load(f:read("*a"), file, config_table); if not ret then error(err:gsub("%[string.-%]", file), 0); end if err then for _, warning in ipairs(err) do t_insert(warnings, warning); end end end if not f then error("Error loading included "..file..": "..err, 0); end return f, err; end env.include = env.Include; function env.RunScript(file) return dofile(resolve_relative_path(config_path, file)); end env.FileContents = filereader(config_path, "*a"); env.FileLine = filereader(config_path, "*l"); env.FileLines = linereader(config_path); if credentials_directory then env.Credential = filereader(credentials_directory, "*a"); elseif credential_fallback_fatal then env.Credential = function() error("Credential() requires the $CREDENTIALS_DIRECTORY environment variable to be set", 2) end else env.Credential = function() return errors.new({ type = "continue"; text = "Credential() requires the $CREDENTIALS_DIRECTORY environment variable to be set"; }, { filename = config_file; fileline = get_line_number(config_file) }); end end local chunk, err = envload(data, "@"..config_file, env); if not chunk then return nil, err; end local ok, err = pcall(chunk); if not ok then return nil, err; end t_insert(files, config_file); return true, warnings; end end function _M.set_credentials_directory(directory) credentials_directory = directory; end function _M.set_credential_fallback_mode(mode) credential_fallback_fatal = mode == "error"; end return _M;