-- Unit checks for the two auth filters, run under a standalone Lua by -- t0505-auth.sh with the script path as the first argument, the variant, -- inline or file, as the second and a directory of the run's own as the -- third, which takes the fixtures and the signing secret. The scripts need -- luaossl and luaposix, which the harness meets with deterministic -- stand-ins, so the checks are about the script's own logic, the cookie -- layout, the expiry and field rules, the redirect vetting and the action -- flows, rather than about OpenSSL. Given a fourth argument, a hash the -- system's crypt(3) makes of the password below, the same checks run -- against the real libraries instead, which is where a misuse of their -- interfaces would show. The accounts and access lists are filled through -- each variant's own channel, its commented-out examples switched on for -- the inline script and fixture files reached through a redirected io.open -- for the file one. Both variants carry the same data so the flow checks -- read identically for the two. local test_directory = arg[0]:match("^(.*)/") or "." local h = dofile(test_directory .. "/harness.lua") local script = arg[1] local variant = arg[2] local directory = assert(arg[3], "test-auth.lua needs a directory for its fixtures") local real_hash = arg[4] if real_hash == nil then h.stub_auth_modules() end -- The password behind every test account. With the stand-ins it is stored -- as what their crypt returns for it, so a login with it verifies and any -- other fails. local password = "open sesame" local stored_hash = real_hash or "$6$rounds=300000$testsalt$" .. password -- carol never logs in, and her hash only has to differ from Alice's, so a -- session bound to one account's hash cannot pass for another's. local carol_hash = "$6$rounds=5000$carolsalt$carols-password" if variant == "file" then local users_file = io.open(directory .. "/auth-users", "w") users_file:write("Alice:" .. stored_hash .. "\r\n") users_file:write("not a parsable line\n") users_file:write("carol:" .. carol_hash .. "\n") users_file:close() local groups_file = io.open(directory .. "/auth-groups", "w") groups_file:write("DEVS:Alice, bob\n") groups_file:write("others:carol, alice\n") groups_file:close() local repos_file = io.open(directory .. "/auth-repos", "w") repos_file:write("secret-repo:Devs\n") repos_file:write("both-repo:devs,others\n") repos_file:write("empty-repo:ghosts\n") repos_file:write("bare-repo:\n") repos_file:close() h.redirect_file("/etc/cgit-auth/users", directory .. "/auth-users") h.redirect_file("/etc/cgit-auth/groups", directory .. "/auth-groups") h.redirect_file("/etc/cgit-auth/repos", directory .. "/auth-repos") end -- The inline script carries its lists in the code, so its commented-out -- examples are switched on with the same data the fixture files hold. local inline_config = { { "local protected_repos = {\n", "local protected_repos = {\n" .. "\t[\"secret-repo\"] = { alice = true, Bob = true },\n" .. "\t[\"both-repo\"] = { alice = true, bob = true, carol = true },\n" .. "\t[\"empty-repo\"] = {},\n" }, { "local users = {\n", "local users = {\n" .. "\tAlice = \"" .. stored_hash .. "\",\n" .. "\tcarol = \"" .. carol_hash .. "\",\n" }, } -- Every load keeps its signing secret in the run's directory, at the path -- given or else at one shared by all of them. local function load_script(changes, secret_path) local all = { { "local secret_filename = \"/var/lib/cgit/auth-secret\"", "local secret_filename = " .. string.format("%q", secret_path or directory .. "/auth-secret") }, } if variant == "inline" then for _, change in ipairs(inline_config) do all[#all + 1] = change end end for _, change in ipairs(changes or {}) do all[#all + 1] = change end h.load_with(script, all) end load_script() -- Served over https at the root, the session cookie takes the __Host- name. local session_name = "__Host-cgitauth" -- crypt is watched through the module the script requires, so a check can -- see whether a login spent it and on which hash. With crypt_unchecked set -- it answers nil, as crypt(3) does for a kind of hash the system does not -- check. local unistd = require("posix.unistd") local real_crypt = unistd.crypt local crypt_settings = {} local crypt_unchecked = false unistd.crypt = function(trypass, setting) crypt_settings[#crypt_settings + 1] = setting if crypt_unchecked then return nil end return real_crypt(trypass, setting) end local function read_file(path) local file = io.open(path, "rb") if file == nil then return nil end local text = file:read("*a") file:close() return text end local function write_file(path, text) local file = assert(io.open(path, "wb")) file:write(text) file:close() end -- The secret is made on first use, so the masks it is made under and the -- temporary files it is linked from are watched the same way. A secret set -- in race_winner is written just ahead of the link, the way another request -- making the file at the same moment would. local sysstat = require("posix.sys.stat") local real_umask = sysstat.umask local umasks = {} sysstat.umask = function(mask) local old = real_umask(mask) umasks[#umasks + 1] = { mask = mask, old = old } return old end local real_link = unistd.link local linked = {} local race_winner = nil unistd.link = function(from, to) linked[#linked + 1] = from if race_winner then write_file(to, race_winner) end return real_link(from, to) end local _, made = pcall(get_secret) h.check("a new secret is 64 hex characters", type(made) == "string" and #made == 64 and not made:find("%X"), tostring(made)) h.equals("and is what its file holds", read_file(directory .. "/auth-secret"), made) if real_hash then local stat = sysstat.stat(directory .. "/auth-secret") h.equals("which only its owner can read", stat and stat.st_mode % 512, 384) end h.equals("it is written under a mask that keeps everyone else out", umasks[1] and umasks[1].mask, 63) h.check("and the mask before it goes back after", #umasks == 2 and umasks[2].mask == umasks[1].old) h.equals("it reaches its name through one link", #linked, 1) h.equals("and the temporary file it was linked from is removed", linked[1] and read_file(linked[1]), nil) load_script() h.equals("a later load takes the same secret from the file", select(2, pcall(get_secret)), made) h.equals("without making another", #linked, 1) -- The request that made the secret first keeps it, since it may have -- signed sessions with it already. race_winner = string.rep("9", 64) load_script(nil, directory .. "/raced-secret") h.equals("a secret made meanwhile by another request wins", select(2, pcall(get_secret)), race_winner) h.equals("and is left as it was", read_file(directory .. "/raced-secret"), race_winner) h.equals("while the temporary file is still removed", read_file(linked[#linked]), nil) race_winner = nil -- A secret made by hand is read as its first line, so one saved with a -- newline still serves, while one of the wrong length is refused. write_file(directory .. "/typed-secret", made .. "\n") load_script(nil, directory .. "/typed-secret") h.equals("a secret ending in a newline is read without it", select(2, pcall(get_secret)), made) write_file(directory .. "/short-secret", "0123456789\n") load_script(nil, directory .. "/short-secret") local short_ok, short_err = pcall(get_secret) h.check("a secret of the wrong length is refused", not short_ok) h.contains("as malformed", tostring(short_err), "is malformed, expected 64 hex characters") load_script() -- The lookups themselves, which are the part the two variants do not share. local hash = account_hash("ALICE") h.equals("an account is found whatever its case", hash, stored_hash) h.equals("an unknown account is not", account_hash("nobody"), nil) local _, stand_in = account_hash("nobody") h.check("it brings another account's hash to spend instead", stand_in == stored_hash or stand_in == carol_hash, tostring(stand_in)) local userset = repo_userset("secret-repo") h.check("a protected repository lists its users", userset ~= nil and userset.alice and userset.bob) h.equals("an unlisted repository is public", repo_userset("unlisted"), nil) h.equals("the repository name matches case exactly", repo_userset("Secret-Repo"), nil) local empty = repo_userset("empty-repo") h.check("a protected repository with no members denies as an empty set", empty ~= nil and next(empty) == nil) local both = repo_userset("both-repo") h.check("a repository of two groups lets in the members of each", both ~= nil and both.alice and both.bob and both.carol) if variant == "file" then local bare = repo_userset("bare-repo") h.check("a protected repository with no groups at all denies too", bare ~= nil and next(bare) == nil) end if variant == "file" then h.redirect_file("/etc/cgit-auth/users", directory .. "/auth-users-missing") h.equals("a missing users file turns every login down", account_hash("alice"), nil) h.redirect_file("/etc/cgit-auth/users", directory .. "/auth-users") -- Without the repos file nothing tells a protected repository from a -- public one, so every page is refused, the index too, which cgit asks -- about with an empty repository name. h.redirect_file("/etc/cgit-auth/repos", directory .. "/auth-repos-missing") h.reset() local refused = repo_userset("unlisted") h.check("a missing repos file refuses every repository", refused ~= nil and next(refused) == nil) h.contains("and says so in the error log", h.error_log(), "[cgit] Unable to read the auth repos file: ") local index = repo_userset("") h.check("and the index as well", index ~= nil and next(index) == nil) h.redirect_file("/etc/cgit-auth/repos", directory .. "/auth-repos") -- Without the groups file nobody is a member, which has to deny rather -- than make the repository public. h.redirect_file("/etc/cgit-auth/groups", directory .. "/auth-groups-missing") local memberless = repo_userset("secret-repo") h.check("a missing groups file leaves a protected repository with nobody let in", memberless ~= nil and next(memberless) == nil) h.redirect_file("/etc/cgit-auth/groups", directory .. "/auth-groups") end h.equals("decode handles escapes and plus", url_decode("%41+b"), "A b") h.equals("encode escapes what is not a word", url_encode("a b|c"), "a+b%7Cc") local tricky = "x&=?|" h.equals("decode inverts encode", url_decode(url_encode(tricky)), tricky) local params = parse_query("u=a&p=b=c&x=%41") h.equals("a query splits on ampersands", params.u, "a") h.equals("a value keeps its own equals signs", params.p, "b=c") h.equals("a value is decoded", params.x, "A") h.equals("a cookie is found among others", get_cookie("foo=1; cgitauth=abc; bar=2", "cgitauth"), "abc") h.equals("a lone cookie is found", get_cookie("cgitauth=abc", "cgitauth"), "abc") h.equals("no header yields no cookie", get_cookie(nil, "cgitauth"), nil) h.equals("a longer name does not match", get_cookie("xcgitauth=z", "cgitauth"), nil) h.equals("a magic character in the name is taken literally", get_cookie("a-b=z", "a-b"), "z") h.equals("spaces around a pair are ignored", get_cookie(" foo=1 ; cgitauth=abc ", "cgitauth"), "abc") h.equals("a cookie value keeps its own equals signs", get_cookie("cgitauth=a=b", "cgitauth"), "a=b") -- The Cookie header is the visitor's to write, so a run of spaces in it -- must not cost the square of its length. At this length a linear trim -- takes about a millisecond and the square about fifteen seconds. local spaces = string.rep(" ", 100000) local started = os.clock() get_cookie("a=" .. spaces .. "b", "cgitauth") h.check("a long run of spaces inside a cookie is read within half a second", os.clock() - started < 0.5) started = os.clock() local found = get_cookie(spaces .. "cgitauth=abc", "cgitauth") h.check("and one ahead of it as well", os.clock() - started < 0.5) h.equals("without losing the cookie behind it", found, "abc") h.check("equal strings compare equal", constant_equals("abc", "abc")) h.check("differing strings do not", not constant_equals("abc", "abd")) h.check("nor do strings whose byte differences cancel out", not constant_equals("ab", "ba") and not constant_equals("ac", "bb")) h.check("differing lengths do not", not constant_equals("a", "ab")) h.check("non strings do not", not constant_equals(nil, "a")) h.check("a local path is a safe redirect", is_safe_redirect("/x")) h.check("and so is the root", is_safe_redirect("/")) h.check("a scheme relative target is not", not is_safe_redirect("//evil")) h.check("a backslash variant is not", not is_safe_redirect("/\\evil")) h.check("nor one of backslashes alone", not is_safe_redirect("\\\\evil")) h.check("nor an absolute url", not is_safe_redirect("https://evil.example/")) h.check("nor any other scheme", not is_safe_redirect("javascript:x")) h.check("nor a relative path", not is_safe_redirect("x/y")) h.check("nor an empty target", not is_safe_redirect("")) h.check("a missing target is not", not is_safe_redirect(nil)) local now = os.time() local cookie = secure_value("username", "alice", now + 3600) h.equals("a signed value verifies and comes back", validate_value("username", cookie), "alice") local signature = cookie:match("|(%x+)$") h.equals("its signature is a whole SHA-256 in hex", signature and #signature, 64) h.equals("each byte becomes two hex digits", tohex("\0\15\255"), "000fff") cookie = secure_value("username", "a|b", now + 3600) h.equals("a value holding the separator survives the round trip", validate_value("username", cookie), "a|b") cookie = secure_value("username", "a\nb", now + 3600) h.equals("a signed control character is still rejected on the way out", validate_value("username", cookie), nil) cookie = secure_value("redirect", "/repo/?a=b", 0) h.equals("an expiration of zero never expires", validate_value("redirect", cookie), "/repo/?a=b") cookie = secure_value("username", "alice", now - 10) h.equals("an expired value is rejected", validate_value("username", cookie), nil) -- The expiry second itself is already too late, which only a clock held -- still can show. local real_time = os.time os.time = function() return now end h.equals("a value is rejected at its expiry second", validate_value("username", secure_value("username", "alice", now)), nil) h.equals("and accepted the second before", validate_value("username", secure_value("username", "alice", now + 1)), "alice") os.time = real_time cookie = secure_value("username", "alice", now + 3600) local flipped = cookie:sub(1, -2) .. (cookie:sub(-1) == "0" and "1" or "0") h.equals("a tampered signature is rejected", validate_value("username", flipped), nil) h.equals("a value signed for one field does not serve another", validate_value("redirect", cookie), nil) -- The signature covers every field beside it, so none can be changed under -- it. A later expiry would let a captured session run for ever. local function with_field(token, index, replacement) local fields = {} for part in token:gmatch("[^|]+") do fields[#fields + 1] = part end fields[index] = replacement return table.concat(fields, "|") end h.equals("a cookie rebuilt unchanged still verifies", validate_value("username", with_field(cookie, 1, "username")), "alice") h.equals("a later expiry under the same signature does not", validate_value("username", with_field(cookie, 3, tostring(now + 7200))), nil) h.equals("nor does another salt", validate_value("username", with_field(cookie, 4, string.rep("0", 32))), nil) h.equals("nor another name", validate_value("username", with_field(cookie, 2, "mallory")), nil) local way_out = secure_value("redirect", "/repo/", 0) h.equals("nor a way back pointed at another page", validate_value("redirect", with_field(way_out, 2, url_encode("/other/"))), nil) h.equals("no cookie does not verify", validate_value("username", nil), nil) h.equals("a tiny cookie does not verify", validate_value("username", "ab"), nil) h.equals("a leading separator does not verify", validate_value("username", "|" .. cookie), nil) h.equals("a trailing field does not verify", validate_value("username", cookie .. "|x"), nil) h.equals("an unsigned cookie does not verify", validate_value("username", "username|alice|123|salt"), nil) h.equals("an exponent spelling of the expiry does not verify, even signed", validate_value("username", secure_value("username", "alice", "1e10")), nil) h.equals("an empty value signs to nothing", secure_value("username", "", 1), "") h.reset() set_cookie("cgitauth", "value") local out = h.output() h.contains("a session cookie carries the hardening attributes", out, "Set-Cookie: cgitauth=value; HttpOnly; SameSite=Lax; Path=/; Secure; " .. "Max-Age=604800\n") h.reset() set_cookie("cgitauth", "") h.contains("an empty value clears the cookie instead", h.output(), "Set-Cookie: cgitauth=; HttpOnly; SameSite=Lax; Path=/; Secure; " .. "Max-Age=0\n") h.reset() redirect_to("/x\r\nX-Evil: 1") out = h.output() h.contains("a newline cannot split the location header", out, "Location: /xX-Evil: 1\n") h.excludes("no carriage return slips through", out, "\r") -- The three actions, driven the way cgit drives them, an open carrying the -- request, a write only for the posted form and the answer read off the -- close. local function run_action(action, opts) opts = opts or {} return h.run({ action, opts.cookie or "", opts.method or "GET", "", "", "/", "example.org", opts.https or "on", opts.repo or "", "summary", opts.url or "/repo/", "/?p=login" }, { opts.body }) end -- A login posts the form, and a visit asks about a repository, with a -- session cookie when it is given one, and comes back with the answer. local function login(user, pass, redirect) local body = "username=" .. url_encode(user) .. "&password=" .. url_encode(pass) if redirect then body = body .. "&redirect=" .. url_encode(redirect) end return run_action("authenticate-post", { method = "POST", body = body }) end local function visit(repo, token) local _, ret = run_action("authenticate-cookie", { repo = repo, cookie = token and session_name .. "=" .. token }) return ret end -- Where the signed way back in a login form leads. local function way_back_in(form) local token = form:match("name='redirect' value='([^']*)'") return token and validate_value("redirect", token) end h.equals("a public repository needs no cookie", visit("unlisted"), 1) h.equals("a protected repository turns a bare request away", visit("secret-repo"), 0) local session = secure_value("username", "Alice", now + 3600, stored_hash) h.equals("a signed session for a member is let through", visit("secret-repo", session), 1) -- A session is signed with its account's hash, so a new password or a -- removed account ends it. local stale = secure_value("username", "Alice", now + 3600, "$6$rounds=300000$old$hash") h.equals("a session from before a password change is turned away", visit("secret-repo", stale), 0) local orphan = secure_value("username", "bob", now + 3600, stored_hash) h.equals("a session for a member with no account is turned away", visit("secret-repo", orphan), 0) local unbound = secure_value("username", "Alice", now + 3600) h.equals("a session signed without its binding does not verify", validate_value("username", unbound, account_hash), nil) h.equals("a memberless repository denies even a valid session", visit("empty-repo", session), 0) h.equals("a repository of two groups lets in a member of the first", visit("both-repo", session), 1) if variant == "file" then h.redirect_file("/etc/cgit-auth/groups", directory .. "/auth-groups-missing") h.equals("as does one whose groups file is missing", visit("secret-repo", session), 0) h.redirect_file("/etc/cgit-auth/groups", directory .. "/auth-groups") end local outsider = secure_value("username", "carol", now + 3600, carol_hash) h.equals("a signed session for an outsider is turned away", visit("secret-repo", outsider), 0) h.equals("while one in the second group lets them in", visit("both-repo", outsider), 1) local forged = session:sub(1, -2) .. (session:sub(-1) == "0" and "1" or "0") h.equals("a forged session is turned away", visit("secret-repo", forged), 0) local expired = secure_value("username", "Alice", now - 10, stored_hash) h.equals("an expired session is turned away", visit("secret-repo", expired), 0) -- Only the __Host- name is safe from a cookie a sibling subdomain planted. local _, planted = run_action("authenticate-cookie", { repo = "secret-repo", cookie = "cgitauth=" .. session }) h.equals("a session under the plain cookie name is turned away", planted, 0) -- Only the secret keeps a visitor from signing a session of their own, so -- one signed under any other secret is worthless. local real_get_secret = get_secret get_secret = function() return string.rep("fedcba9876543210", 4) end h.equals("a session signed under another secret does not verify", validate_value("username", session, account_hash), nil) h.equals("and lets nobody in", visit("secret-repo", session), 0) get_secret = real_get_secret h.equals("while under its own secret it still does", visit("secret-repo", session), 1) local _, ret = run_action("no-such-action", {}) h.equals("an unknown action denies rather than raising", ret, 0) out = run_action("body", { url = "/repo/log/?q=x" }) h.contains("the login form posts to the login url", out, "