From eb414248680936e61fee2d97905ef2c609e8c318 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Fri, 14 Aug 2026 12:18:33 -1000 Subject: Restyle the filter extension headers and markup --- custom/extensions/auth-inline.lua | 296 ++++++++++++++++++++------------------ 1 file changed, 158 insertions(+), 138 deletions(-) (limited to 'custom/extensions/auth-inline.lua') diff --git a/custom/extensions/auth-inline.lua b/custom/extensions/auth-inline.lua index b50b59a..500d705 100644 --- a/custom/extensions/auth-inline.lua +++ b/custom/extensions/auth-inline.lua @@ -1,40 +1,28 @@ --- cgit auth-filter that gates repositories behind a login form and a signed --- session cookie. +-- 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, 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. -- --- This is the INLINE variant. The user accounts and the per-repository access --- lists are written directly in this script, in the two tables among the --- configuration values below. Edit them here and reload. This suits a small --- fixed set of users that rarely changes. +-- 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. -- --- The companion auth-file.lua behaves identically but reads its accounts and --- access lists from files on disk instead, which suits larger or externally --- managed user sets. --- --- Enable it in cgitrc with --- auth-filter=lua:/path/to/auth-inline.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 --- --- luaposix POSIX binding, provides posix.sys.stat and posix.unistd --- --- --- The reliable cross-platform install is LuaRocks, matched to your Lua --- version. luaossl also needs the OpenSSL development headers present. +-- 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 @@ -54,95 +42,94 @@ -- 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. +-- 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. -- --- 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. +-- 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") --- Configuration, edit these values. Nothing below them needs changing for --- ordinary use. +-- The values that follow are the configuration and are meant to be edited. +-- Nothing below them needs changing for ordinary use. --- Protected repositories and the users allowed into each. A repository listed --- here is protected. One not listed is public. Keys and user names are matched --- case-insensitively. REPLACE THE EXAMPLES BELOW, they are commented out so an --- unedited copy protects nothing and grants no accounts. +-- 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 whatever their case. Replace +-- the examples below with your own. They are commented out, so an unedited +-- copy protects nothing and grants no accounts. local protected_repos = { -- ["secret-repo"] = { alice = true, bob = true }, -- ["another"] = { alice = true }, } --- Accounts as name = hash. Generate a hash with +-- Accounts as name and hash. Generate a hash with -- mkpasswd -m sha-512 -R 300000 --- REPLACE THE EXAMPLES BELOW. The commented lines are not real credentials and --- must not be deployed as-is. +-- Replace the examples below. They are not real credentials and must not be +-- deployed as they stand. 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. It is created on first use. It --- must be persistent and writable by cgit. Prefer a path OUTSIDE the cache +-- 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. Default one week. +-- How long a login stays valid, in seconds. The default is 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. +-- "/" covers the whole host. Set this 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. +-- 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 username exists. +-- by timing whether the user name exists. local dummy_hash = "$6$rounds=300000$0000000000000000$" --- Module state shared across the open, write and close calls of one request. +-- 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 --- Account and access-list storage. This is the ONLY part that differs from --- auth-file.lua. Swap these two functions to change where accounts live. +-- The two lookups below, account_hash and repo_userset, are the only part of +-- this script that differs from auth-file.lua. Replacing them is all it takes +-- to keep accounts somewhere else. --- Fold the configured tables to lowercased user names once, so lookups match --- case-insensitively the same way auth-file.lua does. Repository names keep --- their case. +-- 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 = {} + local folded_users = {} for name, hash in pairs(users) do - folded[tostring(name):lower()] = hash + folded_users[tostring(name):lower()] = hash end - users = folded - for repo, set in pairs(protected_repos) do - local fs = {} - for name, allowed in pairs(set) do - fs[tostring(name):lower()] = allowed + 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] = fs + protected_repos[repo] = folded end end --- Return the stored password hash for a user, or nil. function account_hash(user) if user == nil then return nil @@ -150,8 +137,8 @@ function account_hash(user) return users[user:lower()] end --- Return the set of users allowed to access a repository, keyed by lowercased --- user name, or nil if the repository is not protected. +-- 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) if repo == nil then return nil @@ -159,14 +146,16 @@ function repo_userset(repo) return protected_repos[repo] end --- Utility functions based on keplerproject/wsapi. +-- 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(h) return string.char(tonumber(h, 16)) end) + 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 @@ -176,30 +165,31 @@ function url_encode(str) 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, "([^%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_qs(qs) - local tab = {} - for key, val in string.gmatch(qs or "", "([^&=]+)=([^&]*)") do - tab[url_decode(key)] = url_decode(val) +function parse_query(query) + local params = {} + for key, value in string.gmatch(query or "", "([^&=]+)=([^&]*)") do + params[url_decode(key)] = url_decode(value) end - return tab + return params end --- Escape the Lua pattern magic characters, so a name is matched literally. local function pattern_escape(s) return (string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")) 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. +-- 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 @@ -209,16 +199,14 @@ function get_cookie(cookies, name) return string.match(cookies, ";" .. pattern_escape(name) .. "=(.-);") end -function tohex(b) - local x = "" - for i = 1, #b do - x = x .. string.format("%.2x", string.byte(b, i)) +function tohex(bytes) + local hex = "" + for i = 1, #bytes do + hex = hex .. string.format("%.2x", string.byte(bytes, i)) end - return x + return hex end --- Cookie construction and validation helpers. - local secret = nil -- Load the cookie-signing secret, creating it on first use. Failures raise, @@ -230,21 +218,30 @@ function get_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_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) + 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) + error("cgit auth: failed writing secret file " .. + secret_filename) end - unistd.link(temporary_filename, secret_filename) -- Intentionally fails if another worker won the race. + -- 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") @@ -256,7 +253,8 @@ function get_secret() 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") + error("cgit auth: secret file " .. secret_filename .. + " is malformed, expected 64 hex characters") end return secret end @@ -282,7 +280,7 @@ function validate_value(expected_field, cookie) local field = "" local expiration = 0 local salt = "" - local chmac = "" + local signature = "" if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then return nil @@ -294,9 +292,10 @@ function validate_value(expected_field, cookie) 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. + -- 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 @@ -304,19 +303,22 @@ function validate_value(expected_field, cookie) elseif i == 3 then salt = component elseif i == 4 then - chmac = component + signature = component else break end i = i + 1 end - if chmac == nil or chmac:len() == 0 then + if signature == nil or signature: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 + 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 @@ -338,6 +340,8 @@ function validate_value(expected_field, cookie) 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 "" @@ -346,13 +350,15 @@ function secure_value(field, value, expiration) 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 + local payload = field .. "|" .. value .. "|" .. + tostring(expiration) .. "|" .. salt + local signature = tohex(hmac.new(get_secret(), "sha256"):final(payload)) + return payload .. "|" .. signature end --- Strip control characters that could split an HTTP response header. -function strip_ctl(s) +-- 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 @@ -364,29 +370,33 @@ function constant_equals(a, b) end local diff = 0 for i = 1, #a do - local d = a:byte(i) - b:byte(i) - diff = diff + d * d + 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 attrs = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path + local attributes = "; HttpOnly; SameSite=Lax; Path=" .. cookie_path if not cookie_insecure then - attrs = attrs .. "; Secure" + attributes = attributes .. "; Secure" end if value == "" then - attrs = attrs .. "; Max-Age=0" + attributes = attributes .. "; Max-Age=0" elseif session_seconds > 0 then - attrs = attrs .. "; Max-Age=" .. tostring(session_seconds) + attributes = attributes .. + "; Max-Age=" .. tostring(session_seconds) end - html("Set-Cookie: " .. cookie .. "=" .. strip_ctl(value) .. attrs .. "\n") + 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_ctl(url) .. "\n") + html("Location: " .. strip_controls(url) .. "\n") end function not_found() @@ -394,9 +404,12 @@ function not_found() html("Cache-Control: no-cache, no-store\n\n") end --- Authentication actions. Identical to auth-inline.lua from here down. +-- 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. --- Sets HTTP cookie headers based on post and sets up redirection. +-- 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"]) @@ -421,7 +434,8 @@ function authenticate_post() end if ok then - set_cookie(cookie_name, secure_value("username", username, os.time() + session_seconds)) + set_cookie(cookie_name, secure_value("username", username, + os.time() + session_seconds)) else set_cookie(cookie_name, "") end @@ -430,22 +444,26 @@ function authenticate_post() return 0 end --- Returns 1 if the cookie is valid and 0 if it is not. +-- 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 - -- The repository is not protected. + -- A repository nothing lists is public. return 1 end - local username = validate_value("username", get_cookie(http["cookie"], cookie_name)) + 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. +-- 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 @@ -468,8 +486,10 @@ function body() return 0 end --- Wrapper around the filter API, exposing the http, cgit and post tables to --- the functions above. +-- 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 @@ -499,12 +519,12 @@ end function filter_close() if action == nil then - -- Unknown action, deny rather than raise. + -- An unknown action denies rather than raising. return 0 end return action() end function filter_write(str) - post = parse_qs(str) + post = parse_query(str) end -- cgit v2.8.0