-- 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. -- -- The session cookie is marked Secure by default, so a browser only sends -- it back over HTTPS. On an instance served over plain HTTP login loops -- 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. Each is loaded only once a request -- needs it, so a public repository is served without them while a protected -- one is refused. -- -- # 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 -- -- A session is signed together with its account's hash, so changing the -- password or deleting the account ends it. There is no server-side session -- store and no CSRF token, so instances sharing a secret file accept each -- other's cookies, and only a new secret file ends every session at once. -- That is acceptable for gating read access to a git browser, so weigh it -- before guarding anything more. -- The values that follow are the configuration and are meant to be edited. -- Accounts live one per line as username:hash, where the hash is any kind this -- system's crypt(3) checks. yescrypt ($y$) is what libxcrypt recommends, as on -- Debian, Ubuntu and Fedora, though musl, as on Alpine, does not check it. -- SHA-512 ($6$) works on Linux and FreeBSD given enough rounds, bcrypt ($2b$) -- is the only kind OpenBSD checks, and macOS checks none of them. Make one -- with whichever of these the system has. -- -- # Debian and Ubuntu, from the whois package -- mkpasswd -m yescrypt -- # anywhere with OpenSSL, as SHA-512 -- openssl passwd -6 -salt "rounds=300000\$$(openssl rand -hex 8)" -- # OpenBSD, as bcrypt -- encrypt -b a -p -- -- 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 regardless of case. While this file cannot be read every repository -- is refused, since nothing then tells the protected ones apart. 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 = "/" -- Set this true only if cgit is served over plain HTTP with no TLS anywhere. local cookie_insecure = false -- crypt feeds the password into most of its rounds, so its work grows with -- the password's length, and one longer than this is turned down unchecked. local max_password_bytes = 256 -- A browser accepts a cookie named __Host- only when it is Secure, covers the -- whole host and was set by this host, so no sibling subdomain can plant a -- session in its place. Either cookie setting above can rule that out. local session_cookie = cookie_name if not cookie_insecure and cookie_path == "/" then session_cookie = "__Host-" .. cookie_name end -- 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. -- Lua patterns backtrack, so "^%s*(.-)%s*$" costs the square of a run of -- spaces inside the text. Finding each end directly stays linear, which -- matters for the Cookie header a visitor writes. local function trim(text) local first = text:find("%S") if first == nil then return "" end local last = #text while text:find("^%s", last) do last = last - 1 end return text:sub(first, last) end local function add_names(list, set) for name in string.gmatch(list, "([^,]+)") do set[trim(name):lower()] = true end end -- A missing, unreadable or unparsable users 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 CRLF users file would hand -- crypt a hash with a trailing \r and fail every login. -- -- A name with no account comes back as nil and another account's hash, so -- a failed login can spend the same work on a hash of the same kind and -- cost, and its timing does not tell whether the name exists. 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 local stand_in = nil for line in users_file:lines() do local name, hash = string.match(line, "(.-):(.+)") if name ~= nil then if trim(name):lower() == wanted then users_file:close() return trim(hash) end stand_in = stand_in or trim(hash) end end users_file:close() return nil, stand_in 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, err = io.open(repos_filename, "r") if repos_file == nil then io.stderr:write("[cgit] Unable to read the auth repos file: " .. err .. "\n") return {} end 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() 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 -- The token comes back still URL encoded by secure_value. Decoding it here -- would break the signature check for any value carrying a percent escape. function get_cookie(cookies, name) for raw in string.gmatch(cookies or "", "[^;]+") do local pair = trim(raw) local equals = pair:find("=", 1, true) if equals and pair:sub(1, equals - 1) == name then return pair:sub(equals + 1) end end return nil 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 local sysstat = require("posix.sys.stat") local unistd = require("posix.unistd") local rand = require("openssl.rand") -- 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 -- The signature covers the payload and a binding that the cookie never -- carries, so a value only verifies while its binding stays the same. local function sign(payload, binding) local hmac = require("openssl.hmac") local signed = payload .. "|" .. (binding or "") return tohex(hmac.new(get_secret(), "sha256"):final(signed)) end -- Return the value carried by a signed cookie, or nil if it does not verify. -- bind, when given, maps the value to the binding its signature has to cover, -- or to nil to turn it down, which is how a session meets its account's -- current hash. function validate_value(expected_field, cookie, bind) 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 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 local binding = "" if bind ~= nil then binding = bind(decoded) if binding == nil then return nil end end local payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt if not constant_equals(signature, sign(payload, binding)) 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 return decoded end -- The layout built here is what validate_value takes apart again, so the two -- have to move together. The binding, if any, is signed but left out. function secure_value(field, value, expiration, binding) if value == nil or value:len() <= 0 then return "" end local rand = require("openssl.rand") local salt = tohex(rand.bytes(16)) value = url_encode(value) field = url_encode(field) local payload = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt return payload .. "|" .. sign(payload, binding) 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 Found\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 session = "" if username ~= nil and password ~= nil and #password <= max_password_bytes then local unistd = require("posix.unistd") local hash, stand_in = account_hash(username) if hash == nil then -- Spend the work anyway, see account_hash. if stand_in ~= nil then unistd.crypt(password, stand_in) end elseif constant_equals(hash, unistd.crypt(password, hash)) then session = secure_value("username", username, os.time() + session_seconds, hash) end end -- A failed login answers with an empty session, which clears the cookie. set_cookie(session_cookie, session) 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 cookie = get_cookie(http["cookie"], session_cookie) local username = validate_value("username", cookie, account_hash) 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("
") html("
") html("
") html("
") return 0 end -- filter_open unpacks the action and request fields cgit passes in fixed -- order, filter_write collects a post body, and filter_close runs the action. 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