-- 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-inline.lua, and it answers the -- authenticate-cookie, authenticate-post and body actions the filter API -- defines. This variant carries the accounts and the per-repository access -- lists in two tables inside the script, which suits a small fixed set of -- users. The companion auth-file.lua behaves identically but reads the same -- lists from files on disk. -- The script runs on Lua 5.1 through 5.5 and LuaJIT, and the runtime has to be -- the same Lua that cgit was built against. On 5.5 it needs luaossl 20260910 -- or later, since earlier builds fail to load there. -- -- 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 filter guards a repository's pages, not its place on the index, which -- still lists a protected repository with its description unless cgitrc sets -- repo.hide=1 for it. -- Protected repositories and the users allowed into each. A repository named -- here is protected and one that is not named is public. The repository key -- has to match exactly, while user names match regardless of case. The -- examples are commented out, so an unedited copy protects nothing. local protected_repos = { -- ["secret-repo"] = { alice = true, bob = true }, -- ["another"] = { alice = true }, } -- Accounts as name and 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 local users = { -- alice = "$6$rounds=300000$REPLACE_THIS_SALT$REPLACE_THIS_HASH", -- bob = "$6$rounds=300000$REPLACE_THIS_SALT$REPLACE_THIS_HASH", } -- 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 -- A cookie carries its expiry and Max-Age as whole seconds, while on Lua 5.3 -- and later a length worked out with a fraction, such as 1.5 days, is a float -- that tostring would write with a trailing ".0". session_seconds = math.floor(session_seconds) -- 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 -- Apart from the configuration, the lookups below, account_hash and -- repo_userset with the fold that feeds them, are the only code that -- differs from auth-file.lua, which defines trim among its lookups. -- The configured tables are folded to lowercased user names once, so that a -- lookup matches whatever case the login form was filled in with, the way -- auth-file.lua does. Repository names keep their case. do local folded_users = {} for name, hash in pairs(users) do folded_users[tostring(name):lower()] = hash end users = folded_users for repo, members in pairs(protected_repos) do local folded = {} for name, allowed in pairs(members) do folded[tostring(name):lower()] = allowed end protected_repos[repo] = folded end end -- 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) local hash = users[user:lower()] if hash ~= nil then return hash end local _, stand_in = next(users) return nil, stand_in end -- The users allowed into a repository come back keyed by lowercased name, and -- a repository the table does not name comes back as nil. function repo_userset(repo) return protected_repos[repo] end -- The url helpers below are adapted from keplerproject/wsapi. function url_decode(str) str = string.gsub(str, "+", " ") str = string.gsub(str, "%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) return str end function url_encode(str) 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, "([^&=]+)=([^&]*)") do params[url_decode(key)] = url_decode(value) end return params end -- 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 -- 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 field, value, expiration, salt, signature = string.match(cookie or "", "^([^|]+)|([^|]+)|(%d+)|([^|]+)|([^|]+)$") if signature == nil or 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 .. "|" .. 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. expiration = tonumber(expiration) 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 -- 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. Any other value is a token secure_value -- built, which holds no control character to split the response with. 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" else attributes = attributes .. "; Max-Age=" .. tostring(session_seconds) end html("Set-Cookie: " .. cookie .. "=" .. value .. attributes .. "\n") end -- A control character in the location would let it split the response, so -- it is dropped rather than escaped. function redirect_to(url) html("Status: 302 Found\n") html("Cache-Control: no-cache, no-store\n") html("Location: " .. url:gsub("%c", "") .. "\n") end -- A failed login lands back on the page it came from with this flag, which -- the form reads to say so and leaves out of the way back it signs. local failed_flag = "login=failed" local function flag_failure(url) return url .. (url:find("?", 1, true) and "&" or "?") .. failed_flag end local function drop_failure_flag(url) return (url:gsub("[?&]" .. failed_flag .. "$", "")) 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. -- A wrong password and an unknown user answer alike, so the flag on the way -- back says only that the login failed. function authenticate_post() local redirect = validate_value("redirect", post["redirect"]) if not is_safe_redirect(redirect) then not_found() return 0 end 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. redirect_to(session == "" and flag_failure(redirect) or redirect) 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 = drop_failure_flag(cgit["url"]) local failed = target ~= cgit["url"] if not is_safe_redirect(target) then target = cgit["login"] end html("

Authentication Required

") if failed then html("") end html("") return 0 end local actions = {} actions["authenticate-post"] = authenticate_post actions["authenticate-cookie"] = authenticate_cookie actions["body"] = body function filter_open(name, cookie, method, query, referer, path, host, https, repo, page, url, login) action = actions[name] post = {} http = { cookie = cookie } cgit = { repo = repo, url = url, login = login } 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