-- A cgit auth filter that puts chosen repositories behind a login form and a -- signed session cookie. cgit consults it on every request once cgitrc names -- it with auth-filter=lua:/path/to/auth-file.lua, and it answers the -- authenticate-cookie, authenticate-post and body actions the filter API -- defines. This variant keeps the accounts, the groups and the per-repository -- access lists in files on disk, which suits a user set that is large or -- maintained by something else. The companion auth-inline.lua behaves -- identically but carries the same lists inside the script. -- The script runs on Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT, and the runtime has to -- be the same Lua that cgit was built against. Lua 5.5 will not do, because -- luaossl has no 5.5 build. -- -- Serve cgit over HTTPS and terminate TLS in the web server in front of it. -- The session cookie is marked Secure by default, so a browser only sends it -- back over HTTPS, and on an instance served over plain HTTP with no TLS -- anywhere the cookie never comes back and login appears to loop until -- cookie_insecure below is set. -- -- Two libraries are needed. luaossl provides openssl.rand and openssl.hmac and -- lives at , and luaposix provides -- posix.sys.stat and posix.unistd and lives at -- . The reliable cross-platform way to -- install them is LuaRocks matched to your Lua version, and 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 package both as well, for example lua-luaossl and -- lua-posix on Debian, and such a package has to be built for the same Lua -- version as cgit. -- -- The cookie carries only a user name and there is 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, so 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") -- The values that follow are the configuration and are meant to be edited. -- Nothing below them needs changing for ordinary use. -- Accounts live 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 lives one per line as groupname:user1,user2,user3 and so -- on. local groups_filename = "/etc/cgit-auth/groups" -- Per-repository access lives one per line as reponame:group1,group2 and so -- on. A repository named here is protected and one that is not named is -- public. The repository name has to match exactly, while group and user names -- match whatever their case. local repos_filename = "/etc/cgit-auth/repos" -- Where the cookie-signing secret is stored, created on first use. It must be -- persistent and writable by cgit, and it is worth keeping 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/lib/cgit/auth-secret" -- How long a login stays valid, in seconds. The default is one week. local session_seconds = 7 * 24 * 60 * 60 local cookie_name = "cgitauth" -- "/" covers the whole host. Set this to the cgit root to scope the cookie -- more tightly. local cookie_path = "/" -- Leave this 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. 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 user name exists. local dummy_hash = "$6$rounds=300000$0000000000000000$" -- One request reaches this script as an open, a write and a close, so what the -- open decodes is kept here for the calls that follow. local action, http, cgit, post -- The two lookups below, account_hash and repo_userset, are the only part of -- this script that differs from auth-inline.lua. Replacing them is all it -- takes to keep accounts somewhere else. local function trim(s) return (string.gsub(s, "^%s*(.-)%s*$", "%1")) end local function add_names(list, set) for name in string.gmatch(list, "([^,]+)") do set[trim(name):lower()] = true end end -- A missing or unreadable users file is not fatal, and neither is a line that -- does not parse, so a broken file turns every login down rather than failing -- the request outright. -- -- The hash is trimmed as well as the name because reading by line strips the -- newline but not a carriage return, so a users file saved with CRLF endings -- would otherwise hand crypt a hash with a trailing \r and fail every login -- with nothing in the log to say why. function account_hash(user) if user == nil then return nil end local wanted = user:lower() local users_file = io.open(users_filename, "r") if users_file == nil then return nil end for line in users_file:lines() do local name, hash = string.match(line, "(.-):(.+)") if name ~= nil and trim(name):lower() == wanted then users_file:close() return trim(hash) end end users_file:close() return nil end -- The users allowed into a repository come back keyed by lowercased name, and -- a repository the repos file does not name comes back as nil. A protected -- repository whose groups resolve to nobody comes back as an empty table -- instead, so it denies everyone rather than falling through to public. function repo_userset(repo) if repo == nil then return nil end local groups = nil local repos_file = io.open(repos_filename, "r") if repos_file ~= nil then for line in repos_file:lines() do local name, list = string.match(line, "(.-):(.+)") if name ~= nil and trim(name) == repo then groups = {} add_names(list, groups) break end end repos_file:close() end if groups == nil then return nil end local users = {} local groups_file = io.open(groups_filename, "r") if groups_file ~= nil then for line in groups_file:lines() do local name, list = string.match(line, "(.-):(.+)") if name ~= nil and groups[trim(name):lower()] then add_names(list, users) end end groups_file:close() end return users end -- The URL helpers below are adapted from keplerproject/wsapi. function url_decode(str) if not str then return "" end str = string.gsub(str, "+", " ") str = string.gsub(str, "%%(%x%x)", function(hex) return string.char(tonumber(hex, 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(char) return string.format("%%%02X", string.byte(char)) 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_query(query) local params = {} for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do params[url_decode(key)] = url_decode(value) end return params end local function pattern_escape(s) return (string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")) end -- The stored token was already URL encoded by secure_value, so it comes back -- verbatim and the write path in set_cookie stays symmetric with this read -- path. Decoding it here would break the signature check for any value -- carrying a percent escape. -- -- The name is escaped because it lands in a pattern. A cookie_name holding a -- magic character, say "cgit-auth", would otherwise read as a pattern and stop -- matching its own cookie while matching names nobody configured. function get_cookie(cookies, name) cookies = string.gsub(";" .. (cookies or "") .. ";", "%s*;%s*", ";") return string.match(cookies, ";" .. pattern_escape(name) .. "=(.-);") end function tohex(bytes) local hex = "" for i = 1, #bytes do hex = hex .. string.format("%.2x", string.byte(bytes, i)) end return hex end 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 -- The secret is written under a tightened mask so it is not -- created readable by anyone but the user cgit runs as, and -- the old mask goes back on every way out. 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 -- The link is meant to fail when another worker won the race, -- which leaves that worker's secret in place rather than -- replacing it and invalidating the sessions it just signed. unistd.link(temporary_filename, secret_filename) 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 signature = "" 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, since -- tonumber and tostring of "1e9" or "100.0" differ -- across Lua versions and the signed bytes have to -- come back byte for byte. if not string.match(component, "^%d+$") then return nil end expiration = tonumber(component) elseif i == 3 then salt = component elseif i == 4 then signature = component else break end i = i + 1 end if signature == nil or signature:len() == 0 then return nil end local payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt local expected_signature = tohex(hmac.new(get_secret(), "sha256"):final(payload)) if not constant_equals(signature, expected_signature) 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 -- The layout built here is what validate_value takes apart again, so the two -- have to move together. 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 payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt local signature = tohex(hmac.new(get_secret(), "sha256"):final(payload)) return payload .. "|" .. signature end -- A control character in a header value would let that value split the -- response, so it is dropped rather than escaped. function strip_controls(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 delta = a:byte(i) - b:byte(i) diff = diff + delta * delta end return diff == 0 end -- An empty value is how a login is cleared, since Max-Age=0 tells the browser -- to drop the cookie it already holds. function set_cookie(cookie, value) local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path if not cookie_insecure then attributes = attributes .. "; Secure" end if value == "" then attributes = attributes .. "; Max-Age=0" elseif session_seconds > 0 then attributes = attributes .. "; Max-Age=" .. tostring(session_seconds) end html("Set-Cookie: " .. cookie .. "=" .. strip_controls(value) .. attributes .. "\n") end function redirect_to(url) html("Status: 302 Redirect\n") html("Cache-Control: no-cache, no-store\n") html("Location: " .. strip_controls(url) .. "\n") end function not_found() html("Status: 404 Not Found\n") html("Cache-Control: no-cache, no-store\n\n") end -- The three actions cgit can ask for follow, and they are the same in both -- variants, so a change to one belongs in the other. -- The redirect goes out before the password is checked, so a wrong password -- and a right one answer with the same status and location and differ only in -- the cookie. 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 -- cgit reads the answer, where 1 lets the request through and 0 sends it to -- the login form. function authenticate_cookie() local accepted_users = repo_userset(cgit["repo"]) if accepted_users == nil then -- A repository nothing lists is public. 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 -- cgit calls this to fill the page body once the cookie has been turned down. -- The form carries a signed redirect token, so the browser lands back on the -- page that was asked for. function body() local target = cgit["url"] if not is_safe_redirect(target) then target = cgit["login"] end html("

Authentication Required

") html("
") html("") html("") html("") html("") html("") html("
") return 0 end -- cgit calls filter_open with the action name followed by the request fields -- in a fixed order, so they are unpacked here into the tables the functions -- above read. Only a post reaches filter_write, carrying the form body, and -- filter_close is where the action finally runs and answers cgit. 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 -- An unknown action denies rather than raising. return 0 end return action() end function filter_write(str) post = parse_query(str) end