diff options
Diffstat (limited to 'extensions/auth-file.lua')
| -rw-r--r-- | extensions/auth-file.lua | 556 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
1 file changed, 0 insertions, 556 deletions
diff --git a/extensions/auth-file.lua b/extensions/auth-file.lua deleted file mode 100644 index 5415ca7..0000000 --- a/extensions/auth-file.lua +++ /dev/null @@ -1,556 +0,0 @@ --- cgit auth-filter that gates repositories behind a login form and a signed --- session cookie. --- --- This is the FILE-BACKED variant. The user accounts, the groups, and the --- per-repository access lists are read from files on disk, whose paths are set --- in the CONFIGURATION block below. Edit those files without touching this --- script. This suits larger or externally managed user sets. --- --- The companion auth-inline.lua behaves identically but keeps its accounts and --- access lists inline in the script itself, which suits a small fixed set of --- users. --- --- Enable it in cgitrc with --- auth-filter=lua:/path/to/auth-file.lua --- --- SUPPORTED LUA --- --- Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Lua 5.5 is not supported, because luaossl --- has no 5.5 build. Match the runtime to the Lua that cgit is built against. --- --- HTTPS IS RECOMMENDED --- --- Serve cgit over HTTPS. Terminate TLS at the web server in front of cgit. The --- session cookie is marked Secure by default, so a browser only sends it back --- over HTTPS. If you genuinely run cgit over plain HTTP with no TLS anywhere, --- set cookie_insecure below, otherwise the cookie is never returned and login --- appears to loop. --- --- DEPENDENCIES --- --- luaossl OpenSSL binding, provides openssl.rand and openssl.hmac --- <https://github.com/wahern/luaossl> --- luaposix POSIX binding, provides posix.sys.stat and posix.unistd --- <https://github.com/luaposix/luaposix> --- --- The reliable cross-platform install is LuaRocks, matched to your Lua --- version. luaossl also needs the OpenSSL development headers present. --- --- # Debian and Ubuntu --- sudo apt install luarocks libssl-dev --- sudo luarocks --lua-version 5.1 install luaossl --- sudo luarocks --lua-version 5.1 install luaposix --- --- # Fedora --- sudo dnf install luarocks openssl-devel --- sudo luarocks --lua-version 5.1 install luaossl luaposix --- --- # Alpine --- sudo apk add luarocks openssl-dev --- sudo luarocks-5.1 install luaossl luaposix --- --- # macOS with Homebrew --- brew install luarocks openssl --- luarocks install luaossl OPENSSL_DIR="$(brew --prefix openssl)" --- luarocks install luaposix --- --- Some distributions also package these, for example lua-luaossl and lua-posix --- on Debian. If you use a distribution package, make sure it is built for the --- same Lua version as cgit. --- --- SECURITY NOTES --- --- The cookie carries only a username with no server-side session store, so --- deleting an account does not revoke a cookie already issued until it --- expires, and instances that share a secret file accept each other's cookies. --- The login form carries no CSRF token. Both are acceptable for gating read --- access to a git browser. Weigh them before guarding anything more sensitive. - -local sysstat = require("posix.sys.stat") -local unistd = require("posix.unistd") -local rand = require("openssl.rand") -local hmac = require("openssl.hmac") - --- --- ========================= CONFIGURATION ========================= --- Edit the values in this block. Nothing below it needs changing for --- ordinary use. --- - --- Accounts, one per line, as username:hash. Generate a hash with --- mkpasswd -m sha-512 -R 300000 --- This file should not be world-readable. -local users_filename = "/etc/cgit-auth/users" - --- Group membership, one per line, as groupname:user1,user2,user3,... -local groups_filename = "/etc/cgit-auth/groups" - --- Per-repository access, one per line, as reponame:group1,group2,... --- A repository listed here is protected. One not listed is public. -local repos_filename = "/etc/cgit-auth/repos" - --- Where the cookie-signing secret is stored. It is created on first use. It --- must be persistent and writable by cgit. Prefer a path OUTSIDE the cache --- root, because pruning the cache would delete a secret kept inside it and --- invalidate every live session. This file should not be world-readable. -local secret_filename = "/var/cache/cgit/auth-secret" - --- How long a login stays valid, in seconds. Default one week. -local session_seconds = 7 * 24 * 60 * 60 - --- Name of the session cookie. -local cookie_name = "cgitauth" - --- Path the cookie is scoped to. "/" covers the whole host. Set it to the cgit --- root to scope the cookie more tightly. -local cookie_path = "/" - --- Leave false so the cookie is marked Secure and only travels over HTTPS. Set --- it true ONLY if cgit is served over plain HTTP with no TLS anywhere, see the --- HTTPS note in the header. -local cookie_insecure = false - --- --- ================================================================= --- - --- A throwaway hash of the documented shape, used only to spend the same work --- on a missing account as on a present one, so a failed login does not reveal --- by timing whether the username exists. -local dummy_hash = "$6$rounds=300000$0000000000000000$" - --- Module state shared across the open, write and close calls of one request. -local action, http, cgit, post - --- --- --- Account and access-list storage. This is the ONLY part that differs from --- auth-inline.lua. Swap these two functions to change where accounts live. --- --- - -local function trim(s) - return (string.gsub(s, "^%s*(.-)%s*$", "%1")) -end - --- Return the stored password hash for a user, or nil. Reads the users file --- fresh each call. A missing or unreadable file, and any unparsable line, are --- skipped rather than fatal. -function account_hash(user) - if user == nil then - return nil - end - local wanted = user:lower() - local f = io.open(users_filename, "r") - if f == nil then - return nil - end - for line in f:lines() do - local u, h = string.match(line, "(.-):(.+)") - if u ~= nil and trim(u):lower() == wanted then - f:close() - return h - end - end - f:close() - return nil -end - --- Return the set of users allowed to access a repository, keyed by lowercased --- username, or nil if the repository is not protected. A protected repository --- whose groups resolve to no users returns an EMPTY table, which denies --- everyone rather than falling through to public. -function repo_userset(repo) - if repo == nil then - return nil - end - local groups = nil - local f = io.open(repos_filename, "r") - if f ~= nil then - for line in f:lines() do - local r, g = string.match(line, "(.-):(.+)") - if r ~= nil and trim(r) == repo then - groups = {} - for group in string.gmatch(g, "([^,]+)") do - groups[trim(group):lower()] = true - end - break - end - end - f:close() - end - if groups == nil then - return nil - end - local users = {} - local gf = io.open(groups_filename, "r") - if gf ~= nil then - for line in gf:lines() do - local g, u = string.match(line, "(.-):(.+)") - if g ~= nil and groups[trim(g):lower()] then - for user in string.gmatch(u, "([^,]+)") do - users[trim(user):lower()] = true - end - end - end - gf:close() - end - return users -end - --- --- --- Utility functions based on keplerproject/wsapi. --- --- - -function url_decode(str) - if not str then - return "" - end - str = string.gsub(str, "+", " ") - str = string.gsub(str, "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end) - str = string.gsub(str, "\r\n", "\n") - return str -end - -function url_encode(str) - if not str then - return "" - end - str = string.gsub(str, "\n", "\r\n") - str = string.gsub(str, "([^%w ])", function(c) return string.format("%%%02X", string.byte(c)) end) - str = string.gsub(str, " ", "+") - return str -end - --- Parse an application/x-www-form-urlencoded body. A value may itself contain --- '=', for example a base64 password, so the value runs to the next '&'. -function parse_qs(qs) - local tab = {} - for key, val in string.gmatch(qs or "", "([^&=]+)=([^&]*)") do - tab[url_decode(key)] = url_decode(val) - end - return tab -end - --- Return the value of the named cookie, or nil. The stored token was already --- url-encoded by secure_value, so it is returned verbatim, which keeps the --- write path (set_cookie) and the read path symmetric. Decoding it here would --- break the signature check for any value carrying a percent escape. -function get_cookie(cookies, name) - cookies = string.gsub(";" .. (cookies or "") .. ";", "%s*;%s*", ";") - return string.match(cookies, ";" .. name .. "=(.-);") -end - -function tohex(b) - local x = "" - for i = 1, #b do - x = x .. string.format("%.2x", string.byte(b, i)) - end - return x -end - --- --- --- Cookie construction and validation helpers. --- --- - -local secret = nil - --- Load the cookie-signing secret, creating it on first use. Failures raise, --- which cgit turns into a request error, so a broken secret denies rather than --- signs with nothing. -function get_secret() - if secret ~= nil then - return secret - end - local secret_file = io.open(secret_filename, "r") - if secret_file == nil then - local old_umask = sysstat.umask(63) - local temporary_filename = secret_filename .. ".tmp." .. tohex(rand.bytes(16)) - local temporary_file = io.open(temporary_filename, "w") - if temporary_file == nil then - sysstat.umask(old_umask) - error("cgit auth: cannot create secret file " .. secret_filename) - end - local wrote = temporary_file:write(tohex(rand.bytes(32))) - local closed = temporary_file:close() - if not wrote or not closed then - os.remove(temporary_filename) - sysstat.umask(old_umask) - error("cgit auth: failed writing secret file " .. secret_filename) - end - unistd.link(temporary_filename, secret_filename) -- Intentionally fails if another worker won the race. - unistd.unlink(temporary_filename) - sysstat.umask(old_umask) - secret_file = io.open(secret_filename, "r") - end - if secret_file == nil then - error("cgit auth: cannot read secret file " .. secret_filename) - end - secret = secret_file:read("*l") - secret_file:close() - if secret == nil or secret:len() ~= 64 then - secret = nil - error("cgit auth: secret file " .. secret_filename .. " is malformed, expected 64 hex characters") - end - return secret -end - --- A redirect target is unsafe if a browser would read it as another origin. --- The only such form cgit can be tricked into signing is a scheme-relative --- "//host" or "/\host". Everything else stays on this host. -function is_safe_redirect(url) - if type(url) ~= "string" then - return false - end - local head = url:sub(1, 2) - if head == "//" or head == "/\\" then - return false - end - return true -end - --- Return the value carried by a signed cookie, or nil if it does not verify. -function validate_value(expected_field, cookie) - local i = 0 - local value = "" - local field = "" - local expiration = 0 - local salt = "" - local chmac = "" - - if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then - return nil - end - - for component in string.gmatch(cookie, "[^|]+") do - if i == 0 then - field = component - elseif i == 1 then - value = component - elseif i == 2 then - -- The expiration must be a plain integer. Rejecting other forms - -- keeps the signed bytes canonical, since tonumber and tostring of - -- "1e9" or "100.0" differ across Lua versions. - if not string.match(component, "^%d+$") then - return nil - end - expiration = tonumber(component) - elseif i == 3 then - salt = component - elseif i == 4 then - chmac = component - else - break - end - i = i + 1 - end - - if chmac == nil or chmac:len() == 0 then - return nil - end - - -- Compare the HMAC without short-circuiting on the first mismatch. - if not constant_equals(chmac, tohex(hmac.new(get_secret(), "sha256"):final(field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt))) then - return nil - end - - -- An expiration of 0 never expires and is used for the redirect token. - if expiration ~= 0 and expiration <= os.time() then - return nil - end - - if url_decode(field) ~= expected_field then - return nil - end - - local decoded = url_decode(value) - -- Reject values carrying control characters so a signed value cannot - -- smuggle CR or LF into a response header. - if decoded:find("%c") then - return nil - end - return decoded -end - -function secure_value(field, value, expiration) - if value == nil or value:len() <= 0 then - return "" - end - - local salt = tohex(rand.bytes(16)) - value = url_encode(value) - field = url_encode(field) - local authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt - authstr = authstr .. "|" .. tohex(hmac.new(get_secret(), "sha256"):final(authstr)) - return authstr -end - --- Strip control characters that could split an HTTP response header. -function strip_ctl(s) - return (string.gsub(s or "", "%c", "")) -end - --- Compare two strings in time that does not depend on how many leading bytes --- match, so a mismatch position is not revealed by timing. -function constant_equals(a, b) - if type(a) ~= "string" or type(b) ~= "string" or #a ~= #b then - return false - end - local diff = 0 - for i = 1, #a do - local d = a:byte(i) - b:byte(i) - diff = diff + d * d - end - return diff == 0 -end - -function set_cookie(cookie, value) - local attrs = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path - if not cookie_insecure then - attrs = attrs .. "; Secure" - end - if value == "" then - attrs = attrs .. "; Max-Age=0" - elseif session_seconds > 0 then - attrs = attrs .. "; Max-Age=" .. tostring(session_seconds) - end - html("Set-Cookie: " .. cookie .. "=" .. strip_ctl(value) .. attrs .. "\n") -end - -function redirect_to(url) - html("Status: 302 Redirect\n") - html("Cache-Control: no-cache, no-store\n") - html("Location: " .. strip_ctl(url) .. "\n") -end - -function not_found() - html("Status: 404 Not Found\n") - html("Cache-Control: no-cache, no-store\n\n") -end - --- --- --- Authentication actions. Identical to auth-inline.lua from here down. --- --- - --- Sets HTTP cookie headers based on post and sets up redirection. -function authenticate_post() - local redirect = validate_value("redirect", post["redirect"]) - - if redirect == nil or not is_safe_redirect(redirect) then - not_found() - return 0 - end - - redirect_to(redirect) - - local username = post["username"] - local password = post["password"] - local ok = false - if username ~= nil and password ~= nil then - local hash = account_hash(username) - if hash == nil then - -- Spend the work anyway, see dummy_hash. - unistd.crypt(password, dummy_hash) - elseif constant_equals(hash, unistd.crypt(password, hash)) then - ok = true - end - end - - if ok then - set_cookie(cookie_name, secure_value("username", username, os.time() + session_seconds)) - else - set_cookie(cookie_name, "") - end - - html("\n") - return 0 -end - --- Returns 1 if the cookie is valid and 0 if it is not. -function authenticate_cookie() - local accepted_users = repo_userset(cgit["repo"]) - if accepted_users == nil then - -- The repository is not protected. - return 1 - end - - local username = validate_value("username", get_cookie(http["cookie"], cookie_name)) - if username == nil or not accepted_users[username:lower()] then - return 0 - end - return 1 -end - --- Prints the html for the login form. -function body() - local target = cgit["url"] - if not is_safe_redirect(target) then - target = cgit["login"] - end - - html("<h2>Authentication Required</h2>") - html("<form method='post' action='") - html_attr(cgit["login"]) - html("'>") - html("<input type='hidden' name='redirect' value='") - html_attr(secure_value("redirect", target, 0)) - html("' />") - html("<table>") - html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>") - html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>") - html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>") - html("</table></form>") - - return 0 -end - --- --- --- Wrapper around the filter API, exposing the http, cgit and post tables to --- the functions above. --- --- - -local actions = {} -actions["authenticate-post"] = authenticate_post -actions["authenticate-cookie"] = authenticate_cookie -actions["body"] = body - -function filter_open(...) - action = actions[select(1, ...)] - - post = {} - - http = {} - http["cookie"] = select(2, ...) - http["method"] = select(3, ...) - http["query"] = select(4, ...) - http["referer"] = select(5, ...) - http["path"] = select(6, ...) - http["host"] = select(7, ...) - http["https"] = select(8, ...) - - cgit = {} - cgit["repo"] = select(9, ...) - cgit["page"] = select(10, ...) - cgit["url"] = select(11, ...) - cgit["login"] = select(12, ...) -end - -function filter_close() - if action == nil then - -- Unknown action, deny rather than raise. - return 0 - end - return action() -end - -function filter_write(str) - post = parse_qs(str) -end |
