-- Unit checks for the two auth filters, run under a standalone Lua by -- t0505-auth.sh with the script path as the first argument and the variant, -- inline or file, as the second. 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 pinned by replacing get_secret, 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] 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("auth-users", "w") users_file:write("Alice:" .. stored_hash .. "\r\n") users_file:write("not a parsable line\n") users_file:close() local groups_file = io.open("auth-groups", "w") groups_file:write("devs:Alice, bob\n") groups_file:write("others:carol\n") groups_file:close() local repos_file = io.open("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", "auth-users") h.redirect_file("/etc/cgit-auth/groups", "auth-groups") h.redirect_file("/etc/cgit-auth/repos", "auth-repos") end if variant == "inline" then h.load_with(script, { { "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" }, }) else h.load(script) end function get_secret() return string.rep("0123456789abcdef", 4) end -- 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 -- 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) h.equals("a nil user is not", account_hash(nil), nil) 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", "auth-users-missing") h.equals("a missing users file turns every login down", account_hash("alice"), nil) h.redirect_file("/etc/cgit-auth/users", "auth-users") -- Without the repos file nothing tells a protected repository from a -- public one, so every repository is refused while the index stays up. h.redirect_file("/etc/cgit-auth/repos", "auth-repos-missing") local refused = repo_userset("unlisted") h.check("a missing repos file refuses every repository", refused ~= nil and next(refused) == nil) h.equals("the index stays public all the same", repo_userset(nil), nil) h.redirect_file("/etc/cgit-auth/repos", "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 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)) h.equals("control characters are stripped from header values", strip_controls("a\r\nb"), "ab") -- 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) 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) 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("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", "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 local ret out, ret = run_action("authenticate-cookie", { repo = "unlisted" }) h.equals("a public repository needs no cookie", ret, 1) out, ret = run_action("authenticate-cookie", { repo = "secret-repo" }) h.equals("a protected repository turns a bare request away", ret, 0) local session = secure_value("username", "Alice", now + 3600) out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = "cgitauth=" .. session }) h.equals("a signed session for a member is let through", ret, 1) out, ret = run_action("authenticate-cookie", { repo = "empty-repo", cookie = "cgitauth=" .. session }) h.equals("a memberless repository denies even a valid session", ret, 0) local outsider = secure_value("username", "carol", now + 3600) out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = "cgitauth=" .. outsider }) h.equals("a signed session for an outsider is turned away", ret, 0) local forged = session:sub(1, -2) .. (session:sub(-1) == "0" and "1" or "0") out, ret = run_action("authenticate-cookie", { repo = "secret-repo", cookie = "cgitauth=" .. forged }) h.equals("a forged session is turned away", ret, 0) out, ret = run_action("no-such-action", {}) h.equals("an unknown action denies rather than raising", ret, 0) out, ret = run_action("body", { url = "/repo/log/?q=x" }) h.contains("the login form posts to the login url", out, "
") local token = out:match("name='redirect' value='([^']*)'") h.equals("the form carries a signed way back", token and validate_value("redirect", token), "/repo/log/?q=x") out, ret = run_action("body", { url = "//evil.example/x" }) token = out:match("name='redirect' value='([^']*)'") h.equals("an unsafe destination is swapped for the login page", token and validate_value("redirect", token), "/?p=login") local way_back = secure_value("redirect", "/repo/", 0) out, ret = run_action("authenticate-post", { method = "POST", body = "username=Alice&password=" .. url_encode(password) .. "&redirect=" .. url_encode(way_back), }) h.contains("a good login redirects back", out, "Status: 302") h.contains("to where the form said", out, "Location: /repo/\n") local granted = out:match("Set%-Cookie: cgitauth=([^;]*);") h.equals("and grants a session that verifies", granted and validate_value("username", granted), "Alice") out, ret = run_action("authenticate-post", { method = "POST", body = "username=Alice&password=wrong&redirect=" .. url_encode(way_back), }) h.contains("a bad password redirects the same way", out, "Status: 302") h.contains("but clears the session cookie", out, "Set-Cookie: cgitauth=; ") out, ret = run_action("authenticate-post", { method = "POST", body = "username=nobody&password=x&redirect=" .. url_encode(way_back), }) h.contains("an unknown user is told nothing different", out, "Set-Cookie: cgitauth=; ") -- 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 = {} run_action("authenticate-post", { method = "POST", body = "username=nobody&password=x&redirect=" .. url_encode(way_back), }) 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", "auth-users-missing") crypt_settings = {} run_action("authenticate-post", { method = "POST", body = "username=nobody&password=x&redirect=" .. url_encode(way_back), }) h.equals("with no accounts at all nothing is spent", #crypt_settings, 0) h.redirect_file("/etc/cgit-auth/users", "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, ret = run_action("authenticate-post", { method = "POST", body = "username=Alice&password=" .. string.rep("a", 257) .. "&redirect=" .. url_encode(way_back), }) h.contains("an overlong password is turned down", out, "Set-Cookie: cgitauth=; ") h.equals("without spending crypt on it", #crypt_settings, 0) run_action("authenticate-post", { method = "POST", body = "username=Alice&password=" .. string.rep("a", 256) .. "&redirect=" .. url_encode(way_back), }) h.equals("a password at the cap is still checked", #crypt_settings, 1) out, ret = run_action("authenticate-post", { method = "POST", body = "username=Alice&password=" .. url_encode(password), }) h.contains("a post without the signed token is not served", out, "Status: 404") local hijack = secure_value("redirect", "//evil.example/", 0) out, ret = run_action("authenticate-post", { method = "POST", body = "username=Alice&password=" .. url_encode(password) .. "&redirect=" .. url_encode(hijack), }) h.contains("even a signed unsafe destination is not followed", out, "Status: 404") -- 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 out, ret = run_action("authenticate-cookie", { repo = "unlisted" }) h.equals("without the libraries a public repository is served", ret, 1) h.check("while a protected one fails closed", not pcall(run_action, "authenticate-cookie", { repo = "secret-repo", cookie = "cgitauth=" .. session })) h.check("and so does its login form", not pcall(run_action, "body", {})) h.finish()