-- 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. The scripts take luaossl and luaposix at -- load, so the harness meets both with deterministic stand-ins, which keeps -- every check about this script's own logic, the cookie layout, the expiry -- and field rules, the redirect vetting and the action flows, rather than -- about OpenSSL. The signing secret is made in the run's directory, and -- 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") h.stub_auth_modules() -- The password behind every test account, stored as what the harness crypt -- stand-in returns for it so a login with it verifies and any other fails. local password = "open sesame" local stored_hash = "$6$rounds=300000$testsalt$" .. 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:" .. stored_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\n") groups_file:close() local repos_file = io.open(directory .. "/auth-repos", "w") repos_file:write("secret-repo:devs\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[\"empty-repo\"] = {},\n" .. "\t[\"bare-repo\"] = {},\n" }, { "local users = {\n", "local users = {\n" .. "\tAlice = \"" .. stored_hash .. "\",\n" .. "\tcarol = \"" .. stored_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 its stand-in module, so a check can see whether -- a login spent it and on which hash. local unistd = require("posix.unistd") local real_crypt = unistd.crypt local crypt_settings = {} unistd.crypt = function(trypass, setting) crypt_settings[#crypt_settings + 1] = setting 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) 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.equals("it brings another account's hash to spend instead", stand_in, stored_hash) 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 bare = repo_userset("bare-repo") h.check("a protected repository with no groups at all denies too", bare ~= nil and next(bare) == nil) 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") local refused = repo_userset("unlisted") h.check("a missing repos file refuses every repository", refused ~= nil and next(refused) == nil) 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") end -- The url and cookie helpers. 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. local started = os.clock() get_cookie("a=" .. string.rep(" ", 20000) .. "b", "cgitauth") h.equals("a long run of spaces is read quickly", get_cookie(string.rep(" ", 20000) .. "cgitauth=abc", "cgitauth"), "abc") h.check("and in linear time", os.clock() - started < 0.5) h.check("equal strings compare equal", constant_equals("abc", "abc")) h.check("differing strings do not", not constant_equals("abc", "abd")) 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("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("a missing target is not", not is_safe_redirect(nil)) -- Signing and verification. 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") 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", "|x|1|s|sig"), 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", validate_value("username", "username|alice|1e9|salt|beef"), nil) h.equals("an empty value signs to nothing", secure_value("username", "", 1), "") -- The headers the filter writes itself. 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 {} h.reset() filter_open(action, opts.cookie or "", opts.method or "GET", "", "", "/", "example.org", opts.https or "on", opts.repo or "", "summary", opts.url or "/repo/", "/?p=login") if opts.body then filter_write(opts.body) end local ret = filter_close() return h.output(), ret 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) local outsider = secure_value("username", "carol", now + 3600, stored_hash) h.equals("a signed session for an outsider is turned away", visit("secret-repo", outsider), 0) 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) -- 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, "
") h.contains("and its table is read as layout, not data", out, "") h.equals("a first visit says nothing failed", out:find("login-error", 1, true), nil) h.equals("the form carries a signed way back", way_back_in(out), "/repo/log/?q=x") out = run_action("body", { cookie = session_name .. "=" .. outsider }) h.contains("a visitor signed in without access is told so", out, "") h.check("and can still sign in as someone else", way_back_in(out) ~= nil) out = run_action("body", { cookie = session_name .. "=" .. forged }) h.excludes("a forged session is not taken for a signed-in visitor", out, "signed in") -- The session cookie is Secure, so a form served over plain http would only -- come round again after every login. out = run_action("body", { https = "" }) h.contains("over plain http the form says logging in needs https", out, "") h.check("and is still there to fill in", way_back_in(out) ~= nil) out = run_action("body", {}) h.excludes("over https it says nothing of the kind", out, "only works over https") out = run_action("body", { url = "//evil.example/x" }) h.equals("an unsafe destination is swapped for the login page", way_back_in(out), "/?p=login") h.equals("and is not taken for a failed login", out:find("login-error", 1, true), nil) out = run_action("body", { url = "/repo/log/?q=x&login=failed" }) h.contains("a failed login says so", out, "") h.equals("and leaves the flag out of the way back", way_back_in(out), "/repo/log/?q=x") -- The real get_secret, pointed at a directory nobody made, cannot sign the -- way back, so the page says so whole rather than breaking off in the form. load_script(nil, directory .. "/no-such-directory/auth-secret") out = run_action("body", {}) h.contains("a secret that cannot be made leaves a page saying so", out, "") h.excludes("with no form to fill in", out, "= before + 604800 and expires <= os.time() + 604800, tostring(expires)) h.equals("and logs nothing", h.error_log(), "") out = login("Alice", "wrong", way_back) h.contains("a bad password redirects the same way", out, "Status: 302") h.contains("back to the page with the failure flagged", out, "Location: /repo/?login=failed\n") h.contains("but clears the session cookie", out, "Set-Cookie: " .. session_name .. "=; ") h.contains("and is logged with the name and the page", h.error_log(), "[cgit] Failed login as Alice for /repo/\n") h.excludes("but never with the password", h.error_log(), "wrong") login("eve\r\n[cgit] Forged", "x", way_back) h.contains("a name cannot forge a log line of its own", h.error_log(), "[cgit] Failed login as eve[cgit] Forged for /repo/\n") -- A failed login for a name with no account spends crypt on a configured -- hash, the same work a real check costs, so timing tells nothing. crypt_settings = {} out = login("nobody", "x", way_back) h.contains("an unknown user is told nothing different", out, "Set-Cookie: " .. session_name .. "=; ") h.contains("and lands on the same flagged page", out, "Location: /repo/?login=failed\n") h.equals("a missing account spends crypt on a configured hash", crypt_settings[1], stored_hash) if variant == "file" then h.redirect_file("/etc/cgit-auth/users", directory .. "/auth-users-missing") crypt_settings = {} login("nobody", "x", way_back) h.equals("with no accounts at all nothing is spent", #crypt_settings, 0) h.redirect_file("/etc/cgit-auth/users", directory .. "/auth-users") end -- crypt's work grows with the password, so one past the cap is turned down -- before crypt sees it. crypt_settings = {} out = login("Alice", string.rep("a", 257), way_back) h.contains("an overlong password is turned down", out, "Set-Cookie: " .. session_name .. "=; ") h.equals("without spending crypt on it", #crypt_settings, 0) login("Alice", string.rep("a", 256), way_back) h.equals("a password at the cap is still checked", #crypt_settings, 1) out = login("Alice", password) h.contains("a post without the signed token is not served", out, "Status: 404") out = login("Alice", password, secure_value("redirect", "//evil.example/", 0)) h.contains("even a signed unsafe destination is not followed", out, "Status: 404") -- Over plain http the cookie cannot be Secure, and so loses the __Host- name. load_script({ { "local cookie_insecure = false", "local cookie_insecure = true" } }) out = login("Alice", password, way_back) h.contains("an insecure session cookie keeps its plain name", out, "Set-Cookie: cgitauth=") h.excludes("and is not marked Secure", out, "; Secure") out = run_action("body", { https = "" }) h.excludes("so its form does not ask for https", out, "only works over https") load_script() -- A cookie carries whole seconds, while on Lua 5.3 and later a session length -- worked out with a fraction is a float, which tostring writes with a ".0". load_script({ { "local session_seconds = 7 * 24 * 60 * 60", "local session_seconds = 1.5 * 24 * 60 * 60" } }) before = os.time() out = login("Alice", password, way_back) granted = out:match("Set%-Cookie: __Host%-cgitauth=([^;]*);") h.equals("a session a day and a half long still verifies", granted and validate_value("username", granted, account_hash), "Alice") expires = session_expiry(granted) h.check("and expires as long after the login", expires and expires >= before + 129600 and expires <= os.time() + 129600, tostring(expires)) h.contains("and its cookie lasts as many whole seconds", out, "; Max-Age=129600\n") load_script() -- The libraries load only once a request needs one, so with every one of -- them gone a public repository is still served, while a protected one -- fails closed instead of reaching its content. for _, name in ipairs({ "openssl.rand", "openssl.hmac", "posix.sys.stat", "posix.unistd" }) do package.loaded[name] = nil h.preload_failure(name) end h.equals("without the libraries a public repository is served", visit("unlisted"), 1) h.check("while a protected one fails closed", not pcall(visit, "secret-repo", session)) out = run_action("body", {}) h.contains("and its login page says logins are unavailable", out, "Logins are unavailable.") h.excludes("and no form to fill in", out, "