diff options
context:
space:
mode:
Diffstat (limited to 'extensions/auth-file.lua')
-rw-r--r--extensions/auth-file.lua556
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