-- 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, "
") h.contains("and its table is read as layout, not data", out, "") -- login() posts the fields under names of its own, so nothing else holds -- the form to the names a login reads. h.contains("the form names its fields as a login reads them", out, "You are signed in but have no access to this repository.

") h.check("and can still sign in as someone else", way_back_in(out) ~= nil) out = run_action("body", { cookie = session_name .. "=" .. forged }) h.contains("a visitor with a forged session is still shown the form", out, "Logging in only works over https.

") h.check("and is still there to fill in", way_back_in(out) ~= nil) out = run_action("body", {}) h.contains("over https the form is shown", out, "Wrong username or password.

") h.equals("and leaves the flag out of the way back", way_back_in(out), "/repo/log/?q=x") -- A page with no query of its own comes back flagged after a question mark. out = run_action("body", { url = "/repo/?login=failed" }) h.contains("a flag after a question mark says so too", out, "") h.equals("and leaves the flag and its mark out of the way back", way_back_in(out), "/repo/") -- 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)) -- A plain == at either comparison would leave every check above passing -- while timing told a visitor how much of a guess was right. local real_constant_equals = constant_equals local compared = {} constant_equals = function(a, b) compared[#compared + 1] = { a, b } return real_constant_equals(a, b) end local function was_compared(value) for _, pair in ipairs(compared) do if pair[1] == value or pair[2] == value then return true end end return false end visit("secret-repo", session) h.check("a visit compares the signature in constant time", was_compared(session:match("[^|]+$"))) compared = {} login("Alice", password, way_back) h.check("and a login the password's hash", was_compared(stored_hash)) constant_equals = real_constant_equals h.equals("and logs nothing", h.error_log(), "") out = login("Alice", "wrong", way_back) local wrong_password = out 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 .. "=; ") crypt_unchecked = true local answered, answer = pcall(login, "Alice", password, way_back) crypt_unchecked = false h.check("a hash the system cannot check turns a login down without an error", answered, tostring(answer)) h.equals("as a wrong password does", answer, wrong_password) 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", "unknown secret", way_back) h.equals("an unknown user is told nothing different", out, wrong_password) h.check("a missing account spends crypt on a configured hash", crypt_settings[1] == stored_hash or crypt_settings[1] == carol_hash, tostring(crypt_settings[1])) h.contains("and is logged by name", h.error_log(), "[cgit] Failed login as nobody for /repo/\n") h.excludes("but never with the password", h.error_log(), "unknown secret") -- A post missing a field, or with a field that has no value, is a failed -- login like any other rather than an error. local back = url_encode(way_back) for _, case in ipairs({ { "with no name", "password=hunter2&redirect=" .. back, "" }, { "with no password", "username=Alice&redirect=" .. back, "Alice" }, { "with neither", "redirect=" .. back, "" }, { "with a field that has no value", "hunter2&redirect=" .. back, "" }, }) do local answered, answer = pcall(run_action, "authenticate-post", { method = "POST", body = case[2] }) h.check("a login " .. case[1] .. " is answered without an error", answered, tostring(answer)) h.contains("a login " .. case[1] .. " clears the session cookie", tostring(answer), "Set-Cookie: " .. session_name .. "=; ") h.contains("a login " .. case[1] .. " lands on the flagged page", tostring(answer), "Location: /repo/?login=failed\n") h.contains("a login " .. case[1] .. " is logged", h.error_log(), "[cgit] Failed login as " .. case[3] .. " for /repo/\n") h.excludes("a login " .. case[1] .. " never logs a password", h.error_log(), "hunter2") end 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) write_file(directory .. "/auth-users-unparsable", "not a parsable line\nnor this one\n") h.redirect_file("/etc/cgit-auth/users", directory .. "/auth-users-unparsable") crypt_settings = {} out = login("Alice", password, way_back) h.contains("a users file with no line that parses turns a login down", out, "Location: /repo/?login=failed\n") h.equals("and spends nothing either", #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) h.contains("and is logged like any failed login", h.error_log(), "[cgit] Failed login as Alice for /repo/\n") h.excludes("without the password", h.error_log(), string.rep("a", 257)) 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") out = login("Alice", password, secure_value("redirect", "https://evil.example/", 0)) h.contains("nor a signed one with a scheme", 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.contains("so its form is shown over plain http", out, "= 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() local libraries = { "openssl.rand", "openssl.hmac", "posix.sys.stat", "posix.unistd" } local function without(name) package.loaded[name] = nil h.preload_failure(name) end -- Before the secret is made a login needs every library, so with any one of -- them gone the form gives way to a page saying logins are unavailable, -- while a session or a posted login fails closed, and each says which. for _, name in ipairs(libraries) do local loaded, loader = package.loaded[name], package.preload[name] without(name) load_script(nil, directory .. "/unmade-secret") out = run_action("body", {}) h.contains("without " .. name .. " the login page says logins are unavailable", out, "Logins are unavailable.") h.excludes("without " .. name .. " it has no form", out, "